An admission repository is protected by one pre-commit hook in its common Git directory. Because
linked worktrees share that directory, one installation protects the main checkout and every
present or future leg. The hook reads staged additions and renames, asks Git which ignore rule
matched each path, and refuses the commit when a root-level ignore rule for the fleet's own
channel files matched. The same check runs again at merge time, as defence in depth.
The enrolment script (enroll-machine.sh --repo ...) installs the hook automatically after it
creates or refreshes the remote bare admission repository. For an admission repository enrolled
without it, the one-time ceremony is:
Install or update saphan on that host so the binary includes the hook handler.
Measure the admission repository's absolute path with the machine registry and the host.
Read the emitted absolute pre-commit path, then prove a linked worktree resolves the same
common hook with git -C <linked-worktree> rev-parse --git-common-dir.
The installer refuses rather than replacing an existing, non-Saphan pre-commit hook. Combine or
relocate that hook explicitly and retry. A relative core.hooksPath is also refused: Git resolves
it from each hook's execution directory, so it cannot prove that one file reaches every linked
worktree. An absolute core.hooksPath is supported.
Remote worktrees fork from a shared clone whose origin is the machine's bare admission
mirror. Refreshing only the mirror leaves that clone stale; re-enrolling the machine is
also the wrong repair because enrollment reissues machine identity. machine refresh-repo
is the narrow provisioning act: it advances one base branch in the mirror, then fetches
that mirror into the shared clone. It never force-pushes, prunes, or touches your origin:
transport between your machines inside the fleet is provisioning, and pushing to origin
remains exclusively your hand.
Returned work uses the sibling machine deliver-branch verb. It carries one explicitly
named local branch to the same registered mirror and then fetches that mirror into the
registered shared clone, so a later fork can resolve the branch on the far host. An absent
far branch is measured and created by the one named refspec; an existing divergent branch
is refused. The verb never force-pushes, prunes, deletes a branch, or pushes to origin.
If either half fails, the refusal names far admission mirror or far shared clone; success
is measured by reading both refs back.
saphan run performs this rung itself. The mechanism is not the verb's private
property: the remote-spawn path calls the same two
rules the verb calls — when a mirror may be refreshed (a strict ancestor of local base is
stale and may be fast-forwarded; anything else is divergent and is never touched), and
what may be forked from (a named ref, never a bare HEAD). So the runbook below is the
operator's path — for inspection, for a divergent mirror, and for repairing a machine
outside a dispatch. It is not a precondition you must satisfy by hand before a remote spawn
will run.
Runbook.
Measure the absolute path of the bare admission repo on the remote host. Register that
measured path once; do not derive it from the ssh hostname:
saphan machine set-repo-dir --workspace <workspace> --machine <machine-id> \ --repo-dir <absolute path on that machine>
If the workspace holds more than one repository, register the path per repo
instead — --repo <repo> — and read the next subsection first: without it the machine
can serve exactly one of them.
The conventional place is ~/.saphan/repos/<basename> — repos/ PLURAL — with or without
a .git suffix; both shapes are accepted, and the enrolment script composes the one without
it. The workdir remediation saphan dispatch prints therefore names the parent directory,
and reports measured: none when that parent holds nothing.
From the canonical repository with its base branch checked out, measure without writing:
The command prints local, far-mirror, and far-clone origin/<base> SHAs. A stale result
exits non-zero, making this form suitable for automation.
If stale, run the same command without the dry-run prefix. Read the second three-SHA line; all
three values must be equal. A non-fast-forward refuses instead of forcing.
Retry the remote dispatch. For a machine with repo_dir registered, the dispatch dials
the mirror and then acts on what it measured — you do not have to run steps 2-3
first:
Behind (the mirror is a strict ancestor of local base): the dispatch refreshes the mirror
itself and the spawn proceeds. It then reads the push back over the same dial. A
refresh that reports success and did not land, or a refresh that fails outright,
refuses the spawn by name — nothing is spawned onto a stale base, and the refusal
still prints the machine refresh-repo line so you can drive the repair by hand.
Divergent (the mirror carries commits local base does not): never auto-healed,
because a forced push there destroys far-side commits. It passes with a named note,
and repairing it is a human decision — steps 1-3 above, or the far side.
Equal or ahead: passes with a note.
Unmeasurable — no registered repo_dir, an unresolvable local repo or base branch,
an argv that will not compose, a failed dial, or a dial returning no legible SHA — is
named and passes. A machine with no registered repo_dir is not dialed at all. A failed measurement is never silently
reinterpreted as evidence that the mirror is fresh, and it is never converted into a
refusal either.
Materializing the far worktree is the same rung in the one order that works: refresh the
mirror, fetch it into the shared clone, then worktree add from a named ref.
A far repo path belongs to the pair (machine, repo), not to the machine. A workspace
that holds two repositories cannot be served by a machine that knows only one path: every
stream owned by the other repository either cannot travel to that host at all, or travels
and forks the wrong mirror.
Why the engine refuses instead of picking. Two repositories commonly name their trunk
alike — say, both develop. A leg handed the wrong mirror does not fail — git worktree add finds
refs/heads/develop, forks it, checks out the wrong files, and the whole dispatch composes
green. Nothing downstream can catch that, because nothing downstream knows which repository
the leg was supposed to be in. So the pair is either in the record or the dispatch does not
compose. There is no fallback to the unnamed repo_dir, none to "the only pair
registered", none to "the first one".
The repo name to pass is the workspace's own name for the repository — the same token
its orders carry in - repo: <repo>, which for a repository sitting directly under the
workspace root is just its directory name. An absolute path under the workspace normalises
to the same key, so either spelling works.
Ceremony (your hand, once per repository per machine).
Create the bare admission repository on the far host, if it is not there yet, and read
its absolute path back:
Expect machine set-repo-dir: <machine-id> repo <repo> registered (<path>). On the
first pair bound to a machine that still carries an unnamed repo_dir, the verb also
prints a note: — see step 4.
Repeat steps 1-2 for every repository that machine is to serve.
Read the note. The moment a machine binds its first pair, the unnamed repo_dir
stops answering: a stream whose repository has no pair on that machine now refuses by
name. That is deliberate (see "why the engine refuses" above), and it means step 3 is not
optional — a repository you skip is a repository that machine can no longer serve.
Verify what the record holds:
saphan machine list --workspace <workspace> --json
The machine's row carries repo_dirs with one entry per repository you bound. A machine
with no repo_dirs at all keeps serving through its single unnamed repo_dir, unchanged — no migration command exists or is needed.
When a pair is missing, the dispatch refuses and names the repo, the machine, what that
machine does bind, and the command that fixes it. Read it as an instruction, not an error:
run: machine "runner-1" registers no far repo path for repo "docs-site"(it binds far paths for app=/home/runner/.saphan/repos/app.git) — a farrepo path belongs to the pair (machine, repo), and this pair is not in the record. Refusingrather than reaching for another repo's mirror: the repositories in this workspace nametheir trunk alike, so the wrong mirror forks the wrong files, checks out the wrong tree andcomposes GREEN→ measure the bare repo's absolute path ON runner-1, then bind the pair: saphanmachine set-repo-dir --machine runner-1 --repo docs-site --repo-dir<measured-absolute-path-on-runner-1> --workspace <workspace>
The sibling refusal fires when the stream carries no owning repo at all: the dispatch
cannot say which repository it belongs to, so it is not handed one, and the refusal names
- repo: in the order and saphan fleet observe <stream> --repo <owning-repo> as the two
ways to record it.
refresh-repo and deliver-branch choose the mirror by the same pair. Both verbs
require --repo, and that one flag names both halves of the same
repository: the local checkout the commits are read from, and the (machine, repo) pair
whose far mirror receives them. There is no second flag, and there is deliberately no way to
push repository A's commits into repository B's mirror.
The three outcomes are the pair's own, at these verbs too:
the pair is registered → that mirror, and the shared clone derived from it;
the machine binds no pairs → its unnamed repo_dir, for any --repo value — which is
what keeps a single-repository host working with no migration command;
the machine binds pairs but not this one → refusal by name, naming the repo, the
machine, what that machine does bind, and the set-repo-dir that fixes it. Nothing is
dialled: a refused invocation issues zero far commands.
The refusal matters more here than at dispatch, not less. These verbs push one branch
into the far mirror, and a branch the mirror does not have is created rather than
rejected — so the wrong mirror would silently grow another repository's refs, and for
a trunk name both repositories share, the push lands on a real ref.
Provisioning a new host's mirror end to end. Run per repository, from the local
checkout, and read each output before the next step:
The far shared clone is derived from the pair's mirror name — mirror
<home>/.saphan/repos/<name>.git pairs with clone <worktrees-root>/<name> — so it moves
with the pair and needs no separate registration beyond set-worktrees-root. Two
repositories therefore get two clones, as long as their mirrors are named differently.
Delivering a returned branch. From the local checkout, rehearse first:
The output contains the local, far-mirror, and far-clone SHAs (or missing). A stale result
is non-zero and changes nothing. Re-run without the dry-run prefix to perform the mirror push and
clone fetch. The command is idempotent when both far refs already equal the local branch;
it reports the measured equality and performs no mutation. The pair (or, on a machine that
binds none, repo_dir) and worktrees_root must already be registered; the verb refuses by
name, with those remedies, rather than deriving either path.