Saphan StudioDocs
Working with git

Merging

The gate sequence, why a merge is a human act, and the two-stage close that follows it.

Merging

The gate sequence

Six gates take decisions: stop1, stop2, review, merge, promotion, teardown. Five decisions are writable: accept, conditions, reject, ready-to-merge, closed-without-merge. Which decisions each gate accepts is a closed list, and a pair outside it is refused by name:

gatedecisions it accepts
stop1, stop2accept · conditions · reject · closed-without-merge
review, promotionaccept · conditions · reject
mergeready-to-merge
teardownaccept — and only through saphan teardown

stop1, stop2, merge and promotion are owner decisions: recording one asks for a one-time six-character code typed back at the terminal (see owner presence). A rehearsal asks for nothing.

⚠ merged is not on that list, and its absence is the design. It is not a decision anyone may record — it is a git observation. There is no writable path to it anywhere in the product.

The ordinary path, once a stream has returned its finished status:

saphan gate <stream> --gate stop2  --decision accept          --actor <name>
saphan gate <stream> --gate merge  --decision ready-to-merge  --actor <name>
saphan merge <stream> --actor <name>

The merge also stands directly on a stop-2 accept, so the common case is one act:

saphan accept <stream> --actor <name>

It records the stop-2 accept and then merges, each half through its own verb's preconditions, behind one confirmation that names both acts. A refused merge leaves the gate row standing. Use saphan gate and saphan merge for everything else — conditions, reject, review gates.

--gate merge requires the stream's latest stop-2 decision to be accept, re-read from the record rather than trusted from the caller. When a review lane is engaged, --gate review records the reviewer's judgement, and the merge refuses while that judgement is on record with no decision answering it.

⛔ A gate is never skipped. Every refusal in this sequence names the state the stream is actually in and the state the act needed.

If you would rather be told than remember:

saphan fleet next --stream <stream>

One read gives the stream's resolved state, every gate it still owes forward to a terminal, each act the record makes legal from here with a ready command, and each act it does not with that act's own refusal text.

The merge itself

saphan dry-run merge <stream> --actor <name>      # rehearsal
saphan merge <stream> --actor <name>              # the act

The rehearsal previews the preconditions and the composed commit message, and runs the same conflict probe in its own detached scratch tree, which it removes again. A pair git cannot combine is refused in the rehearsal too, naming the conflicted paths. It lands nothing and writes nothing.

The act verifies its preconditions read-only, before git is touched: the stream stands at a gated accept or at ready-to-merge, the merge gate is legal, the branch exists, the base is checked out and clean, the work is not already merged, and the base has not diverged from its remote. Any failure is a named refusal with a remediation line, and nothing is landed. After those checks it materializes the current branch-and-trunk pair in a detached scratch tree and merges there: a conflict refuses and names the unmerged paths. When the repository declares a build command (saphan workspace repo set-build <repo> "<command>"), that command then runs on the merged tree — a compile and static check, never a test suite — and a tree that does not build is refused (merged-tree-does-not-build) with the trunk unmoved; a build that cannot run to an answer refuses too. With no build declared, the merge says so and no build check runs. A clean probe lands in the same serialized act, pinned to the trunk commit that was checked. The commit message is composed from the record and reaches git byte-exact, blank lines included, and the resulting commit's trailers print as proof-of-done.

The merge lands on the branch the order names in - lands-on:, or else on - base:. --onto <branch> lands one act on your trunk instead — for a continuation round whose recorded base is its predecessor's branch; it opens no door, and every gate and probe still runs. Where your workspace requires a person's signature on gate rows, the merge row is signed with a passkey in the console, or with a security key on the terminal with --cli.

A merge is a human act

This is worth stating plainly for anyone who has to answer for it later.

The product holds one mutating merge command. Its arguments are fixed inside the product — there is no caller-shaped git — and it is reachable only from the verb a human invokes, after the preconditions verified. --actor names that human, and the invocation is the act: the engine executes a decision, it never makes one.

Everything else the product does to your local repository is either a read, from a small closed allowlist of subcommands that are read-only in every form, or one of a short list of individually named operations whose arguments are likewise fixed: the merge above, importing a branch from a local bundle file, provisioning a local landing repository, creating and removing working trees, and the detached probes. Nothing in this path pushes to, fetches from or mutates your origin, and the closing gate cannot be reached by adding a flag.

Two things do leave this machine, and both are named: delivering a base or a branch to your own machines' mirrors over SSH (Delivering a branch), and — only when you have configured a forge — delivering a signed merge decision to it (Pull requests and forge integrations). Neither ever force-pushes or rewrites history.

The two-stage close

Merging is not the end. Teardown is a second, separate decision, and it stands on a fact the product read rather than a fact it asserted.

  1. Merge — as above. The merge commit exists.

  2. The record shows merged — merged enters through exactly one door, the observation:

    saphan fleet observe <stream>

    This is why stage two exists as a stage. The product will not write "merged" from having performed a merge; it writes it from having looked.

  3. Only then, teardown:

    saphan teardown <stream>

    It archives the stream's channel documents into that stream's channel archive, verified byte-exact against a generated checksum manifest, and then records the teardown gate's accept. The teardown gate admits only accept — a teardown you decline is a refusal, loud and unrecorded, never a decision row. saphan gate --gate teardown records nothing and points you here. The archive happens before any removal, so the channel survives the tree.

    saphan teardown <stream> --verify-archive then removes the worktree itself, once the gate is recorded and every archived byte re-verifies — over the same SSH connection for a worktree on a remote machine, after bringing its remaining channel files home into the archive. Rehearse it with saphan dry-run teardown <stream> --verify-archive, which names the directory it would remove and removes nothing.

⚠ Both terminals admit a teardown: a stream closed without a merge has a live worktree with no merge ahead of it, and it needs a legal end too.

On this page