Git, down to the plumbing

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.

The four-layer addressing chain gateline reads and writes: a ref names a commit, a commit names a tree, a tree binds names and modes to blobs. Only the ref is mutable.
Four columns left to right — refs, commits, trees, blobs — split by a mutability divider. Refs are the only mutable layer; commits, trees and blobs are immutable and content-addressed. The tree binds modes and object ids to names, and one state.yaml blob is reachable from two histories, each commit reaching it through a tree of its own. the only thing that changes mutable immutable — content-addressed mutable — a name that points refs/heads/main refs/heads/run/<slug> update-ref moves these, and nothing else points at points at commit a7c3f1e… tree parent author / committer commit 4f2a1e8… tree parent author / committer parent parent commit history tree tree mode + object id name 100644 blob 7086065… intent-brief.md 100644 blob b9d94cd… state.yaml 040000 tree 6771cf5… tasks blob 7086065… blob b9d94cd… tree 6771cf5… subtree tree tree a different tree, the same state.yaml blob same content = one object, reachable from two histories git show <rev>:<path> walks this chain — no checkout, no index

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-only will "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-only is 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's syncFromRemote builds 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

  1. A blob's object id is a function of its contents alone. Where does a file's name live, and why does that force writeTreeWithBlob to run update-index --cacheinfo?
  2. updateRefCAS returns false instead of throwing. Name two consequences for callers.
  3. What does passing forty zeros as <old-oid> assert, and which operations in the framework depend on it?
  4. Name the three mechanisms this codebase uses to enforce fast-forward-only, and which one protects an unpushed decision commit during a sync.
  5. Which refs are shared across all worktrees of a repository, and which are not?
  6. 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).