3 The Stiftfile
dxtr edited this page 2026-10-16 17:53:33 +02:00

The Stiftfile

A project's stiftfile sits at its root, in plain sight, as a qlfile or a Makefile does. It is the only file stift needs: it holds what the project asks for, and the commit chosen for each project of its build, so it does the work of both a qlfile and a qlfile.lock. Commit it with the project.

;;; -*- mode: lisp-data -*-
(:project "fakehacker.cc/dxtr/pyssel"
 :require (("https://fakehacker.cc/dxtr/cl-gfx.git" :branch "main"
            :commit "5e03cf5ae3ff322ec143280a69680ed4b13ce233")
           ("https://github.com/Shinmera/parachute.git" :branch "master"
            :commit "9a6679e611925dfb59067393c5b7996f69501aa6" :test t)
           ("https://fakehacker.cc/dxtr/cl-ffi.git" :branch "main"
            :commit "b7248691986fdeb2e7b4c0bcb7463ea3375089bb"
            :indirect t)))

The first line is a mode line, so that Emacs opens the file as Lisp data.

Properties

The file holds one property list, data that describes the project, as a defsystem form describes a system:

  • :project NAME comes first and names the project: the host and path of its repository (see Naming projects).
  • :require (ENTRY ...) holds an entry for each project required. (URL :commit ID ...) requires the project at URL, at the commit ID, a full commit ID: 40 hexadecimal digits, or 64 in a repository that uses SHA-256, in lower case. The commit or any commit that contains it meets the requirement. An entry's other keys:
    • :branch NAME or :tag NAME, the ref the commit was read from, never both, which update follows;
    • :test t, for a project only this project's own tests need, such as Parachute;
    • :indirect t, on the entries tidy writes: projects that only the projects this one requires need (see Choosing Commits);
    • :exclude ("DIR/" ...), directories of the project required, relative to its root, whose systems stift leaves out, such as a copy of another library it carries.
  • :replace (ENTRY ...) holds an entry (URL ...) for each project whose files it chooses, whatever the requirements say:
    • :commit ID, that commit;
    • :with URL2 :commit ID, that commit of another repository, such as a fork;
    • :local DIR, the files of a directory, relative to this project's;
    • :branch NAME or :tag NAME beside :commit, the ref update follows.

:require and :replace are left out when they would be empty. A project may be required once and replaced once, and a project can neither require nor replace itself.

Which entries count where

The stiftfile of each project required counts too: stift reads it at the commit required, and follows its requirements (see Choosing Commits). Three things count only in the main project, the one being built:

  • requirements marked :test, since nobody who uses a library needs its test framework;
  • :replace, and
  • :exclude, since a library cannot rearrange the builds of the projects that use it.

Reading and writing it

stift reads the file with a reader of its own, not with read, since it reads other people's stiftfiles too. It accepts lists, strings, the keys above, integers, t, nil and comments (; and #| |#), and nothing else: no #., no quotes, no symbols. It evaluates nothing and interns nothing. A mistake is reported with the file, the line and the column:

/home/me/hello/stiftfile:4:13: :colour is not a key stift knows.

stift get, remove, tidy and update keep the direct requirements first and the indirect ones after them, each sorted by name, and the replacements sorted by name, so that a new entry lands in a place of its own, and two branches that each add one rarely touch the same line. They edit the file entry by entry:

  • An entry they do not change keeps its text.
  • The comments on lines of their own above an entry, and those after it on its line, stay with it, even when it moves, as a requirement that becomes indirect does.
  • An entry they write is laid out by stift rather than by the Lisp printer, so that SBCL and CCL write the same bytes: the URL on the first line, then the keys, as many to a line as fit in 72 characters.
  • A property whose list becomes empty goes, and one that comes is written in its place among the others.

Naming projects

A project's name is the host and path of its repository, without the scheme, the user, the port, a trailing / and a final .git; for a repository on this machine, its path. Every URL of a repository gives the same name:

URL Project
https://fakehacker.cc/dxtr/cl-ffi.git fakehacker.cc/dxtr/cl-ffi
git@fakehacker.cc:dxtr/cl-ffi fakehacker.cc/dxtr/cl-ffi
ssh://git@fakehacker.cc:2222/dxtr/cl-ffi/ fakehacker.cc/dxtr/cl-ffi
file:///srv/git/x.git /srv/git/x

A stiftfile's :project gives the name in that form, which stift init writes from a name or a URL. The name keeps no more than it needs, and, unlike a short name such as cl-ffi, no other project can have it: with no registry, two repositories of one short name, a fork and its original or two unrelated libraries, would be taken for one project.

Paths keep their case. How a URL is fetched is git's business: a stiftfile written with https:// URLs can be fetched over SSH through git's url.<base>.insteadOf, so stiftfiles need not differ between people.

stift fetches from https://, ssh://, USER@HOST:PATH and file:///PATH URLs. It refuses http:// and git://: a branch read over them could be changed on the way, and the commit recorded from it trusted from then on.