Most senior engineers know git as a workflow: branch, commit, rebase, push. gateline uses it as something else: a small transactional database that happens to store source code. The framework has no server, no lock service, and no message queue; two independent writers edit one file concurrently and neither can clobber the other. This module walks the git primitives that make that possible, and for each one shows the place in the codebase that would collapse without it.
The thesis is written down in the repository, at the top of the module
that wraps the git CLI: reads always address refs (never the working
tree); writes go through plumbing so no checkout is ever touched. Not one
read method on the Git class in
packages/core/src/sources/git.ts takes a working-tree
path; the ones that read content take a revision. gateline can observe and
write a run branch that is checked out nowhere, on a machine whose working
tree is on some unrelated branch, mid-rebase, and dirty.
Plumbing and porcelain are git's own
terms for its two command layers. Porcelain is the human interface
(git commit, git merge, git pull), and
it assumes a working tree and an index belonging to a person. Plumbing is the
scriptable layer underneath:
hash-object, read-tree, write-tree,
commit-tree, update-ref. git-commit-tree(1)
says as much in its own description: "This is usually not what an end
user wants to run directly."
Objects: a content-addressed database that happens to store code
A git repository is an object store with exactly four object types: blob,
tree, commit, tag. gitglossary(7) defines an
object as "the unit of storage in Git. It is uniquely
identified by the SHA-1 of its contents. Consequently, an object cannot be
changed." A tree object is "an object containing a list of file names
and modes along with refs to the associated blob and/or tree objects", and a
commit names one tree plus its metadata.
Two consequences do most of the work here. The object id is a function of the content, so identical content anywhere in history is one object. And names and modes live in the tree, not in the blob, which is why building a commit programmatically takes more than writing a blob: you must also produce a tree binding the blob to a path and a file mode.
Exercise 1 — objects, in a repository you can throw away
Build a scratch repository and look at what git actually stored. The object ids are reproducible because the identity and the commit dates are pinned; unpinned, only the commit id would vary — the blob id is a pure function of the bytes.
d=$(mktemp -d)/objects; git init -q -b main "$d" && cd "$d"
git config user.email demo@example.invalid && git config user.name demo
git config commit.gpgsign false
export GIT_AUTHOR_DATE='2026-01-01T00:00:00+0000' GIT_COMMITTER_DATE='2026-01-01T00:00:00+0000'
printf 'one\n' > f.txt && git add f.txt && git commit -qm c1
printf 'one\n' | git hash-object --stdin # id of the content, computed without a repo
git rev-parse HEAD:f.txt # id git stored for that path
git cat-file -p HEAD # a commit is a tree plus metadata
git ls-tree HEAD # the tree carries the name and the mode
5626abf0f72e58d7a153368ba57db4c673c0e171
5626abf0f72e58d7a153368ba57db4c673c0e171
tree 7385b9ca65269b27de63aea3ddff716dd768c253
author demo <demo@example.invalid> 1767225600 +0000
committer demo <demo@example.invalid> 1767225600 +0000
c1
100644 blob 5626abf0f72e58d7a153368ba57db4c673c0e171 f.txt
The first two lines are the same forty characters: hash-object
computed the id of four bytes on standard input, and rev-parse
asked the repository what it had stored at that path. Note what
cat-file -p HEAD does not contain: the filename. Only
ls-tree shows it, alongside the mode 100644.
Where gateline lives on this
The worked example is writeTreeWithBlob in
git.ts, docblocked as building "a tree that is
baseCommit's tree with one blob replaced, without touching any
index or working tree the user owns". It points GIT_INDEX_FILE at
a temporary file, runs read-tree,
update-index --add --cacheinfo 100644,<blob>,<path>
and write-tree against that scratch index, and deletes the
directory in a finally. git(1) documents
GIT_INDEX_FILE as specifying "an alternate index file", and that
is the whole of it: the variable relocates the index and changes nothing
else. The operator's index at $GIT_DIR/index is left unread and
unwritten.
commitTree then makes the commit, setting
GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL,
GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL from an
optional identity argument. That is how the orchestrator commits under its own
bot identity without ever mutating git config. A design that
switched git config user.name instead would be writing shared,
persistent state that any concurrent human commit would inherit.
Refs: mutable pointers, and the one operation that makes them safe
Objects are immutable, so everything that changes in a repository changes
in the refs. gitglossary(7) defines a ref as "a
name that points to an object name or another ref", and adds the rule that
matters for library design:
"The ref namespace is hierarchical. Ref names must either start with refs/ or
be located in the root of the hierarchy." A branch is nothing but a ref under
refs/heads/. Because names only have to start with
refs/, a program can carve out a namespace nothing else will
collide with. gateline uses refs/gateline/wip/<branch> to
hold a write-ahead intent commit while a state write is in flight: a ref no
branch reaches, keeping an object alive without putting it in anybody's
history.
A pseudoref is the exception you have to know about; the
glossary defines it as "a ref that has different semantics than normal refs. These refs can be read via
normal Git commands, but cannot be written to by commands like
git-update-ref(1)." MERGE_HEAD and FETCH_HEAD are
pseudorefs, which is why gateline's code-tree monitor tests for the
existence of MERGE_HEAD, never transacts on it.
The three-argument update-ref
Here is the primitive the whole framework rests on. Most engineers have
only ever met update-ref in its two-argument form, a blunt
"point this ref here". git-update-ref(1) states the
three-argument form:
Given three arguments, stores the <new-oid> in the <ref>, possibly dereferencing the symbolic refs, after verifying that the current value of the <ref> matches <old-oid>. E.g. git update-ref refs/heads/master <new-oid> <old-oid> updates the master branch head to <new-oid> only if its current value is <old-oid>. You can specify 40 "0" or an empty string as <old-oid> to make sure that the ref you are creating does not exist.
This is a compare-and-swap in the sense the concurrency literature uses the term. That framing is this guide's analysis, not language git uses. Git's documentation never argues that the three-argument form defeats a race; it states a conditional update and describes locking. The conclusion is ours: an update that applies only if the observed value is still current is what lets independent writers share mutable state without a lock.
Exercise 2 — feel the refusal
Two commits, then two ref updates: one whose stated old value is true, one whose stated old value is stale. Continue in a fresh scratch repository.
d=$(mktemp -d)/cas; git init -q -b main "$d" && cd "$d"
git config user.email demo@example.invalid && git config user.name demo
git config commit.gpgsign false
export GIT_AUTHOR_DATE='2026-01-01T00:00:00+0000' GIT_COMMITTER_DATE='2026-01-01T00:00:00+0000'
printf 'one\n' > f.txt && git add f.txt && git commit -qm c1 && old=$(git rev-parse HEAD)
printf 'two\n' >> f.txt && git commit -qam c2 && new=$(git rev-parse HEAD)
git update-ref refs/heads/main "$old" "$new"; echo "rewind, old value matches: exit=$?"
git update-ref refs/heads/main "$new" "$new"; echo "replay, old value stale: exit=$?"
rewind, old value matches: exit=0
fatal: update_ref failed for ref 'refs/heads/main': cannot lock ref 'refs/heads/main': is at 0c807259e9a3fc6eb1e46f87a61fffadbd2f4b2f but expected c6534426b98a60e379172034b78861edc0b4cdd1
replay, old value stale: exit=128
Note two things. The first call moved a branch backwards —
update-ref has no opinion about direction, only about the old
value. And the refusal names both the value found and the value expected,
which is what a writer needs in order to re-read and re-derive.
Where gateline lives on this
The helper is updateRefCAS in git.ts: a
try around
update-ref <ref> <new> <expectedOld> returning
true, with a catch returning false. Its
docblock says what the false means: "the ref no longer points at
expectedOld — the caller re-reads and re-presents."
Failure is a return value, not an exception. That one design choice is what makes losing the race ordinary. Both writers read the branch tip, both build a commit on it, and the ref update admits exactly one. The two-writer figure on the gates page draws the loop; module 2 takes apart what the loser does next.
Note what updateRefCAS does not do: it does not
retry. Retrying is the caller's job, it is attempted only for the one refusal
reason a retry can fix, and it is bounded: the orchestrator's
closing-bookkeeping loop gives up after five attempts and logs.
The all-zeros old value: create-only
The man-page quote's last sentence is a separate primitive hiding in the
same command. Passing forty zeros as the old value asserts this ref does not
exist, so the update succeeds only for the writer who gets there first.
gateline names that value ZERO_OID and uses it in three places:
minting a run branch when a run is staged, creating a sweep branch when a
scheduled role comes due, and materializing a remote-only run branch locally.
The scheduler's own header names the pattern: branch creation from
ZERO_OID is the CAS duplicate-dispatch guard.
Exercise 3 — create-only, as a duplicate-dispatch guard
Two orchestrator instances wake at the same moment, both decide the weekly documentation sweep is due, and both try to create the same branch. Continue in the repository from exercise 2.
zero=0000000000000000000000000000000000000000
git update-ref refs/heads/sweep-2026-01-01 "$old" "$zero"; echo "first writer: exit=$?"
git update-ref refs/heads/sweep-2026-01-01 "$old" "$zero"; echo "second writer: exit=$?"
first writer: exit=0
fatal: update_ref failed for ref 'refs/heads/sweep-2026-01-01': cannot lock ref 'refs/heads/sweep-2026-01-01': reference already exists
second writer: exit=128
Exactly one sweep is dispatched, and no coordination was needed to arrange it. The loser's correct response is not to retry but to re-scan and re-derive: the work it wanted may already be in flight.
Reading a file without checking anything out
The syntax is one colon, and gitrevisions(7) defines it: "A
suffix : followed by a path names the blob or tree at the given path in the
tree-ish object named by the part before the colon." So
<rev>:<path> addresses a blob inside a commit.
There is an adjacent syntax one character away that means something else
entirely. gitrevisions(7) also defines
:[<n>:]<path> as naming "a blob object in the index
at the given path". A leading colon reads your index; a revision before the
colon reads a commit. Every read in gateline puts a revision before the
colon. There is no index in the picture at all.
Exercise 4 — read a run's spine from a branch you have not checked out
In any clone of the framework repository, print the first lines of a finished run's state file straight out of a commit. Your working tree is not touched.
git show main:runs/dupefind/state.yaml | head -6
# Contract: maintained by Orchestrator (human in v0); read by everyone.
# Lives at runs/<slug>/state.yaml — the single source of truth for a run.
run: dupefind
branch: run/dupefind
phase: done # spec | plan | implement | integrate | release | done | paused
Note the fifth line. The file names a branch
run/dupefind that no longer exists anywhere in the repository —
the run merged and the branch was pruned. Its record survives on
main regardless, because the record was never the branch; it
was always the files.
Where gateline lives on this
Git.show wraps that syntax with one refinement worth copying:
a missing path or revision returns null and never throws. A
regular expression matches git's "does not exist" family of errors, so
"is there a verification-report.md on this branch yet?" is a
read rather than an exception handler.
Because reads address refs, and refs include raw object ids, the past is
just another ref: shadow replay hands the observer a run reference whose
revision is a commit id, not a branch name, and the same
derivation code then runs against a point in history with no special mode. One caution when you go looking for a run this
way: in a fresh clone the only reference to an unmerged run branch is the
remote-tracking one, so origin/run/<slug> is the portable
form.
Fast-forward-only as a policy primitive
Most engineers meet fast-forward as a merge preference. It is more useful
read as a safety property. gitglossary(7) defines it as the case
where the branch you are merging is "a descendant of what you have", so you "do
not make a new merge commit but instead just update your branch to point at the
same revision". That last clause connects this section to the last one: a
fast-forward creates no object, it is a pure pointer move. That is why
"fast-forward only" is a usable policy. It says history is only ever appended
to, never rewritten underneath anyone. Enforcing it takes three mechanisms, and
they are not interchangeable.
- The flag
git merge --ff-onlywill "resolve the merge as a fast-forward when possible. When not possible, refuse to merge and exit with a non-zero status."git pull --ff-onlyis documented differently ("only update to the new history if there is no divergent local history"), so quote the page you mean.- The refspec
- A fetch refspec written
refs/heads/<b>:refs/heads/<b>, with no leading+, is fast-forward-only by grammar: the plus sign is what would make it forced. gateline'ssyncFromRemotebuilds exactly that spec, one per branch, so a local branch carrying an unpushed decision commit is never clobbered by a sync. - The ancestry test
- To decide for yourself whether a move would be a fast-forward,
ask whether the old commit is an ancestor of the new one:
git merge-base --is-ancestor. This is what the code-tree monitor calls.
The refspec carries a second lesson. A non-fast-forward fetch is a
per-ref refusal: the other refspecs still apply and the
remote-tracking refs still update, which is why the code deliberately swallows
that failure. What is fatal, and aborts the whole batch, is fetching
into a branch checked out in a worktree, which is why
syncFromRemote filters those out first.
Exercise 5 — the fast-forward test, and its third exit code
merge-base --is-ancestor answers by exit status, and it has
three answers, not two.
d=$(mktemp -d)/ff; git init -q -b main "$d" && cd "$d"
git config user.email demo@example.invalid && git config user.name demo
git config commit.gpgsign false
printf 'v1\n' > f.txt && git add f.txt && git commit -qm c1 && start=$(git rev-parse HEAD)
printf 'v2\n' > f.txt && git commit -qam c2 && head=$(git rev-parse HEAD)
git merge-base --is-ancestor "$start" "$head"; echo "moved forward: exit=$?"
git merge-base --is-ancestor "$head" "$start"; echo "moved backward: exit=$?"
git merge-base --is-ancestor "$start" deadbeefdeadbeefdeadbeefdeadbeefdeadbeef; echo "unknown commit: exit=$?"
moved forward: exit=0
moved backward: exit=1
fatal: Not a valid commit name deadbeefdeadbeefdeadbeefdeadbeefdeadbeef
unknown commit: exit=128
git-merge-base(1) is explicit that "errors are signaled by a
non-zero status that is not 1". gateline's isAncestor catches
every failure and returns false, which folds exit 128 into "not
an ancestor". In the monitor's context that is fail-safe — an error reads as
"not a fast-forward" and pauses dispatch — but it is a conflation, and worth
knowing about before you reuse the helper somewhere the two cases differ.
Where gateline lives on this: the code-tree monitor
The checkout the engine's own code runs from stays on the default branch
and moves only by clean fast-forward. CodeTreeMonitor enforces
that, and its decision order reads as a specification. On each check it asks,
in order: is the working tree dirty? Is a rebase or merge in progress — do the
rebase-merge, rebase-apply or MERGE_HEAD
git-dir markers exist? Has HEAD not moved at all since the process started? Is
HEAD detached? Is the branch something other than the default branch? And is
the commit the process started on an ancestor of the current HEAD? Answered
yes, the first two pause dispatch, and so do the fourth and fifth; the last
pauses dispatch when answered no, and counts as an update when
answered yes. The third is the odd one out, and it is the answer on nearly
every check of a healthy engine: HEAD has not moved means the tree is fresh,
and that observation also clears any in-flight supersede debounce. A pause
carries a machine-readable cause naming which question failed
(dirty, in-progress, detached,
off-default-branch or non-fast-forward), and a
dirty tree that is hiding a queued fast-forward also sets
upgradeBlocked, so the operator can see that a pull is waiting
behind their uncommitted change.
A fast-forward must be observed on two consecutive checks before it is
confirmed, so a check racing a still-running git pull does not
fire on a half-updated tree; when it is confirmed, the process drains and
exits with EX_TEMPFAIL. The engine never pulls: updating the code
is an operator act, and the engine's only move is to stand down. That act has
a command, gateline self-update, which pulls, rebuilds the web
dist, and leaves the running engine to supersede itself.
Maintainer note
The one-authority rule was not designed up front. It was written after a bad day. The topology design records that within a single day of running Gatehouse hosted and the orchestrator on a workstation, every seam between them produced an incident: a human raised a budget limit on origin, and the workstation engine, reading a local branch that nothing ever fast-forwarded, kept deriving against the stale limit and re-escalated within seconds. The document's own diagnosis is the sentence to remember: the state store was replicated, and both replicas accepted writes.
Worktrees: one repository, several checkouts
git-worktree(1) describes a repository as supporting "multiple
working trees, allowing you to check out more than one branch at a time",
sharing "everything except per-worktree files such as HEAD, index, etc.".
The rule that makes worktrees useful here is in that page's REFS section:
"In general, all pseudo refs are per-worktree and all refs starting with refs/
are shared." The next sentence is half the rule: "There are exceptions,
however: refs inside refs/bisect, refs/worktree and refs/rewritten are not
shared." So refs/heads/* is shared across every worktree, and so
is gateline's own refs/gateline/wip/*, clear of all three
exceptions, while HEAD and the index are not.
Where gateline lives on this
Worktree awareness changes the write path here.
Git.worktrees parses git worktree list --porcelain
into paths and branches, and decisions across core and the orchestrator
consult it: from which branches may be fetched to whether a rejected push
may be rolled back. The governing sentence is a code comment in
writeState: if the branch is checked out somewhere,
an update-ref behind its back would leave that checkout silently
diverged.
Commit messages as a protocol, and the limit of that idea
Every commit subject this system produces is a template literal, never free
text. Human decisions get sentences
(state(<slug>): G2 approved by <name> [burden:
confirmation], staged by <name>, armed by
<name>, closed by <name> [disposition: …]),
and the orchestrator gets a reserved set of bookkeeping verbs instead. So a run branch reads as a transcript of a partnership, in
git log, with no tooling at all.
It is evidence for humans. The grammar is not the machine's input
to any decision. Two places in the codebase parse a commit subject, and
neither decides anything from it: a regular expression in the observer counts
how often an artifact was bounced since its last resolved dispute, and
Gatehouse's decision ledger in
packages/core/src/view-model/ledger.ts turns each
state(<slug>): … subject into a typed entry to render,
showing a subject it cannot parse verbatim, not a guess. Everything
that decides reads the committed files. That is also why a reflog, which
nothing in gateline reads or writes, is not part of the record. Module 2
picks that thread up.
Check your understanding
- A blob's object id is a function of its contents alone. Where does a
file's name live, and why does that force
writeTreeWithBlobto runupdate-index --cacheinfo? updateRefCASreturnsfalseinstead of throwing. Name two consequences for callers.- What does passing forty zeros as
<old-oid>assert, and which operations in the framework depend on it? - Name the three mechanisms this codebase uses to enforce fast-forward-only, and which one protects an unpushed decision commit during a sync.
- Which refs are shared across all worktrees of a repository, and which are not?
- The commit grammar makes authorship auditable, and Gatehouse renders it. What does it still not do?
Further reading
Read packages/core/src/sources/git.ts end to end:
about three hundred lines, and the whole surface this module describes. Then
local-source.ts, whose writeState uses every
primitive here at once and is the subject of
module 2; then
docs/TOPOLOGY.md §3 and docs/ORCHESTRATOR.md §13. On
git's side: gitglossary(7), gitrevisions(7),
git-update-ref(1) and git-worktree(1).