lithos-loom develop merge-gate — reference¶
Pulled from the lithos-loom repo
This page is imported at build time from docs/cli/merge-gate.md on main. Edit it there, not here.
Trial-merge a delivered PR into its base's current tip and gate the result
with the project's current check-set (PRD
pr-reconciliation.md S3). Zero agent tokens:
no coder, no panel — a deterministic check-set on a different tree. This is
the command the github-watcher sweep drives as a subprocess on every base
move (the watcher half of S3); on its own it answers the operator's question
"will the PR's current base break if I merge this now?" before they press the button.
TL;DR¶
# Gate PR #352 against origin/main as it is right now; push the update if green
lithos-loom develop merge-gate 352 --repo ~/projects/lithos-loom
# Gate only — never touch the PR branch
lithos-loom develop merge-gate 352 --no-push
# Keep the merged tree to poke at, and save the record
lithos-loom develop merge-gate 352 --no-push --keep-worktree --json /tmp/mg.json
What it does¶
- Resolve the PR the way
review/convergedo: the head sha, the base branch, and a fetch of both soorigin/<base>is local. A fork PR is refused before any git work (fork_unsupported, exit 2): its head would have to be fetched into the operator's checkout, and the update could not be pushed under origin credentials anyway. - Throwaway worktree on a fresh branch at the PR head. If the base tip is
already an ancestor of the head the PR is up to date and the head itself
is gated; otherwise the base tip is merged
--no-ff, so a base move yields a real merge commit whose parents are the head and the base — never a rewrite of which commit the head is. - Conflict → the unmerged paths are reported (
conflict, exit 3) and the merge is aborted. This is the only source of that path list — GitHub's API saysdirtyand nothing more — and it is the input S5's resolver needs. No check-set runs on a tree that does not exist. - Check-set on the merge result: the Review Profile's checks (the story's
via
--story, else--profile, else the host default) with the same overridesconvergetakes. The verdict is the same ledger-aware floor the develop loop uses: an adapter-backed required check (ruff / bandit, run with--exit-zero) blocks through its findings at the configured threshold, never through its exit code. A required check that could not execute iserrored, not green — nobody reviews here, and an unverified merge is never pushed. Each check runs in its own throwaway container off a fresh export of the committed tree (#282).green/red(exit 0 / 1) by the blocking verdict;errored(exit 1) when the set could not run at all;no_checks(exit 0) when the profile resolves to nothing — a vacuous gate proves nothing and is said so. - Push the update when — and only when — the gate is
greenand the PR was behind: the merge commit goes onto the PR branch through the same leased, ancestry-proved pushconvergeuses (append-only; a branch that moved since the resolve is apush_error, reported beside the green verdict, never a rewrite).--no-pushdisables it. Red, errored, no-checks and up-to-date results never push: an unverified or pointless update is not written to someone's PR. - Clean up. The worktree and its branch are removed (the merge commit
object survives for the pushed ref).
--keep-worktreekeeps the merged tree for inspection and prints its path.
The record carries two fingerprints. The config fingerprint is a digest
of the resolved checks (name, command, state, stage), the image, the
per-check timeout and the blocking threshold — what actually gated. The
settings fingerprint is the resolved gate settings alone (image,
timeout, threshold, the profile by definition — its check declarations
and the catalog commands behind them, so a loom upgrade that tightens what
standard means moves it — plus a schema version for check semantics that
live in code, test command / gate, check tables, parity command): it needs
no worktree, so --resolve-only reports it without a
fetch, a merge or a check, and it is what the watcher sweep's re-run key
(head_sha, base_sha, settings_fingerprint) compares each pass — a tightened
check-set re-gates an already-observed PR, which is precisely why the
current config is re-resolved rather than a snapshot replayed (PRD S3
decision). A tree-dependent change (a lockfile appears) moves the head sha
anyway.
Flags¶
| Flag | Meaning |
|---|---|
CHANGE |
The PR: #142, 142, or a GitHub PR URL. A bare range / branch is rejected — there is no PR to update. |
--profile, -p |
Review Profile whose check-set gates the result (default: the story's, else the host's default_review_profile resolved through its unknown_profile policy, else standard). |
--story TASK_ID |
Resolve the story's develop settings (project doc + task develop_*: profile, check-set, image, test command, parity, generated paths + regenerate command) exactly as the daemon path does — the current config defending that project — as the base layer under any explicit flags. Strict: where the daemon would degrade to built-ins (no project slug / doc, a read failure, a malformed gate setting — develop_image / develop_test_command / develop_test_gate / develop_check_commands / develop_check_states / develop_parity_command / develop_generated_paths / develop_regenerate_command / develop_review_profile, at either layer), merge-gate exits config_unresolved (4) and gates nothing. The watcher-dispatched run passes it. |
--check-command NAME=CMD |
Override a check's command (repeatable). Beside --story, merges per key over the story's develop_check_commands table — the resolver's own task-over-project shape — so overriding one check never drops the project's other overrides. |
--check-state NAME=STATE |
Override a check's blocking state (repeatable). Beside --story, merges per key over the story's develop_check_states table. |
--test-command |
Explicit test check command (beats detection). |
--parity-command |
Repo-parity command, run as a required raw-exit check. |
--generated-path PATH |
Repo-relative path whose content is generated (repeatable; PRD S4). A conflict confined to these paths is taken from the base and rebuilt on the composed tree with --regenerate-command — never merged — and the merge is committed once with fresh outputs; a clean merge regenerates too. Only a conflict outside them is a conflict, reported without the generated noise (the set-aside paths ride in the record as generated_conflicts with the regenerate_command, so the [PRConflicted] finding tells the human to take either side and run it before pushing; what the generator moved as regenerated). A modify/delete in a generated path is taken too (the deleted side wins; the generator recreates the file if the composed tree wants it). Beside --story, replaces the story's develop_generated_paths. |
--regenerate-command CMD |
The command that rebuilds every --generated-path on the composed tree, run in the gate container (the gate's image and cache) before the check-set. Required with --generated-path; a generator that fails is a red regenerate check row (red, nothing pushed). |
--image |
Sandbox image the checks run in. |
--test-timeout |
Per-check timeout in seconds. |
--no-push |
Gate only; never push the merge commit. |
--expect-repo OWNER/NAME |
Refuse to act unless the checkout's origin is this repository — compared before any fetch; a mismatch exits repo_mismatch (2) and, with --json, writes {status: "repo_mismatch", expected_repo, actual_repo, message}. A PR number resolves against the checkout, so the watcher pins every run to the repo its gate names (a stale [projects.<slug>].repo must never act on another repo's PR with the same number). |
--resolve-only |
Resolve the gate settings (flags over the story's current config) and print their fingerprint — no fetch, no merge, no checks, no GitHub call. Still strict under --story (exit 4). With --json, writes {status: "resolved", settings_fingerprint, image, review_profile}. The watcher sweep's per-pass probe. |
--keep-worktree |
Keep the throwaway worktree (the merged tree). |
--repo |
Repository to work in (default: current directory). |
--json PATH |
Write the structured record. |
--config |
Host config path (the watcher passes the one it booted with). |
Output¶
- Text:
merge-gate <ref>: <status>, the message, base/head shas and behind/up-to-date, one line per conflicting path, one per check, and the push outcome. - JSON (
--json):status,head_ref,head_branch,base_ref,base_sha,head_sha,merge_sha(the gated tree: the merge commit when behind, the head when up to date, empty on conflict),behind,conflicting_paths[],checks[](name,command,state,stage,outcome,passed,exit_code,timed_out,output_tail),verdict(the gate's own decision:GREEN/RED, null when no verdict was produced — never the process-exit aggregate),config_fingerprint,settings_fingerprint,pushed,pushed_sha,push_error,message.
Exit codes¶
| Status | Exit | Meaning |
|---|---|---|
green |
0 | The check-set passed on the merge result (update pushed when behind, unless --no-push or a push race — see push_error). |
no_checks |
0 | The profile resolved to an empty check-set; nothing gated, nothing pushed. |
red |
1 | A blocking check failed on the merge result. |
errored |
1 | The check-set could not run (infrastructure); no verdict. |
infra_failed |
1 | The intake fetch of the PR head + base failed on the host — a transport failure is retried 3× with a short backoff first, the attempts sharing one fetch timeout (#431), and a git that cannot even be spawned is reported the same way — so nothing was merged or gated; host_action names git's own fatal: line and where to look (the daemon's SSH agent / its network access to origin). Not a verdict on the merge: the watcher's re-gate posts [Friction] with that action, retries the key, and re-arms it on the next daemon boot (#377). |
fork_unsupported |
2 | A fork PR; refused from GitHub's metadata before any fetch. |
pr_closed |
2 | The PR is merged or closed; its branch is not a live target, nothing is trial-merged or pushed. |
repo_mismatch |
2 | --expect-repo named a repository and the checkout's origin is a different one; nothing was fetched, gated or pushed. |
config_unresolved |
4 | --story could not resolve the project's current config (no project slug, no context doc, a read failure, or a malformed gate-affecting setting); nothing was gated — S3 gates with the current config or not at all, never with built-in defaults. |
conflict |
3 | The base no longer merges; conflicting_paths names why. |
The watcher half¶
The github-watcher sweep ([github_watcher] merge_gate_enabled, default on)
runs this command autonomously on every still-open pr gate whose
(head_sha, base_sha, settings_fingerprint) changed — merge-gate <pr>
--story <id> --repo <path> --json <path> --config <host> — one in-flight run
per project, holding and held by external remediation per PR (either may
push to the branch). The record lives in metadata.merge_gate on the gate.
Green records (and a pushed merge commit is recorded as loom's own push on
the remediation budget); a green verdict whose push failed is push_failed
— [Friction] with the push error, one retry on the same key, then the
verdict stands until a settings change or a move; red / errored posts
[MergeGateFailed] on the story; a conflict widens [PRConflicted] with
the conflicting paths; an unresolvable config, an unmapped project, a fork,
a repo mismatch or a crash posts [Friction] on the story (a crash or
mismatch is retried once on the same key, then waits for a head or base
move — or, for a crash or a failed push, for a daemon restart: either may
be loom's own bug and the restart is the fix attempt, so each boot gets its
own bounded retry pair on the same shas). base_sha in the key is the base
branch's live tip (GET /git/ref/heads/<base> each sweep), never the
PR payload's stale base.sha snapshot. The settings probe runs as a background task, never inline in the
sweep, acting only on the record it was scheduled for. Every run is pinned
to the gate's repo twice: the sweep reads the mapped checkout's origin
first (a mismatch — or a read that cannot answer: no checkout, no origin,
a non-GitHub origin — spawns nothing and posts one [Friction] naming why),
and the run's own --expect-repo is the authoritative check. Either kind of mismatch
settles on the mapped path plus the sweep's own origin read, and re-arms
the same shas when one of those moves — the mapping or the checkout's
remote url is the operator's lever. Per-project opt-out: context-doc
develop_merge_gate = false. See SPECIFICATION §2.2.
Requirements¶
Same as converge: a checkout with origin pointing at the PR's repository,
gh authenticated (the PR resolve), Docker for the check containers. No
agent CLIs — nothing here spends tokens.