Skill v1.0.1
Automated scan100/100+3 new
version: "1.0.1" name: epic-merge description: "Sequential squash-merge of stacked PR chains into an epic branch. Handles dependency-ordered rebase, collision-safe backup tags, CI monitoring (delegates to /watch-ci), and post-merge verification. Use when: merging a chain of stacked PRs into an epic branch, collapsing a linear PR stack into per-PR squash commits, preparing an epic branch for final review. Triggers on: 'merge PRs into epic', 'squash chain', 'collapse PR stack', 'epic merge', or when user has a linear PR dependency chain (PR A -> B -> C) targeting an epic branch. Not for: single PR merge (use /create-pr + GitHub UI), simple rebase (use /smart-rebase), pre-merge analysis (use /merge-prep). Output: chain analysis table + backup tag manifest + per-iteration AskUserQuestion gate + verification log." disable-model-invocation: true allowed-tools: Bash(git:), Bash(gh:), Bash(bash:*), Read, Grep, Glob, AskUserQuestion, Skill
Epic Merge — Stacked PR Chain Squash-Merge
Sequentially squash-merge a chain of stacked PRs into an epic branch, producing one squash commit per PR for clean per-PR review on the epic. Every destructive iteration is gated by AskUserQuestion to keep the operator in control.
When NOT to Use
- Single PR merge — use
/create-pr+ GitHub UI - Simple rebase without stacked dependencies — use
/smart-rebase - Pre-merge conflict / impact analysis only — use
/merge-prep - Diamond / parallel merge chains — this skill handles linear chains only
- Repos that use merge-commit or rebase-merge — this skill assumes squash-merge only
Permissions
This skill is one of the explicit exceptions in @rules/git-workflow.md allowed to execute git rebase --onto, git push --force-with-lease, and gh pr merge --squash. Every destructive step is gated by AskUserQuestion.
| Phase | Operation | Mutates | Approval | |
|---|---|---|---|---|
| Phase 0 step 0 refresh | bounded git fetch (§ Phase 0 step 0) | refs/remotes/origin/* + .git/FETCH_HEAD | No (local, bounded, recoverable) | |
| Phase 1 backup | git tag -f | local refs only | No (no remote / non-recoverable mutation) | |
| Phase 2 iteration | rebase + force-push + gh pr merge | local + remote | Yes — single bundled gate per iteration (or per-step with --per-step) | |
| Phase 3 verify | git log | none | No (read-only) |
`--dry-run` outputs the plan and performs exactly one bounded local *operation — enumerated rather than implied, because every earlier phrasing was a promise the flag did not keep. "Skips all destructive steps" let Phase 1 run unconditionally, so a dry run force-updated every `backup/pr- tag and left a manifest per PR in the working tree. The repair overcorrected: it said the run "writes nothing" while still running git fetch origin, which is not a read and is not confined to origin/*` — see § Phase 0 step 0. And "one bounded write" was still too strong: a refspec bounds which refs a fetch may update, not what else fetching does.
Under --dry-run | Behaviour | |
|---|---|---|
| Phase 0 step 0 refresh | Runs. The one mutating operation a dry run keeps: the bounded fetch of § Phase 0 step 0, whose ref updates are bounded to refs/remotes/origin/* plus .git/FETCH_HEAD — its other writes are not, see § --dry-run residue below. Skipping it would print a plan derived from stale refs, and a wrong plan is worse than a refreshed origin/* | |
| Phase 0 analysis | Runs — read-only, over the refs step 0 has just refreshed | |
Phase 1 git tag -f "backup/pr-*" | Skipped. Printed as a command, not executed | |
| Phase 1 manifest files | Skipped. The git log runs, but its output goes to the report instead of $(git rev-parse --git-path epic-merge)/expected-pr-*.manifest, so no file is created and none of a previous run's are overwritten | |
| Phase 2 iteration | Skipped entirely — no gate is asked, no rebase, no push, no merge. The commands are printed | |
| Phase 3 verification | Skipped — there is nothing to verify |
The residue, stated because a dry run that leaves a trace should say where. Refs: refs/remotes/origin/* and .git/FETCH_HEAD — that set is what the explicit refspec bounds. Objects and metadata: a fetch that finds new commits downloads them into the object database, and depending on configuration git may run auto-maintenance or rewrite the commit-graph on its way out; in a shallow clone it may update the shallow metadata too. None of that is destructive and none of it is bounded by a refspec, which is why the promise above is "one bounded operation" and not "one bounded write". Pass --no-auto-maintenance --no-write-commit-graph to suppress the maintenance half; the downloaded objects are inherent to fetching and remain. Nothing changes on the remote.
Core Concept
After squash-merging PR N, the original commits are replaced by a single squash commit on epic. PR N+1 still contains N's original commits as its base — these must be cut via git rebase --onto before merging N+1. The cut point is the original tip of PR N's branch, captured in Phase 1 as a backup tag.
epic: E ─── S_N (squash of PR N)PR N+1: E ─ A1 ─ A2 ─ ... ─ B1 ─ B2└── drop (in S_N) ──┘ └─ keep ─┘After: git rebase --onto origin/epic backup/pr-<N> PR_N+1epic: E ─── S_N ─── B1' ─ B2'
Names in commands
A PR head branch name is not display text. It arrives from GitHub, and git check-ref-format accepts far more than the names people type: measured, refs/heads/feat/x$(printf${IFS}PWNED>&2) and refs/heads/--all both pass, git update-ref creates both, and git clone carries both to every copy of the repository. git switch -C refuses to create such a name, which is why they look impossible — creation is not how they arrive.
Two separate readers, and each needs its own answer:
| Reader | What a hostile name does | The answer | |
|---|---|---|---|
| The shell | A name pasted into a command as literal text is evaluated: case "feat/x$(printf${IFS}PWNED>&2)" in runs printf before the guard decides anything, and the guard then passes the branch as unprotected | Bind once to a variable (head=<quoted head>), then use "$head". Expanding a variable does not re-scan for $( ) — measured both ways | |
| git's option parser | Quotes are consumed by the shell, so git still sees --all as a flag. Measured: pushing such a branch without a separator answers Everything up-to-date — git took the flag and pushed every branch, none of them the one the operator approved | The separator, before the ref operand |
Every <…> slot bound above is written <quoted …>: substitute a shell-quoted value, single-quoted with each ' rendered as '\''.
And the separator ends option parsing, not refspec parsing — a third reader with its own answer. After --, git reads the operand as a refspec, where a leading + means "force" and a : splits source from destination. git check-ref-format refs/heads/+main exits 0, so +main is a legal branch name that the protected-head guards below compare against main and pass as unprotected. Measured, with a local main rewound behind the remote:
$ git push origin -- "+main" # no force flag anywhere on this line+ affcbe7...ad7e970 main -> main (forced update) # exit 0 — a protected branch, force-updated
It is not only a bypass, it is the wrong branch: with a real +main branch present, that form pushed main and never created refs/heads/+main on the remote, while refs/heads/+main:refs/heads/+main created it correctly and left main untouched. So both pushes below name a full `src:dst` refspec, whose first character cannot be read as +. Write ${head} in braces — $head:refs is a modifier expansion in zsh and silently eats the :refs.
Bind at the first use, not at the first destructive one. Phase 0 reads the names and already puts them on a command line, so a binding that started at Phase 2 would leave the whole analysis step evaluating them. Every fenced block below that names a ref binds it at the top of that block — each fence is its own shell, so nothing carries over between them.
Which separator a command takes is measured, not assumed — and for one command neither works. Measured on git 2.55.0:
Subcommand names below are written without the git prefix, so that naming a command in this table is not mistaken for issuing it:
| Subcommand | Separator | Measured | |
|---|---|---|---|
push, fetch, merge-base | -- and --end-of-options | Both accepted, equivalent. -- here is a convention, not a correctness requirement | |
branch -D | -- | git branch -D -- --all deletes the branch actually named --all; without the separator git answers fatal: branch name required | |
| `rev-parse` | neither | git rev-parse -- main prints -- and main back verbatim; git rev-parse --end-of-options main prints --end-of-options and then the SHA — two lines. Either way the captured value is not a SHA, and Step 7 would hand it to /watch-ci |
rev-parse is therefore solved by `--verify --quiet` with a fully-qualified ref, never by a separator — Step 7 below uses that form.
The `+` refspec hazard above applies to `git fetch` too, and it is why the epic refresh in Steps 9 and the Iteration-1 tail names a full src:dst refspec rather than "$epic": measured, git fetch origin -- '+main' reads the + as the force modifier and fetches `main`, so an epic literally named +main would leave origin/+main stale and every later rebase would cut against the wrong tip.
A rev range takes -- too, and for a different reason than the option one. It cannot begin with -, so binding does settle the option question — but not the revision-versus-path one. When the range names a ref that does not resolve, git falls back to reading the whole argument as a pathspec, and if a matching path happens to exist the command succeeds:
$ git log --oneline "origin/main..origin/feat" # ref missing, path ./origin/main..origin/feat existsf0fb083 two # exit 0 — read as a path, answered about the wrong thing$ git log --oneline "origin/main..origin/feat" --fatal: bad revision 'origin/main..origin/feat' # exit 128 — the failure that should have happened
Measured — and the same fallback catches a single unresolvable ref, not just a range: git log --oneline "origin/gone" answers about a path and exits 0 where … -- fatals bad revision. A wrong answer with a zero exit is worse than an error here: the manifests below are built from these arguments and then compared, so an unreadable one degrades into a mismatch that reads as "the rebase went wrong". Every revision argument in this document — range or single ref — carries the separator.
What this section does not close: git switch -C "$head" "refs/remotes/origin/$head". No separator form applies — git refuses an option-shaped branch name outright there, so such a head ends the run with git's own error and no explanation from this skill. That is a failure, not an exploit, and it is not a handled case either. Tracked in docs/features/ref-name-hardening/requests/2026-08-20-ref-name-hardening-r1.md, which owns this defect class across the ref-handling skills.
Until that redesign lands, the Phase 0 validation gate below detects it rather than letting it surface mid-run: abort if any PR head begins with -. That is not the redesign — it neither fully-qualifies nor escapes anything — it only moves an opaque failure to the point before backup tags and per-iteration approvals are created, where it costs nothing to recover from. A head beginning with - is legal to git (git check-ref-format refs/heads/-x exits 0) but unusable here, so refusing it loses no working case.
Workflow
sequenceDiagramparticipant U as Userparticipant E as /epic-mergeparticipant W as /watch-ciparticipant GH as GitHubE->>E: Phase 0 — analyze chain (linear?)E->>GH: Phase 1 — fetch + create backup tagsNote over E,GH: Iteration 1 — direct squash (no rebase needed)E->>U: AskUserQuestion (bundled gate)U-->>E: Proceed / Dry-run / AbortE->>GH: gh pr merge --squashE->>GH: fetch updated epicNote over E,GH: Iteration 2..N — rebase + force-push + CI + mergeloop For each remaining PRE->>U: AskUserQuestion (bundled gate)U-->>E: Proceed / Per-step / Dry-run / AbortE->>E: rebase --onto epic backup/pr-<prev>E->>E: verify manifest (subject + count)E->>GH: push --force-with-leaseE->>GH: gh pr edit --base epicE->>W: /watch-ci --sha <sha> --branch <head> --timeout <ci-timeout>W-->>E: PASS / FAIL verdictE->>GH: gh pr merge --squashE->>GH: fetch updated epicendE->>E: Phase 3 — verify final epic log
Phase 0: Analyze PR Chain
Step 0 — the bounded refresh, before anything reads a ref. Every count and validation below is computed from refs/remotes/origin/*, so a refresh that runs afterwards refreshes nothing the operator was shown. It used to sit in Phase 1, one whole approval gate too late.
git fetch origin is the wrong command for it, and not marginally: git applies the repository's configured remote.origin.fetch refspecs, and those may write anywhere. Measured — one extra git config --add remote.origin.fetch '+refs/heads/feat/a:refs/heads/victim', then a plain git fetch origin, printed + 425e2ea...0a77df9 feat/a -> victim (forced update) and destroyed a local branch. Default tag following and submodule recursion are two more write paths on the same command. So the refresh is spelled out rather than left to configuration:
Before that, one refusal — and it precedes the refresh rather than following it, because the refresh is itself a transport operation that writes refs: redirected, it does not merely misreport the chain, it fills refs/remotes/origin/* from another repository, and every count, backup tag, rebase destination and lease below is computed from exactly those refs.
# ── Step 0a: the interpreter, before anything else ────────────────────────────# First, because every check below is only as good as the shell running it. A non-interactive bash# SOURCES `$BASH_ENV` before line 1 of this fence; zsh does the same with `$ENV` under sh# emulation. A sourced file may define a function whose name contains a slash — bash refuses to# IMPORT such a name from the environment, which is why the prefix is spelled absolutely, but it# does not refuse to DEFINE one. Measured 2026-08-22, bash 3.2.57 and zsh 5.9: with# `function /usr/bin/env { …; }` defined, the word `/usr/bin/env` resolved to the function and the# child never ran. Every reading this phase prints, and every attestation the iteration gates# collect, would then be whatever that function chose to say.## **This block contains no command word, and that is the design.** Two `[[ ]]` tests (a keyword the# parser resolves — a function cannot outrank it), three assignments (syntax, not commands), one# expansion. Round 65 rewrote it after measuring the two ways the first version failed:# * it read its sentinel without resetting it, so an exported `SD0X_EPIC_MERGE_REFUSED=1` satisfied# the expansion and the fence continued with status 0 — the refusal printed and nothing stopped;# * it used `${!name+set}`, bash indirect expansion, which zsh rejects as `bad substitution`# even under `--emulate sh` — so on macOS's default shell it aborted at the first iteration# whether or not anything was set, and the `ENV` refusal it documents never ran.# Assign, THEN expand: `:?` fires on null **or** unset, so assigning empty one line above makes it# fire unconditionally. Set-ness, not emptiness, for what is DETECTED (`${BASH_ENV+set}` — an# exported empty value is still a file the parent named); names never values (Anchor Register #2).## What this does NOT close, stated because the comment that used to stand here over-claimed: a# startup file that defines the function and then unsets the variable leaves nothing to detect. That# residue has no owner downstream — the `pre-push` hook is opt-in, so where it is absent the# in-session approval is the whole credential (`rules/git-workflow.md` § Push safety).SHELL_STARTUP_INHERITED=[[ -n "${BASH_ENV+set}" ]] && SHELL_STARTUP_INHERITED=BASH_ENV[[ -n "${ENV+set}" ]] && SHELL_STARTUP_INHERITED="${SHELL_STARTUP_INHERITED:+${SHELL_STARTUP_INHERITED}, }ENV"if [[ -n "$SHELL_STARTUP_INHERITED" ]]; then# No apostrophe anywhere in the word: inside `${var:?word}` bash reads one as an opening quote# even within double quotes, and that is a PARSE error — it would take the whole fence down on# every run, refusing and ordinary alike. Measured 2026-08-22.SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — ${SHELL_STARTUP_INHERITED} is set in this environment.That startup file is sourced before line 1 of this fence and can redefine the commands below,including the absolute /usr/bin/env prefix (measured). Nothing this phase reports could then berelied on, and the in-session approval is the only credential where the opt-in pre-push hook isnot installed. Unset it and re-run. Nothing is planned and nothing is pushed.}"fi# Transport variables decide WHERE git’s traffic goes — which repository is read from and# written to — so nothing is planned while any of them is set. Four names, each measured 2026-08-22 on git 2.55.0 / OpenSSH 10.3p1: `GIT_SSH_COMMAND`,# `GIT_SSH` and `GIT_PROXY_COMMAND` are run BY git AS the connection, handed the host and the# remote command as arguments they are free to ignore; `GIT_SSH_VARIANT` names no executable at# all but changes the argv git BUILDS — under `=plink` a URL's `:2222` is emitted as OpenSSH's# `-P`, which takes a *tag* rather than a port (`ssh` usage: `[-P tag]`), so the connection# silently falls back to 22.## Refusing here, rather than relying on the `-u` clearing every command below carries, is this# step's whole point. Clearing is not a neutral act: an operator's own# `GIT_SSH_COMMAND='ssh -p 2222'` encodes part of the destination, and dropping it moves the push# to port 22 — which SUCCEEDS silently wherever that host serves the same path there too. Set or# cleared, the URL and digests this phase prints would then describe a destination the push does# not reach, which is the one thing this phase exists to prevent. The `-u` list stays as defence# in depth, for any caller that arrives at a later phase without passing through here.## Set-ness, not emptiness, is the test — measured: an exported-empty `GIT_SSH_COMMAND` is not# treated as unset, git runs `''` as the command (`run_command: GIT_PROTOCOL=version=2 '' -G …`).# `${VAR+set}` — the direct form, one literal test per name — is what delivers it below; the# indirect `${!_n+set}` a loop would need is bash-only and is why the loop is gone (next# paragraph). Names are printed and values never are: a transport# command line routinely carries a key path (Anchor Register #2).# Four literal tests rather than a loop over `${!_n+set}`. That is **bash** indirect expansion and# zsh 5.9 rejects it outright — `bad substitution`, rc=1, even under `--emulate sh` — so on the# platform's default shell the loop aborted at its FIRST iteration whether or not anything was set:# this refusal never ran, and neither did anything below it. Measured 2026-08-22. Round 65 took the# same construction out of step 0a and left this copy, one block away, standing.TRANSPORT_PRESENT=[[ -n "${GIT_SSH_COMMAND+set}" ]] && TRANSPORT_PRESENT=GIT_SSH_COMMAND[[ -n "${GIT_SSH+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_SSH"[[ -n "${GIT_PROXY_COMMAND+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_PROXY_COMMAND"[[ -n "${GIT_SSH_VARIANT+set}" ]] && TRANSPORT_PRESENT="${TRANSPORT_PRESENT:+${TRANSPORT_PRESENT}, }GIT_SSH_VARIANT"if [[ -n "$TRANSPORT_PRESENT" ]]; thenecho "⛔ transport variables set in this environment: ${TRANSPORT_PRESENT}" >&2echo " Each one decides where a push lands, so neither honouring nor clearing them lets this" >&2echo " phase describe the destination that would be reached." >&2echo " Move the setting to ~/.ssh/config or 'git config core.sshCommand' — per-host, durable," >&2echo " and visible to 'git config' — then re-run. Nothing is planned or pushed until then." >&2# Terminated the way step 0a is, and for the same measured reason: `exit` is a builtin, and an# imported `BASH_FUNC_exit%%` that returns leaves the refusal printed and the phase running.SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — transport variables set in this environment}"fi
/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git fetch --refmap= --no-tags --no-recurse-submodules --upload-pack=git-upload-pack origin \'+refs/heads/*:refs/remotes/origin/*' || {echo "⛔ cannot refresh origin — the chain table below would be computed from stale refs" >&2# Not `exit`. This document's own operating model is that an exported `BASH_FUNC_exit%%`# outranks the builtin (§ Names in commands), and the startup guard checks `BASH_ENV`/`ENV`# only — an imported function is not a variable it can see. Measured 2026-08-22 under bash 3.2:# with `exit() { return 0; }` imported, this arm printed its refusal and the group returned 0,# so every step below ran against stale remote-tracking refs. Assign-then-expand, as in the# `PHASE1_OK` / `ITER1_OK` / `PUSH_BLOCKED` blocks that already do this.SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — origin could not be refreshed; the chain table would be stale}"}
--refmap= discards the configured refmap so only the refspec written here applies; --no-tags and --no-recurse-submodules close the other two. --upload-pack=git-upload-pack closes a fourth, and it is the one that decides which repository answers: remote.origin.uploadpack names the program run at the far end, so a configured value serves refs from wherever it likes while the URL still reads as origin. Measured — with remote.x.url pointing at a path that does not exist and remote.x.uploadpack pointing at this repository, git ls-remote x HEAD printed this repository's refs and exited 0; with --upload-pack=git-upload-pack on the command line the same call failed 128. It is pinned on every fetch and ls-remote in this document and in /push-ci, symmetrically with --receive-pack=git-receive-pack on the pushes: a measurement and the push that acts on it must reach the same repository, and the read is the half that had no pin. Measured against the same hostile configuration, victim was left untouched. What still gets written is .git/FETCH_HEAD — which is why § Arguments calls a dry run bounded rather than read-only.
# For each PR, get head/base branch and unique commit count.# `gh pr view` reports the names; bind them before any of them reaches a command line# (§ Names in commands). This is the FIRST place a PR head is used, so binding only at# Step 0 of Phase 2 would leave this line evaluating whatever the name contains.# Its status is guarded HERE. It is the only evidence the PR was read at all, and every line# below derives head, base and the commit count from what it printed — so a later command# overwriting `$?` lets this fence exit 0 having read nothing and report a chain it invented.if ! /usr/bin/env -u BASH_ENV -u ENV gh pr view <N> --json number,headRefName,baseRefName,title,state; thenecho "⛔ Phase 0: the PR could not be read — the view command exited nonzero. head, base and" >&2echo " the commit count below all derive from its output, and there is none. STOP." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the PR could not be read}"fihead=<quoted head>base=<quoted base># `--` closes revision-vs-path ambiguity: without it a range naming a ref that does not# exist locally can be read as a pathspec instead. And the count is taken in two steps,# never as `git log … | wc -l`: a pipeline exits with `wc`'s status, so a `git log` that# fataled reports **0 unique commits** and the chain table shows a PR as empty when it was# actually unreadable. Measured — the failing pipeline exits 0.# Fully qualified, never the `origin/<name>` shorthand. That shorthand is DWIM, and git# resolves `refs/tags/<name>` BEFORE `refs/remotes/<name>`: a tag literally named# `origin/feat/a` is a legal ref name (`git check-ref-format refs/tags/origin/feat/a` exits 0)# and wins. Measured — with such a tag present, `git rev-parse origin/feat/a` warns# "refname is ambiguous" and prints the TAG's commit, while `refs/remotes/origin/feat/a`# prints the branch's. A warning on stderr is not a refusal, so the range is silently wrong# and every count, backup and rebase destination derived from it is wrong with it.range=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git log "refs/remotes/origin/$base..refs/remotes/origin/$head" --oneline --) || {echo "⛔ cannot read refs/remotes/origin/$base..refs/remotes/origin/$head — run step 0, or the PR refs are missing" >&2# Not `exit`, for the reason the refresh arm above gives: the builtin is outranked by an# imported `BASH_FUNC_exit%%`, and this arm sets no flag a later guard could read, so a# shadowed `exit` turns an unreadable range into a reported zero-commit PR.SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the PR revision range could not be read}"}# A legitimate empty range is a count, not a failure — `grep -c` would exit 1 on it and a# caller running under `set -e` would abort on a PR that simply has no unique commits.# Absolute paths for the same reason as the classifier report below: this line's stdout **is** the# commit count every later step derives from, and both `echo` and `printf` are builtins a caller's# exported function outranks. **`wc` being external does not help**: shell function lookup comes# before PATH as well as before the builtins, so a bare `wc` is claimable by exactly the same# import. Measured 2026-08-22 — an imported `wc() { echo 999; }` made this pipeline print `999`# and still exit 0, forging the count in the chain table. Both words are absolute, so the whole# value leaves through names no `BASH_FUNC_*` can claim: bash refuses to import a function whose# name contains a slash.if [[ -z "$range" ]]; then /usr/bin/printf '%s\n' 0; else /usr/bin/printf '%s\n' "$range" | /usr/bin/wc -l; fi
Output a chain table:
| Order | PR | Head Branch | Base Branch | Unique Commits | State | |
|---|---|---|---|---|---|---|
| 1 | #100 | feat/A | epic/xxx | N | OPEN | |
| 2 | #101 | feat/B | feat/A | N | OPEN | |
| ... | ... | ... | ... | ... | ... |
Validation gate — abort if any of:
- The first PR's base is not `<epic-branch>` — the linearity check below only relates each PR
to the one before it, so PR 1 has nothing to be checked against and its base would go unverified. That is not a cosmetic gap: iteration 1 runs gh pr merge <first-PR> --squash, which merges into that PR's own base, whatever it is. A chain whose first base drifted to some other branch would therefore mutate that branch, and every later step would proceed on the false premise that <epic-branch> had received the commits. Compare gh pr view "$first" --json baseRefName -q .baseRefName against the requested epic branch and hard-abort on mismatch — before Phase 1 backups, so nothing is written first
- Any PR's head branch name begins with `-` — option-shaped and unusable at
git switch -C (see § above); refuse before anything is written, not mid-run
- A PR's base is not the previous PR's head (chain not linear)
- Any PR is not OPEN
- Any PR has uncommitted local changes on its head branch
- Working tree is dirty (
git status --porcelainnon-empty) - Any PR's head branch is protected (
main,master,develop,release/*) — a PR
head is not inherently unprotected (a PR can be opened from main), Step 5 force-pushes every head, and force push to shared branches is prohibited (rules/git-workflow.md § Prohibited). Exact match only: feat/main-menu and release-notes are not protected. Step 5 and Rollback re-assert this guard, so a chain that slipped past Phase 0 still cannot rewrite a protected branch. That is not the same as "cannot rewrite a shared branch", and the difference is not pedantry: rules/git-workflow.md § Prohibited forbids force-pushing shared branches, and shared is a fact about who else holds the branch — not something any ref inspection can decide. A two-person feat/* head is shared and this guard passes it — but Step 5 no longer reaches the push unchallenged. The protected list is the decidable half of the shared set, chosen as the conservative side of a judgment; the undecidable half was an open authorization question, and it was settled on 2026-08-21 as option A in docs/features/push-gate-optin/requests/2026-08-20-push-ci-force-with-lease-r5.md: pre-push-gate.sh refuses a push that rewrites history unless the operator attests the rewritten refs are unshared (ALLOW_FORCE_UNSHARED=1, or yes at its /dev/tty prompt). The one exclusion is a ref the protected prompt already covers, so no single ref is asked about twice. That is the claim; "one push never asks twice" was the earlier wording and it overstates it — a push carrying a protected fast-forward and an unprotected rewrite fires both prompts, one per ref. This skill cannot produce such a push (it pushes one refspec per iteration), but the scoping argument has to be stated at the strength it actually holds, because that is the wording the hook’s own comment was corrected to. This skill must never set that variable, and Step 5 clears it — it is the operator’s answer, not the skill’s, and this workflow pushes in a loop, which is the shape session caching makes unsafe. Setting it answers the hook’s question; passing an ambient one through lets the shell answer it; only clearing it guarantees the operator is asked. Where the hook is not installed nothing at /dev/tty asks anything, so the per-iteration AskUserQuestion is the only attestation there will be — which is why the gate table asks the unshared question by name and first, rather than folding it into the force-form approval. Option A requires this skill to refuse without the evidence, and a question that never mentions sharedness collects approval, not evidence
On a gated repo with no controlling terminal, this skill cannot proceed — and cannot roll back
Every push this skill makes is a rewrite by construction (Step 5 pushes a rebased head; Rollback rewinds one), so on a repo with the gate installed both reach /dev/tty. One exception, and it falls the safe way: an iteration whose head is already based on the target rebases to a no-op, local and remote OIDs match, git sends no ref, and the hook exits 0 without prompting — nothing was rewritten, so nothing needed asking. "Every" is the rule for every push that changes anything.
The same -u GIT_EXEC_PATH that leads both push forms is not cosmetic here either: git prepends its exec-path to PATH before running a hook, so an ambient GIT_EXEC_PATH chooses the git the gate asks about ancestry — and a gate that mis-answers ancestry sees a rebased head as a fast-forward. Measured on 2026-08-21: a forced update landed with exit 0 and no prompt. From an agent shell there is no terminal to reach, and the gate refuses with Cannot open /dev/tty … and exit 1.
The GIT_* names that follow it answer the same question about configuration and ancestry. Three were measured the same day: GIT_CONFIG_COUNT carrying core.hooksPath=/dev/null removes the gate outright, the same channel carrying url.<host>.insteadOf sends the approved refspec to another server, and GIT_GRAFT_FILE leaves the gate installed while making its merge-base --is-ancestor answer "fast-forward" for a rewrite. This skill pushes in a loop, which is where an ambient one is most dangerous: it is set once and answers every iteration.
GIT_GRAFT_FILE=/dev/null and GIT_NO_REPLACE_OBJECTS=1 are set rather than unset — the only two names in the prefix that work that way, and for the same reason. Unsetting GIT_GRAFT_FILE restores its default path, $GIT_DIR/info/grafts, a file inside the repository that no -u can reach, so the strip closes one channel by opening another (measured 2026-08-21). Unsetting the other restores git's default of honouring refs/replace/*, so a git replace --graft L R sitting in the repository makes that same ancestry oracle answer "fast-forward" for a rewrite — while the transfer publishes the real, unrelated L, because pack transfer ignores replacements. The gate is asked a question whose answer the push then disregards, and that asymmetry belongs to pushing alone. Measured 2026-08-21: honest 1, grafted 0, guarded 1.
GIT_SSH_COMMAND, GIT_SSH and GIT_PROXY_COMMAND are stripped for a third reason, distinct from both above: each names an executable git runs in place of the connection, so it decides where the bytes go, not how they are authenticated. Measured 2026-08-22 on git 2.55.0 — the wrapper is invoked as <host> "git-receive-pack '/team/a.git'" and is free to ignore both arguments. This skill pushes in a loop, which is where an inherited one is worst: set once, it redirects every iteration while Phase 0's digest, the operator's approval and the hook all still describe origin. GIT_ASKPASS is left alone, and the distinction is measurable rather than stylistic: it is handed a prompt and returns a credential, so it cannot choose a destination. The strip closes the environment channel only — core.sshCommand and url.*.insteadOf in the repository's own config still apply, which is both deliberate (that config is the operator's) and what keeps their key selection working here.
The half worth stating plainly is the second one: the recovery path is refused on exactly the same grounds as the path that failed. A chain interrupted mid-iteration therefore cannot be unwound from here — the backup tag exists, and the push that would restore it is the one being refused. Do not read the rollback failure as a second, worse fault; it is the first one seen twice.
What to do, in order:
- Stop the loop and report which iteration it stopped at, plus the backup tag
(backup/pr-<N>). Nothing is lost — the tag is local and the remote is untouched, because the refused push never happened.
- Point the operator at the command already written here — Step 5's for resuming the
iteration, Rollback's for unwinding it — to run in their own terminal, where the gate can ask them. Do not restate either command in the report. This document contains exactly two push commands and test/skills/epic-merge.test.js pins that pair by equality; a third copy written for a recovery note is a second source of truth for the most dangerous line in the skill, and the one that drifts is always the copy nobody re-reads. Name the step, quote nothing.
- Never set `ALLOW_FORCE_UNSHARED` or `ALLOW_PUSH_PROTECTED` to get past it (§ Prohibited) —
including on the rollback, where the temptation is strongest because the push looks like a repair. It is still a rewrite of a ref somebody else may hold, which is precisely the question nobody is present to answer.
- **Never push without the full
/usr/bin/env -uprefix, and never let one of its names
through. The absolute path is load-bearing: a bare `env` is shadowed by an imported `BASH_FUNC_env%%` function, which ignores every `-u` (measured), and `command env` is shadowed too because functions outrank builtins. A word containing `/` closes the import vector — bash refuses to import a function whose name contains one — but does not make the word immune: measured 2026-08-22, a `$BASH_ENV` file defining `function /usr/bin/env` is sourced before the fence's first line and intercepts the prefix in the fence's own shell (`bash -p` refuses the sourcing; a markdown fence cannot ask for `-p`). A shell already running attacker-chosen code forges `git` just as easily, which is why the terminal credential is the hook under `-p`, not this prefix. The `BASH_ENV`/`ENV` half, `GIT_EXEC_PATH`, and the `GIT_*` configuration and ancestry names. Each answers, from the caller's shell, a question the gate is supposed to ask now: which interpreter reads the hook, which `git` it consults, which configuration that `git` resolves (including whether the hook exists at all), and what ancestry it reports. Measured 2026-08-21 — dropping the configuration half alone force-updated a protected `main` at exit 0 with no gate. `GIT_SSH_COMMAND`, `GIT_SSH` and `GIT_PROXY_COMMAND` are in the prefix for a related but distinct reason — git runs each as the connection itself, so they choose *where the bytes go* (see above). `GIT_ASKPASS` is deliberately not** in the prefix and must not be added: it is handed a prompt and returns a credential, so it cannot select a remote, and stripping it breaks the operator's credential helper on a push that is otherwise exactly what was approved.
Phase 1: Pre-flight Backup
Creates safety nets. Original branch tips and PR-level commit fingerprints persist as git tags + manifest files so they survive shell session loss.
No fetch here — § Phase 0 step 0 already refreshed refs/remotes/origin/*, bounded. A second fetch at this point would re-open the write paths step 0 closed and would refresh refs the operator has already been shown a plan for, which is the reordering defect, not a safety net.
Every ref below is fully qualified, for the reason § Phase 0 step 0 measures: origin/<name> resolves a same-named tag first, so a backup taken through the shorthand can tag the wrong commit — and a backup of the wrong commit is worse than none, because the rollback path trusts it.
# Collision-safe backup tags keyed by PR number (NOT branch basename)# PHASE1_OK is this fence's verdict, and it is an ASSIGNMENT for the same reason `PUSH_BLOCKED`# is one: a refusal spelled `exit` is a refusal an imported function can swallow. `break` is an# optimisation, never the guard — the flag is set once, cleared by any failure, and never set# again, so the last line below states the verdict whatever the loop did after the failure.PHASE1_OK=1for pr in <PR-numbers>; dohead_branch=$(/usr/bin/env -u BASH_ENV -u ENV gh pr view "$pr" --json headRefName -q .headRefName) || { echo "⛔ PR ${pr}: head branch unreadable — no backup tag exists for it" >&2; PHASE1_OK=; break; }/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git tag -f "backup/pr-${pr}" "refs/remotes/origin/${head_branch}" || { echo "⛔ PR ${pr}: backup tag not created — the rollback point Phase 2 promises does not exist" >&2; PHASE1_OK=; break; }done# Stable manifest per PR (subject-only — survives SHA rewrite during rebase).# Written inside the git directory, NEVER the worktree: at the repo root these are# untracked files, `git status --porcelain` lists them as `??`, and the rollback in# § Recovery refuses on any nonempty porcelain output — so writing them beside the# working files would make the recovery path unreachable in exactly the runs that# create them. Measured: `?? .epic-merge-pr-100.manifest` at the root vs empty# porcelain under `.git/epic-merge/`.MANIFEST_DIR=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --git-path epic-merge) || { echo "⛔ the git directory could not be resolved — there is nowhere to write the manifests" >&2; PHASE1_OK=; }/bin/mkdir -p "$MANIFEST_DIR" || { echo "⛔ the manifest directory could not be created — Step 4 would compare against a file that was never written" >&2; PHASE1_OK=; }[[ -n "$PHASE1_OK" ]] && for pr in <PR-numbers>; dohead=$(/usr/bin/env -u BASH_ENV -u ENV gh pr view "$pr" --json headRefName -q .headRefName) || { echo "⛔ PR ${pr}: head branch unreadable — no expected manifest exists for it" >&2; PHASE1_OK=; break; }base=$(/usr/bin/env -u BASH_ENV -u ENV gh pr view "$pr" --json baseRefName -q .baseRefName) || { echo "⛔ PR ${pr}: base branch unreadable — no expected manifest exists for it" >&2; PHASE1_OK=; break; }/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git log "refs/remotes/origin/${base}..refs/remotes/origin/${head}" --pretty=format:'%s' -- > "${MANIFEST_DIR}/expected-pr-${pr}.manifest" || { echo "⛔ PR ${pr}: expected manifest not written — Step 4 would compare against nothing" >&2; PHASE1_OK=; break; }done# The fence's exit status. Zero only if every backup tag and every expected manifest exists —# a `for` loop reports its LAST iteration, so without this line a failure on the first PR is# erased by a success on the second, and Phase 2 force-pushes with no rollback point.[[ -n "$PHASE1_OK" ]]
Why `backup/pr-<N>`: branch basenames collide (feat/foo vs fix/foo both become foo). PR numbers are globally unique within the repo. Why subject-only manifest: rebase rewrites SHAs; commit subjects are stable across rebases (assuming no --squash/--fixup mid-rebase), so subject + count is the invariant that survives the operation. What it verifies, precisely: that no subject went missing, got duplicated, or changed order. It says nothing about content — a conflict resolution, or any amend that keeps the subject, changes the tree while the diff stays green. So this is a structural check, and it is weakest exactly where it is needed most: right after manual conflict resolution. Before calling a rebased branch verified, compare the patches as well — git range-diff "refs/tags/backup/pr-<N>...$head" — and read the resolved hunks; on any PR whose rebase hit a conflict, CI is the evidence, not the manifest. Why origin refs: local branches drift; origin/* is SSOT. Why tags: shell variables die on session interruption; tags persist in .git/refs/tags/.
Phase 2: Sequential Merge Loop (gated)
Iteration 1 (First PR) — direct squash, no rebase
# AskUserQuestion gate (see Iteration Gate Design below)# On Proceed:ITER1_OK=1/usr/bin/env -u BASH_ENV -u ENV gh pr merge <first-PR> --squash || { echo "⛔ PR <first-PR>: the squash merge failed — the epic branch is unchanged, and iteration 2 must not proceed as though PR 1 had merged" >&2; ITER1_OK=; }epic=<quoted epic># The refresh is not bookkeeping: iteration 2 rebases onto `refs/remotes/origin/${epic}`, so a# fetch that fails leaves that ref at the pre-merge tip. Its status is therefore read, not assumed.ITER1_REFRESHED=if [[ -n "$ITER1_OK" ]]; thenif /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git fetch --upload-pack=git-upload-pack origin -- "+refs/heads/${epic}:refs/remotes/origin/${epic}"; thenITER1_REFRESHED=1elseecho "⛔ PR <first-PR> merged, but refreshing refs/remotes/origin/${epic} failed. The merge" >&2echo " stands and must not be repeated; the RUN must stop, because iteration 2 would rebase" >&2echo " onto the pre-merge tip and force-push history without PR <first-PR> in it." >&2echo " Re-run the fetch by hand, confirm origin/${epic} moved, then resume (§ Recovery)." >&2fifi# The fence's exit status. Without the first conjunct a failed squash merge is erased by the fetch# that follows it, and the run continues into iteration 2 with PR 1 silently unmerged. Without the# second, a failed FETCH is erased the same way — PR 1 merged, `origin/${epic}` stale, and the# iteration that reads that ref never told anything went wrong.[[ -n "$ITER1_OK" ]] && [[ -n "$ITER1_REFRESHED" ]]
Iteration 2..N — gate first, then rebase + force-push + CI + merge
For each subsequent PR (PR <N> with head branch <head>, previous PR was <prev>):
# PUSH_BLOCKED is this fence's own refusal record, and it exists because `exit` cannot be trusted# to end the fence. `exit` is a builtin, so an imported `BASH_FUNC_exit%%` function outranks it —# measured on bash 3.2.57: a refusal printed in full and the force-push then ran, exit status 0. No# keyword terminates a shell (`return` is a builtin too), so the fix is not a better terminator: a# refusal RECORDS itself in an assignment, and the push below is reached only through `[[ ]]`,# which the parser resolves before any name is looked up.# The record is FROZEN, not merely written — this paragraph used to say an assignment is something# "nothing outranks", which confuses the command with the value. The command cannot be outranked;# the value it wrote can be erased by whatever runs next, and under this vector that is the hostile# function itself: `BASH_FUNC_exit%%='() { PUSH_BLOCKED=; return 0; }'` cleared the flag and the# push ran at status 0 (measured 2026-08-22, bash 3.2.57 and 5.3.15). `readonly` at every pre-push# refusal site below closes it — the erasing assignment, `unset` and `declare -g` each fail against# a readonly name and the refusal held on both shells. The post-push sites that only accumulate a# status stay plain assignments: no `exit` runs between them and the guard, so the vector needs a# terminator it never gets. What none of this closes is injection — an environment that can define# `exit` can define `git`, measured the same day intercepting a whole push. The record defends the# case where the terminator alone was trusted; it was never a fence against imported functions.# `exit 1` stays —# in an ordinary shell it is still right, and it is no longer the only thing standing between a# refusal and a force-push. Cleared here rather than defaulted, so an exported value of the same# name cannot pre-approve anything either.## The guard sits on its own physical line, ending in `&& \`, so the push line's own bytes stay# out of it: everything after the `push` subcommand on that line is read as this push's argv,# by the byte pin and by the forbidden-flag scan alike, and a guard written INTO the line would# put words there that git never sees.# This paragraph used to say the two force-pushes are byte-identical by design. They are not,# and have not been since round 60 gave Step 5 an explicit `--force-with-lease=<ref>:<expect>`# and dropped `--force-if-includes` from it — measured on git 2.55.0, the flag is a silent no-op# once the lease carries a value. Round 75 put the rollback push on the same shape, so what# separates them now is one variable name: `$FINAL_TIP` here, `$RB_TIP` there. What the two share# is the refspec — an object ID on the left, under the same name, so neither publishes something# later than what it classified — and now the lease as well, each bound to the tip its own fence# measured. Both are# pinned; the pins are what make the difference visible in a diff rather than something a reader# has to notice.PUSH_BLOCKED=# Step 1: AskUserQuestion BEFORE any destructive op (see Gate Design)# On Proceed: continue Steps 2-9 atomically# On Per-step: re-prompt before push (Step 5) and merge (Step 8)# On Dry-run: print Steps 2-9 commands, do not execute# On Abort: stop, leave backup tags in place# Step 0: Bind the names ONCE — see § Names in commands. Substituting a branch name# into the lines below as literal text runs whatever it contains; a variable does not.head=<quoted head>epic=<quoted epic># Re-derived here, not inherited: this fence is a separate shell from Phase 1's, so the# `MANIFEST_DIR` set there is gone. Unset, `"${MANIFEST_DIR}/actual-pr-<N>.manifest"`# expands to `/actual-pr-<N>.manifest` and Step 4 writes at the filesystem root.# Checked, and checked HERE rather than at the write: the two commands below rewrite the branch,# so a failure discovered at Step 4 is discovered after the damage. An empty `MANIFEST_DIR`# expands `"${MANIFEST_DIR}/actual-pr-<N>.manifest"` to `/actual-pr-<N>.manifest` — the# filesystem root — which is the same class § 4.36 records for cleanup, reached one step later.MANIFEST_DIR=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --git-path epic-merge) || MANIFEST_DIR=if [[ -z "$MANIFEST_DIR" ]]; thenecho "⛔ the manifest directory could not be derived — Step 4 would compare against a file" >&2echo " written at the filesystem root, and the checkout and rebase below would already have" >&2echo " happened. Nothing is checked out and nothing is pushed." >&2readonly PUSH_BLOCKED=1SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the manifest directory could not be derived}"fi# Step 2: Checkout fresh from remote. Fully qualified — a tag named `origin/<head>` outranks# the remote-tracking ref in DWIM resolution (§ Phase 0 step 0), and this is the start point# every later step is measured against.if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git switch -C "$head" "refs/remotes/origin/$head"; thenecho "⛔ could not check out refs/remotes/origin/$head for PR <N> — the start point every later" >&2echo " step is measured against does not exist here. STOP; nothing after this means anything." >&2readonly PUSH_BLOCKED=1; exit 1fi# Step 3: Rebase — cut already-squashed commits, replay unique ones onto epicif ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rebase --onto "refs/remotes/origin/$epic" "refs/tags/backup/pr-<prev>" -- "$head"; thenecho "⛔ the rebase did not complete for PR <N>. A rebase is probably still in progress and the" >&2echo " working tree holds a partial replay — pushing it would publish a branch nobody approved." >&2echo " STOP. Resolve and continue, or abort and restore from the backup tag:" >&2echo " git rebase --abort" >&2echo " git switch -C \"$head\" refs/tags/backup/pr-<N>" >&2echo " No abort is issued here: it would discard conflict resolution the operator may have done." >&2readonly PUSH_BLOCKED=1; exit 1fi# Step 4: Verify manifest (subject + count, NOT SHA) — see the guarantee's limits below.# Named per PR, not one shared `actual` file: the loop visits each PR in turn, and a# single shared name is also what two concurrent runs would fight over.if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git log "refs/remotes/origin/$epic..$head" --pretty=format:'%s' -- > "${MANIFEST_DIR}/actual-pr-<N>.manifest"; thenecho "⛔ could not write the actual manifest for PR <N> — there is nothing to compare, so the" >&2echo " verification did not happen. Nothing is pushed. STOP." >&2readonly PUSH_BLOCKED=1; exit 1fiif ! /usr/bin/diff "${MANIFEST_DIR}/expected-pr-<N>.manifest" "${MANIFEST_DIR}/actual-pr-<N>.manifest"; thenecho "⛔ manifest mismatch for PR <N>: the rebased branch is not the branch that was approved." >&2echo " Nothing is pushed. Restoring the branch from its backup tag:" >&2if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git switch -C "$head" "refs/tags/backup/pr-<N>"; thenecho "⛔ and the restore FAILED — the working tree is in neither state. Do not push." >&2echo " git status, then: git rebase --abort (if one is in progress) and re-run the switch." >&2fireadonly PUSH_BLOCKED=1; exit 1fi# Step 5: Force-push (--force-with-lease, NEVER --force) — and never to a protected# branch: re-assert the Phase 0 check right before the push, exact match onlycase "$head" inmain|master|develop|release/*)echo "⛔ PR head '$head' is a protected branch — force push to shared branches is prohibited" >&2readonly PUSH_BLOCKED=1; exit 1;;esac# The rebase above makes this push non-fast-forward by construction, and the opt-in# pre-push hook refuses that outright (`exit 1`, no prompt) unless the caller declares# the lease form — so without this prefix the skill cannot complete on a gated repo.# ALLOW_PUSH_PROTECTED is *cleared*, never set: the guard above already refused every# protected head, and inheriting a `1` would silently disarm the hook's own check.# …and never to a different repository than the approval named. Same divergence as the probe# above, one step later: re-resolve the push destination here and compare it against the redacted# destination the approval **named in its own question text** — every bundled, per-step and# rollback gate carries `<PUSH_URLS_SAFE>`, because a fence comparing against a value the# operator was never shown detects a later config change while authorizing nothing. A config# change between the question and the push would otherwise redirect an approved history rewrite# to another repository, silently.# `PUSH_URL` — the single destination — is derived HERE, in the same conditional that reads# the list, because the post-push verification at the end of this fence looks the ref up over# it. A fence that consumes a value it never derives reads empty in a fresh shell and stale in# a reused one, and both of those look like a working step: the empty one blocks every# iteration, the stale one verifies a destination this iteration never resolved.if PUSH_URLS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git remote get-url --push --all origin); thenPUSH_URL=${PUSH_URLS%%$'\n'*}elsePUSH_URLS=; PUSH_URL=fi# A push URL can carry credentials — `https://user:token@host/repo.git`, returned verbatim by# the command above (measured 2026-08-21). The raw value never leaves this shell: everything the# operator sees, and everything compared against an approval, is the redacted form. Three# credential-bearing components are masked whole: userinfo — split at the LAST `@` inside the# authority, because git parses it that way and the first `@` leaves the tail of a password# behind — plus query and fragment, since `?access_token=` is a credential no userinfo mask# reaches. Comparing redacted forms costs this: two destinations differing inside a masked# component read alike. For userinfo that merges two credentials for one repository, never two# repositories. For query and fragment the loss is real where a host identifies the repository by# parameter, and round 54 stopped accepting it — `https://gw.example/push?repo=A&token=one` and# `…?repo=B&token=two` redact to one string (measured), so a guard on the redaction alone binds# an approval to a host and a path rather than to a repository. Identity is therefore compared on# a one-way digest of the RAW list and the redaction is only displayed; the alternative that was# rejected — printing the token — is still rejected. Scheme, host and path are never# masked, so a redirect to a different repository is still caught even before the digest. `scripts/pre-push-gate.sh`# applies the same transformation to its prompts; keep them in step.PUSH_URLS_SAFE=while IFS= read -r U; docase "$U" in*://*)REST=${U#*://}; AUTH=${REST%%/*}; AUTH=${AUTH%%\?*}; AUTH=${AUTH%%\#*}case "$AUTH" in*@*) U="${U%%://*}://<redacted>@${AUTH##*@}${REST#"$AUTH"}" ;;esaccase "$U" in*\?*) U="${U%%\?*}?<redacted>" ;;*\#*) U="${U%%\#*}#<redacted>" ;;esac;;*:*)# scp-like `[user@]host:path`. No scheme, so the arm above cannot reach it — until# 2026-08-22 every scp-like user printed verbatim, on the reasoning that it is always `git`.# It is not: `<token>@host:path` is legal, and this value goes into an approval transcript.# The `*/*` guard is the two readings of `:` — git treats one as scp-like only when no `/`# precedes it, so a local path keeps its `@`. Same as `scripts/pre-push-gate.sh`; keep in step._pre=${U%%:*}case "$_pre" in*/*) ;;*@*) U="<redacted>@${_pre##*@}:${U#*:}" ;;esac;;esacPUSH_URLS_SAFE=${PUSH_URLS_SAFE:+$PUSH_URLS_SAFE$'\n'}$Udone <<SAFE_EOF$PUSH_URLSSAFE_EOF# Round 54: identity is the DIGEST, not the redaction. Two destinations differing only in the# query redact to one string (measured), so comparing the redaction alone binds this approval to a# host and a path — and a `.git/config` edit between the question and the push then redirects an# approved history rewrite to another repository with this guard still passing. The digest is# one-way and carries no credential; `git hash-object` needs no repository. An EMPTY digest# refuses rather than matching an empty expectation.# One digest per push URL, SHA-256, space separated — a SET, because git invokes the pre-push hook# ONCE PER PUSH URL with that single URL in `$2` (measured 2026-08-22). A digest of the whole list# matches no single call, so it refused every fan-out the operator had configured and approved.# SHA-256 rather than `git hash-object`: `rules/security.md` prohibits SHA-1 where a digest carries# a security decision, and that prohibition is what makes the change mandatory. `hash-object` also# follows the *repository's* object format — measured 2026-08-22, the same URL digests to# `b354136a…` by default and `7524f1f0…` under `--object-format=sha256`, and back to the SHA-1# value outside a repository. Round 59 corrects how much that carries: it does NOT by itself make# the two sides disagree, since the plan side and the hook run for the same repository and read# the same format. It is a reason not to build a cross-process binding on a tool whose algorithm# is chosen by ambient state, and it bites where one side runs outside the repository at all.# A URL that will not hash empties the WHOLE value rather than shortening the set: a partial set# approves fewer destinations than the plan showed, and looks like a successful derivation.# Round 60: SELECT the digest tool, THEN feed it. A `||` chain over a pipeline let the FIRST# command consume stdin and then fail, after which the fallback hashed EOF. Measured 2026-08-22:# `https://gw.example/push?repo=A&token=one` and `…?repo=B&token=two` BOTH digested to# e3b0c442…b855 — the SHA-256 of the empty string — so two different destinations compared EQUAL# and the destination guard passed on a destination that had changed. `command -v` does not read# stdin, so doing the selection with it feeds the input exactly once, to exactly one tool. Same# shape as `scripts/pre-push-gate.sh` § sha256_raw, deliberately: one algorithm, stated once.sha256_raw() { # reads stdin, writes the selected tool's own output line; nonzero only if none exists# Invoked through `/usr/bin/env`, never as a bare word. `command -v` reports an imported shell# function as a perfectly good command, and the known-answer test below only rejects a tool that# answers one CONSTANT. An ADAPTIVE function passes both vectors and then returns one fixed# digest for every real URL, so two different destinations compare EQUAL and the approval is# bound to nothing. `env` resolves PATH only, and bash refuses to import a function whose name# contains a slash, so a function-only match makes `env` fail and the test below correctly# empties the digest. `scripts/pre-push-gate.sh` needs no such spelling and is not inconsistent# with this: its `#!/usr/bin/env -S bash -p` shebang refuses to import functions at all, while# these fences have no shebang of their own. The defence differs because the channel does.if command -v sha256sum >/dev/null 2>&1; then /usr/bin/env sha256sumelif command -v shasum >/dev/null 2>&1; then /usr/bin/env shasum -a 256elif command -v openssl >/dev/null 2>&1; then /usr/bin/env openssl dgst -sha256else return 1fi}sha256_hex() { # the bare hex the tool produced — NO shape check, the KAT below needs the raw answer_H=$(/usr/bin/printf '%s' "$1" | sha256_raw 2>/dev/null) || _H=_H=${_H##*= } # openssl: `SHA2-256(stdin)= <hex>`_H=${_H%% *} # sha256sum / shasum: `<hex> -`/usr/bin/printf '%s' "$_H"}# Known-answer test, two vectors. A tool that answers one constant whatever it is fed makes every# destination compare equal to every approval — and a constant is well-shaped, so the shape check# in the loop cannot see it. The empty vector is precisely the answer the defect above produced.DIGEST_TOOL_OK=if [[ "$(sha256_hex '')" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ]] \&& [[ "$(sha256_hex abc)" = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad ]]; thenDIGEST_TOOL_OK=yesfiPUSH_URLS_DIGEST=while IFS= read -r U; do[[ -n "$U" ]] || continueD=if [[ -n "$DIGEST_TOOL_OK" ]]; then D=$(sha256_hex "$U"); ficase "$D" in *[!0-9a-f]*|'') D= ;; *) [[ ${#D} -eq 64 ]] || D= ;; esacif [[ -z "$D" ]]; then PUSH_URLS_DIGEST=; break; fiPUSH_URLS_DIGEST=${PUSH_URLS_DIGEST:+$PUSH_URLS_DIGEST }$Ddone <<< "$PUSH_URLS"# `remote.<name>.receivepack` names the program that receives the objects on the far side, and a# program is free to ignore the repository the URL named. Measured 2026-08-22: with one configured,# an ordinary branch push printed `To <the approved URL> * [new branch] main -> main` while every# object landed in a DIFFERENT repository and the named one stayed empty. No digest of the URL can# see that, so with one configured the destination is not established and this skill does not push.# The gate refuses it too where the binding reaches it; this line is what covers the projects that# never installed the gate, and `git-workflow.md` § Push safety is why the absent gate moves the# question here rather than deleting it. This read is best-effort and its boundary is measured:# git runs the pre-push hook only after the ref advertisement, so a wrapper that clears its own# config key before serving redirects the objects while every reader here sees nothing (measured# 2026-08-22 — the hook saw `<unset>`, git reported success against the named URL, and the objects# landed elsewhere). What closes that is the push line itself, which spells# `--receive-pack=git-receive-pack`: a command-line value overrides the configured one, while# `-c remote.<name>.receivepack=` does not (git keeps the config value and says "more than one# receivepack given, using the first"). This read still earns its place — it refuses BEFORE the# operator is asked to approve a destination that was never going to receive the objects.PUSH_RECEIVEPACK=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git config --get remote.origin.receivepack 2>/dev/null) || PUSH_RECEIVEPACK=if [[ -n "$PUSH_RECEIVEPACK" ]] || [[ -z "$PUSH_URLS" ]] || [[ -z "$PUSH_URLS_DIGEST" ]] \|| [[ "$PUSH_URLS_DIGEST" != "<the PUSH_URLS_DIGEST value the classifier fence printed for this iteration, written literally and quoted>" ]] \|| [[ "$PUSH_URLS_SAFE" != "<the redacted destination this iteration's approval named — the PUSH_URLS_SAFE value the question showed>" ]]; thenecho "⛔ push destination '${PUSH_URLS_SAFE:-unresolvable}' is not the one approved — refusing" >&2echo " (identity is a digest of the raw destination, so a change the redaction hides still refuses)" >&2if [[ -n "$PUSH_RECEIVEPACK" ]]; thenecho " (remote.origin.receivepack is configured, so the URL does not decide where the objects land; read it with: git config --get remote.origin.receivepack)" >&2fireadonly PUSH_BLOCKED=1; exit 1fi# ⚠️ Why this is a comparison and not a push to the validated URL — the measurement, recorded so the# swap is not proposed a third time. Round 54 declined it because a URL destination defeats# `--set-upstream`, which does not apply here (nothing in this skill passes `-u`), and that made the# refusal look like an accident inherited from `/push-ci`. It is not. Measured 2026-08-22: with# `url.<B>.insteadOf=<A>` configured, a push whose destination argument was the literal URL `<A>`# — no remote name anywhere on the line — put the ref in **B**. git# applies the rewrite layer to a command-line URL exactly as to a remote name, so addressing the URL# relocates the re-resolution and pins nothing. The same run shows the check is honest —# `git remote get-url --push --all origin` reports the POST-rewrite URL, so the digest above covers# the destination git would really use. What is left cannot be closed **by naming a destination**:# git resolves it inside its own process, from configuration this shell cannot freeze, and every# construct that names one goes through the same rewrite layer. The comparison sits in the SAME# fence as the push with no question in between, so the window it narrows is the real one (the# approval is iterations away). It was written here as "irreducible client-side", and that was# wrong — corrected 2026-08-22: git hands the resolved destination to `pre-push-gate.sh` as `$2`,# inside the pushing process, and `SD0X_PUSH_DEST_DIGEST` below binds against it.# `SD0X_PUSH_DEST_DIGEST` is the other half of the destination check above, and the half that is# not a race. The comparison a few lines up re-reads the destination in THIS shell; the push is a# different process, so a `.git/config` edit or a `url.<x>.pushInsteadOf` landing in between still# redirects it. git closes that window itself and hands the answer to the pre-push hook as `$2` —# the destination it is about to reach, resolved inside the pushing process, after every rewrite.# Measured 2026-08-22 (git 2.55.0): under `url.<B>.pushInsteadOf=<A>` a push naming `origin` gives# `$1=origin` and `$2=<B>`, and the digest of `$2` equals the digest of `git remote get-url --push# --all origin` byte for byte, with the rewrite and without it. Wired end to end: the rewrite was# refused and nothing reached B; the same push carrying B's own digest went through.## **This is not an ALLOW_* variable and the Prohibited list does not cover it.** Those are# developer attestations, which is why this skill must never set them and must clear the ones it# inherits. This one is the opposite direction: it is a constraint the skill imposes on its own# push, it can only ever cause a refusal, and setting it inline is what stops an inherited value# from deciding. Where the hook is not installed it does nothing at all — monotone, like# the lease binding below (round 60): this fence no longer carries `--force-if-includes`.# ── Step 5 topology re-check: measured AFTER the rebase, in both modes (round 59) ──────────────# **Bundled mode decided whether an unshared attestation was owed before Step 2, and then Steps 2# and 3 changed the very topology it predicted.** The prediction reads the remote-tracking ref;# Step 2 checks that ref out and Step 3 rebases it. Between the prediction and the push, a# collaborator can force-update the PR head and any background fetch can move# `refs/remotes/origin/<head>` — after which Step 2 checks the new tip out, Step 3 drops or# re-parents it, `--force-with-lease` sees the tip it just fetched, and `--force-if-includes`# passes because Step 2 put that tip in this branch's reflog. § Safety already records that exact# outcome: "a collaborator commit checked out locally and then dropped by a rewrite is overwritten# with exit 0." A prediction is not a measurement, and the only place a measurement is possible is# here — after the commit that will be pushed exists.## This is not a second approval in the common case. It re-derives the reading and STOPS only when# the prediction was falsified; a bundled iteration that predicted `no-rewrite` and still rewrites# nothing passes through it silently, so the gate count in § Gate Moments is unchanged for every# run whose prediction held.## **Round 60 corrects what "falsified" means here.** The first version refused every measured# rewrite unconditionally — including the one this iteration had ALREADY collected an unshared# attestation for, which is the ordinary path of this whole skill: rebase, rewrite, force-push.# It therefore stopped every normal iteration and its own advice ("re-run Step 5 on a yes") looped# straight back into the same refusal, because the rerun measures the same rewrite. What the check# is for is the case where the reading and the attestation DISAGREE, so it has to be able to see# the attestation.## `UNSHARED_ATTESTED` is that attestation, and it is written **literally into this fence** by the# model from the operator's answer, exactly like `PUSH_URLS_DIGEST` below. Three properties, none# optional: it is **never read from the environment**, so an exported value cannot answer a# question nobody was asked (that is the whole hazard `ALLOW_FORCE_UNSHARED` carries, which is why# this skill clears that one and does not imitate it); it is assigned **unconditionally** here, so# an inherited value cannot survive to the test; and its default is **empty**, so a model that# forgets to fill it in refuses the push rather than authorizing it.# It names the REF, because that is what the operator was asked about: an attestation about# `<head>` says nothing about any other branch, and comparing the ref is what stops it carrying.## Fill it in ONLY when THIS iteration asked the unshared question by name and the operator# answered "Nobody else works on <head>": replace the empty value below with the literal,# quoted string "refs/heads/<head>". Every other case leaves it empty — no question asked,# "Someone else might", an attestation collected in an earlier iteration, or one given about# another ref. Empty refuses.UNSHARED_ATTESTED=# The remote tip the iteration gate PRINTED as `REMOTE_TIP=[...]` before the rebase — the commit# the operator was shown as the thing this push would overwrite — written literally and quoted by# the model, on the same three properties as the attestation above. The rebase moves the LOCAL# side, so this fact is still the destination's; re-reading it here would ask the question again# instead of remembering the answer, which is the failure the field closes. Empty refuses,# because the `rewrite` arm compares it against a `$FINAL_TIP` that is non-empty by construction.APPROVED_TIP=PUSHED=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --verify --quiet "refs/heads/${head}") || PUSHED=# Anything but exactly one destination is fail-closed, the same test the two classifier fences# below apply: this lookup asks ONE url what it holds, so an empty or plural list leaves it no# single destination to ask about. `$(...)` strips trailing newlines, so one URL leaves none# and "$PUSH_URLS" != "$PUSH_URL" is precisely "more than one" — expansion, no command to shadow.if [[ -z "$PUSH_URL" ]] || [[ "$PUSH_URLS" != "$PUSH_URL" ]]; thenFINAL_TIP=; FINAL_LOOKUP_FAILED=1# Round 76: `$PUSH_URL` has already been through one `url.*.insteadOf` pass, and handing that# string to another git command applies a SECOND. Measured 2026-08-22 (git 2.55.0) with# `url.<B>.insteadOf=<A>` and `url.<C>.insteadOf=<B>`: the resolved push URL is B and the push# lands in B, while `git ls-remote -- <B>` answers **C's** tip — so the lease would carry a value# measured from a repository this push never contacts. No repair is available from a URL string# (anything handed back to git is rewritten again), so the reading becomes `unknown` and the arm# below refuses. The detector is purely local: `--get-url` expands the URL and exits.elif ! FINAL_REPROBE=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --get-url -- "$PUSH_URL") || [[ "$FINAL_REPROBE" != "$PUSH_URL" ]]; thenecho "⛔ url.*.insteadOf rewrites the resolved push destination a SECOND time — the push" >&2echo " goes to the once-rewritten URL while a probe of that URL reads the twice-" >&2echo " rewritten one, so nothing here can measure the destination. STOP." >&2FINAL_TIP=; FINAL_LOOKUP_FAILED=1elif FINAL_LS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --upload-pack=git-upload-pack -- "$PUSH_URL" "refs/heads/${head}"); thenFINAL_TIP=${FINAL_LS%%$'\t'*}; FINAL_LOOKUP_FAILED=elseFINAL_TIP=; FINAL_LOOKUP_FAILED=1fi# Same ordering discipline as the classifier: the fail-closed rows are tested FIRST, because a# failed lookup also leaves the tip empty and testing emptiness first would read every unreachable# remote as a creation.if [[ -z "$PUSHED" ]] || [[ "$FINAL_LOOKUP_FAILED" = 1 ]]; thenFINAL_ANCESTRY=; FINAL_READING=unknownelif [[ -z "$FINAL_TIP" ]]; thenFINAL_ANCESTRY=; FINAL_READING=creationelif [[ "$FINAL_TIP" = "$PUSHED" ]]; thenFINAL_ANCESTRY=; FINAL_READING=up-to-dateelseif /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git merge-base --is-ancestor "$FINAL_TIP" "$PUSHED"; then FINAL_ANCESTRY=0; else FINAL_ANCESTRY=$?; fi# captured immediately — three readings, never twocase "$FINAL_ANCESTRY" in0) FINAL_READING=fast-forward ;;1) FINAL_READING=rewrite ;;*) FINAL_READING=unknown ;;esacfi# The refusal is written as a `case` over the WORD, not as a negated list of the benign ones: a# reading this fence has never heard of must land in the refusing arm, and `*` does that by# construction. `creation`, `up-to-date` and `fast-forward` are the three that rewrite nothing,# so they need no attestation. `rewrite` needs one, and holds it on the ordinary path. `unknown`# and every unheard-of word refuse whatever the operator attested, and that is not an oversight:# the attestation answers "is this ref shared", while `unknown` says the MEASUREMENT failed, and# no answer to the first question is evidence about the second. It also keeps the lease honest —# the push below binds itself to `$FINAL_TIP`, and the only readings that reach it are the ones# where that tip was actually read.case "$FINAL_READING" increation|up-to-date|fast-forward) ;;rewrite)if [[ "$UNSHARED_ATTESTED" != "refs/heads/${head}" ]]; thenecho "⛔ the post-rebase topology is a rewrite of refs/heads/${head} and this iteration holds" >&2echo " no unshared attestation for that ref. The gate before Step 2 was answered about a" >&2echo " topology the rebase has since changed, so it does not cover this push." >&2echo " STOP. Two things are owed, and the ORDER is the contract (git-workflow.md" >&2echo " § Push safety: the unshared question comes BY NAME and BEFORE the force approval):" >&2echo " 1) put the unshared question to the operator by name;" >&2echo " 2) on a yes, ask the per-iteration force approval AGAIN, with a plan that states" >&2echo " this push rewrites the ref and shows the lease it will carry. The approval this" >&2echo " iteration holds was given for a topology the rebase has since changed." >&2echo " Only then re-run this fence with UNSHARED_ATTESTED=refs/heads/${head}." >&2echo " Do not set ALLOW_FORCE_UNSHARED." >&2readonly PUSH_BLOCKED=1; exit 1fi# The attestation is a credential; this is a FACT, and one cannot stand in for the other. The# check above binds the ref name to the approval and the lease below binds itself to# `$FINAL_TIP` — but the commit this push DESTROYS was bound to nothing. A tip that moved# between the iteration gate and here means the operator approved overwriting one commit while# this fence overwrites a different one, and the lease, carrying the NEW value, sails through.# The movement is also evidence AGAINST the attestation: a ref nobody else holds does not# acquire commits nobody here published.if [[ "$FINAL_TIP" != "$APPROVED_TIP" ]]; thenecho "⛔ refs/heads/${head} points at '${FINAL_TIP:-<none>}' but this iteration's approval" >&2echo " covered overwriting '${APPROVED_TIP:-<none>}' — a different commit would be destroyed." >&2echo " The attestation you hold says this ref is not shared; the tip moving since the" >&2echo " iteration gate is evidence against it, so it cannot carry this push. STOP." >&2echo " 1) put the unshared question to the operator by name, for the tip as it reads NOW;" >&2echo " 2) on a yes, ask the per-iteration force approval AGAIN, with a plan naming that tip." >&2echo " Do not set ALLOW_FORCE_UNSHARED." >&2readonly PUSH_BLOCKED=1; exit 1fi ;;*)echo "⛔ post-rebase topology reads '${FINAL_READING}' for refs/heads/${head} — the measurement" >&2echo " did not answer, so nothing here knows what this push would overwrite and no" >&2echo " attestation about sharedness can supply it. STOP; re-run once the destination reads." >&2readonly PUSH_BLOCKED=1; exit 1 ;;esac# `if` rather than `cmd` followed by `STEP5_STATUS=$?`: an inherited `errexit` aborts the shell# AT the failing push, before a following capture line could run, and the `case` below — the# arm that says the PR still points at its pre-rebase commit and Steps 6-9 must not run — would# never be reached. A command whose status the `if` itself consumes is not a command `set -e`# acts on, so the classification survives the very failure it exists to classify.# `then` sits on its own line and the command is unindented: everything after the `push`# subcommand on that line is read as its own argv — by the byte pin, and by the forbidden-flag# scan that decides over tokens. A trailing `; then` would put two words there that git never sees.# `$PUSHED`, not `refs/heads/${head}`, on the left of the refspec below — and the same in the# rollback push. Every classification above is about the object `$PUSHED` holds;# `refs/heads/${head}` is a NAME git resolves again, inside its own process, after each of# those decisions has been taken. Between the `rev-parse` that filled `$PUSHED` and that# resolution the branch can move — a rebase finishing in another worktree, a second agent# session, an editor. The lease binds the DESTINATION (`--force-with-lease=<ref>:<expect>`# refuses if the remote moved) and nothing bound the source, so a `fast-forward` reading —# the arm that needs no unshared attestation — could publish an object nothing classified.# An object ID on the left cannot be moved by anything between here and git's resolution.# `[[ -n "$PUSHED" ]]` is not redundant with the classification. An empty left side makes the# refspec `":refs/heads/${head}"`, which is git's spelling for DELETE that branch. Reaching# the push with `$PUSHED` empty already requires the `unknown` arm's `exit 1` to have been# answered by an imported `BASH_FUNC_exit%%` that returns — the case this document anticipates# everywhere else — and closing it costs one `[[ ]]`.if [[ -z "$PUSH_BLOCKED" ]] && [[ -n "$PUSHED" ]] && \/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT ALLOW_PUSH_PROTECTED= ALLOW_FORCE_UNSHARED= SD0X_PUSH_DEST_DIGEST="$PUSH_URLS_DIGEST" GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 ALLOW_FORCE_WITH_LEASE=1 git push --force-with-lease="refs/heads/${head}:${FINAL_TIP}" --receive-pack=git-receive-pack "origin" -- "${PUSHED}:refs/heads/${head}"thenSTEP5_STATUS=0elseSTEP5_STATUS=$?fi# The push is the last thing above that can fail into a mutation of the PR. `$?` here is the# whole AND-list: nonzero when the push failed AND when a guard above refused it, and both must# stop. `case` rather than `[[ … -eq … ]]` for the reason used everywhere else in this document —# it is a keyword the parser resolves, and it compares text rather than evaluating arithmetic on# an operand that could be empty.case "$STEP5_STATUS" in0) ;;*) echo "⛔ Step 5 did not publish refs/heads/${head} (status ${STEP5_STATUS}) — the PR still" >&2echo " points at its pre-rebase commit. Steps 6-9 must not run: retargeting and merging it" >&2echo " now would merge a diff nobody approved. Nothing has been merged." >&2PUSH_BLOCKED=1 ;;esac# Step 6: Update PR base so CI runs against correct diffif [[ -z "$PUSH_BLOCKED" ]]; then/usr/bin/env -u BASH_ENV -u ENV gh pr edit "<N>" --base "$epic" || {echo "⛔ Step 6: PR <N> could not be retargeted onto ${epic} — CI would then run against the" >&2echo " wrong base diff, so its verdict would not be about this change. STOP." >&2PUSH_BLOCKED=1}fi# Step 7: resolve the commit CI must be asked about, then hand off# `--verify --quiet` + fully-qualified ref: the only form that yields exactly one SHA.# A separator does NOT work here — see § the separator table above.if [[ -z "$PUSH_BLOCKED" ]]; thensha=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --verify --quiet "refs/heads/${head}") || {echo "⛔ cannot resolve PR head '${head}' to a single commit — STOP" >&2PUSH_BLOCKED=1}# Absolute path, like every other report in this document. This one is a cross-fence handoff:# Step 8 writes the value back literally and merges with `--match-head-commit`, so a forged SHA# here sends `/watch-ci` to the wrong commit while the fence still exits 0. `printf` is a# builtin and an imported function outranks it (measured under bash 3.2, the PATH bash, and zsh)./usr/bin/printf 'PR_HEAD_SHA=%s\n' "$sha"# `$?` on the next line, and this is the shape § 4.46 removed elsewhere — safe here for the# same reason Phase 2 of `/push-ci` is: what § 4.46 forbids is a following capture whose# purpose is to REACH a classification the dying shell will skip. Under an inherited# `errexit` a failing `printf` kills the shell, and a non-zero fence is exactly the outcome# this arm exists to produce — so the line being skipped costs nothing. With `errexit` off# it runs, and it is the only thing that notices. Kept on its own line rather than in a# `|| { … }` so the report keeps the exact bytes two tests pin it by.REPORT_STATUS=$?if [[ "$REPORT_STATUS" != 0 ]]; thenecho "⛔ the PR head SHA could not be reported (printf exited ${REPORT_STATUS}) — Step 8" >&2echo " has nothing to write back, and /watch-ci would be dispatched against an absent" >&2echo " or stale commit. STOP." >&2PUSH_BLOCKED=1fifi# The fence's exit status, and the last line it has: zero only if the push landed, the base was# retargeted, the SHA resolved **and the handoff was actually written**. That last clause is the# one this comment claimed before anything checked it: `printf` writing to a closed or full# stdout fails, and with `errexit` not inherited the failure was invisible here — `PUSH_BLOCKED`# stayed empty and the fence reported the success of a handoff that produced no value. Step 8# then merges with `--match-head-commit` against whatever the caller believed the SHA to be.[[ -z "$PUSH_BLOCKED" ]]
Only if that fence exited zero, dispatch CI with the SHA it printed:
/watch-ci --sha <the PR_HEAD_SHA value the fence printed> --branch <head> --timeout <--ci-timeout value (default 15)>
Why the fence ends there. /watch-ci is a skill dispatch, not a shell command. Written as a # comment inside a running fence it is a line the shell skips — and every line after it then executes regardless of what CI said, which is how a squash merge could happen with no PASS verdict on a fence that reads as though it waited. The gate is the fence boundary: Step 8 is a separate fence, entered only on PASS. On FAIL, on a timeout, or on any verdict that is not PASS — STOP and restore from backup (§ Recovery). Fences do not share a shell, so the one below re-establishes what it uses; that is what makes the separation mechanical rather than a promise.
# Step 8: Squash merge — reached ONLY on a PASS verdict from the dispatch aboveMERGE_BLOCKED=epic=<quoted epic># The SHA `/watch-ci` returned PASS for, written literally by the model from the PR_HEAD_SHA the# previous fence printed. Not read from the environment and not re-derived: re-deriving it here# would resolve the head as it stands NOW, which is exactly the value the check exists to distrust.CI_PASSED_SHA=<the PR_HEAD_SHA value the previous fence printed, quoted># A PASS verdict is about a commit and a base, not about a PR number. Both can move while# `/watch-ci` waits and after it returns, so both are bound before the merge.BASE_NOW=$(/usr/bin/env -u BASH_ENV -u ENV gh pr view "<N>" --json baseRefName --template '{{.baseRefName}}') || BASE_NOW=if [[ -z "$CI_PASSED_SHA" ]] || [[ "$BASE_NOW" != "$epic" ]]; thenecho "⛔ Step 8: the PASS verdict cannot be tied to this merge." >&2echo " tested commit: ${CI_PASSED_SHA:-<none carried into this fence>}" >&2echo " PR base now: ${BASE_NOW:-<unreadable>} expected: ${epic}" >&2echo " Merging now would merge a diff that verdict was not about. STOP." >&2MERGE_BLOCKED=1fi# `--match-head-commit` is checked by GitHub at merge time, which is the only place it can be# checked without a race: a local comparison would read the head, then merge, and the head may move# between the two. The base has no such flag — the read above is a best-effort narrowing, and the# window between it and the merge is real. Say so; do not let the code imply otherwise.if [[ -z "$MERGE_BLOCKED" ]]; then# Absolute, via `env`, like every other command word in this document that decides something.# `gh` is an external program, and that is not protection: shell function lookup precedes PATH,# so an exported `BASH_FUNC_gh%%` claims the word. Measured 2026-08-22 — an imported `gh()`# that recorded its arguments and returned 0 left `MERGE_BLOCKED` empty, let the fetch below# succeed against an unchanged epic branch, and reported the iteration merged. The existing# tests place a fake `gh` on PATH, which proves exit-status propagation and says nothing about# function precedence — a real executable is never consulted when a function has the name./usr/bin/env -u BASH_ENV -u ENV gh pr merge "<N>" --squash --match-head-commit "$CI_PASSED_SHA" || {echo "⛔ Step 8: PR <N> was not merged — either the head no longer matches the commit CI" >&2echo " passed, or the merge itself failed. Either way the epic branch is unchanged, so the" >&2echo " next iteration must not rebase onto it as though it had moved. STOP." >&2MERGE_BLOCKED=1}fi# Step 9: Refresh epic — its status is read for the same reason Step 8's is. The NEXT iteration# rebases onto `refs/remotes/origin/${epic}`; a fetch that fails leaves that ref at the pre-merge# tip, so the iteration after this one would rebase and force-push history missing this PR. From# here on `MERGE_BLOCKED` means "this iteration did not complete", which is what the exit status# below is for — the messages say which half failed, and they are not the same repair.if [[ -z "$MERGE_BLOCKED" ]]; thenif ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git fetch --upload-pack=git-upload-pack "origin" -- "+refs/heads/${epic}:refs/remotes/origin/${epic}"; thenecho "⛔ Step 9: PR <N> merged, but refreshing refs/remotes/origin/${epic} failed. The merge" >&2echo " stands and must not be repeated; the RUN must stop — the next iteration would rebase" >&2echo " onto the pre-merge tip and force-push history without PR <N> in it." >&2MERGE_BLOCKED=1fifi[[ -z "$MERGE_BLOCKED" ]]
Iteration Gate Design
Default for iterations 2..N: the unshared question, then one bundled gate — two in total, as the table's first row says, and one when the topology test below says the iteration rewrites nothing. Iteration 1 has the Proceed question and nothing else. Operator can opt into finer control with --per-step.
| Mode | Gate count per iteration (2..N) | Gate moments | When to use | |
|---|---|---|---|---|
| Bundled (default) | 2 (1 when nothing is rewritten) | The unshared question, then one bundled gate before Step 2 (covering Steps 2-9 — every step of the iteration, including the closing epic refresh) | Trusted chain, fast iteration | |
--per-step | 4 (3 when nothing is rewritten) | Before Step 3 (rebase), the unshared question, Step 5 (push), Step 8 (merge) | First-time use, untrusted diff, recovery from prior failure |
The unshared question is owed only where the iteration actually rewrites something. § Phase 2 already records the exception — an iteration whose head is already based on the target rebases to a no-op, local and remote OIDs match, git sends no ref and the hook exits 0 without prompting — and a gate table that asks unconditionally contradicts it, putting a rewrite to the operator that this iteration will not perform. rules/git-workflow.md § Push safety measures topology, not the declared flag; so does the hook. Determine it before asking, and fail closed:
| Mode | What is measurable at the moment the question is due | |
|---|---|---|
--per-step | The rebase has already run, so the outcome is observable directly: the remote tip against the rebased HEAD | |
| Bundled (default) | The gate precedes Step 2, so the outcome must be predicted: does the head already sit on the target, making the rebase the no-op above |
The tests are written out rather than quoted in a table cell, and that is not presentation. The test/skills/epic-merge.test.js guard that requires the canonical prefix on every git command deliberately does not judge table cells — it asserts so explicitly — so a command living in a cell is a command nobody checks. These decide whether an attestation is collected; they belong where the guard can see them:
# Step 0's bindings are gone — this is a separate shell (§ Names in commands), and both names below# reach a command line. Unset, `"refs/heads/${head}"` is `refs/heads/` and# `"refs/remotes/origin/${epic}"` is `refs/remotes/origin/`: the per-step classifier reads the# empty remote answer as a branch creation, the bundled one degrades to `unknown` because its local# refs will not resolve, and either verdict is about an empty name rather than about the iteration# the operator is being asked to approve.head=<quoted head>epic=<quoted epic>if [[ -z "$head" ]] || [[ -z "$epic" ]]; thenecho "⛔ iteration gate: head or epic is unbound in this fence. Every ref below would name" >&2echo " refs/heads/ or refs/remotes/origin/, and the answer would be about neither branch." >&2echo " Bind both from the chain table (§ Names in commands) and re-run. Nothing is pushed." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — head or epic unbound in the iteration-gate fence}"fi# --per-step: after Step 3, before the push question.# Fully qualified refs on BOTH sides. `origin/<name>` is shorthand git resolves through# refs/tags/ before refs/remotes/, and § Backup already forbids it for exactly this reason —# `git check-ref-format refs/tags/origin/feat-x` exits 0, so a tag of that name is legal and would# answer this ancestry question in place of the branch.# No pipe and no `awk`: a pipeline reports its LAST command's status, so `ls-remote | awk` exits 0# when ls-remote fails (measured `rc=0` against a nonexistent remote) and the fail-closed reading# below becomes unreachable; and a bare `awk` is shadowable by an imported function, which would# emit an empty tip from real output. `${x%%<tab>*}` is expansion — no command to shadow.# `origin` is two destinations, not one: `git ls-remote origin` reads the FETCH url, while# pushing to that same name contacts the PUSH url — `remote.origin.pushurl`, or `url.<x>.pushInsteadOf`# with no explicit pushurl, moves them apart (both measured, git 2.55.0). Probing one and# rewriting the other classifies repository A and force-pushes repository B. `--push --all` is# the oracle that matched what push actually contacted in both cases; `--all` because pushurl is# multi-valued and the singular form reports a fan-out as a single destination.if PUSH_URLS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git remote get-url --push --all origin); thenPUSH_URL=${PUSH_URLS%%$'\n'*}elsePUSH_URLS=; PUSH_URL=fi# Redacted destination — the same derivation, and the same reason, as its first use above.PUSH_URLS_SAFE=while IFS= read -r U; docase "$U" in*://*)REST=${U#*://}; AUTH=${REST%%/*}; AUTH=${AUTH%%\?*}; AUTH=${AUTH%%\#*}case "$AUTH" in*@*) U="${U%%://*}://<redacted>@${AUTH##*@}${REST#"$AUTH"}" ;;esaccase "$U" in*\?*) U="${U%%\?*}?<redacted>" ;;*\#*) U="${U%%\#*}#<redacted>" ;;esac;;*:*)# scp-like `[user@]host:path`. No scheme, so the arm above cannot reach it — until# 2026-08-22 every scp-like user printed verbatim, on the reasoning that it is always `git`.# It is not: `<token>@host:path` is legal, and this value goes into an approval transcript.# The `*/*` guard is the two readings of `:` — git treats one as scp-like only when no `/`# precedes it, so a local path keeps its `@`. Same as `scripts/pre-push-gate.sh`; keep in step._pre=${U%%:*}case "$_pre" in*/*) ;;*@*) U="<redacted>@${_pre##*@}:${U#*:}" ;;esac;;esacPUSH_URLS_SAFE=${PUSH_URLS_SAFE:+$PUSH_URLS_SAFE$'\n'}$Udone <<SAFE_EOF$PUSH_URLSSAFE_EOF# Anything but exactly one URL is fail-closed. $(...) strips trailing newlines, so one URL leaves# none and "$PUSH_URLS" != "$PUSH_URL" is precisely "more than one" — expansion, no command to shadow.if [[ -z "$PUSH_URL" ]] || [[ "$PUSH_URLS" != "$PUSH_URL" ]]; thenREMOTE_TIP=; LOOKUP_FAILED=1# Round 76, the same detector the Step 5 and rollback fences carry. `$PUSH_URL` has already# been through one `url.*.insteadOf` pass, and handing it to another git command applies a# SECOND. Where a chain exists this lookup reads a repository the push never contacts, so its# tip is not about the destination and the ancestry test below would classify the wrong# history. `LOOKUP_FAILED=1` is the honest reading and it fails closed — the classification# becomes `unknown` and the unshared question gets asked rather than skipped. The detector is# purely local: `--get-url` expands the URL through the rewrite table and exits.elif ! REMOTE_REPROBE=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --get-url -- "$PUSH_URL") || [[ "$REMOTE_REPROBE" != "$PUSH_URL" ]]; thenecho "⛔ url.*.insteadOf rewrites the resolved push destination a SECOND time — this" >&2echo " lookup would read a repository the push never contacts. Topology unknown." >&2REMOTE_TIP=; LOOKUP_FAILED=1elif REMOTE_LS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --upload-pack=git-upload-pack -- "$PUSH_URL" "refs/heads/${head}"); thenREMOTE_TIP=${REMOTE_LS%%$'\t'*}; LOOKUP_FAILED=elseREMOTE_TIP=; LOOKUP_FAILED=1fiNEW_HEAD=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse HEAD)# Round 53. The ancestry test is GUARDED, and the guard is the classification rather than a# precaution taken before it. `merge-base --is-ancestor "" "$NEW_HEAD"` exits 128 — measured — so# running it unconditionally made an empty `REMOTE_TIP` match two table rows that contradict each# other: "merge-base exited above 1, topology unknown, ask" and "REMOTE_TIP empty, this is a# creation, do not ask". Both were true of the same run, and which one the agent applied was left# to whoever read the table. A creation would then be asked the unshared question about a branch# the remote does not have, which is not a stricter reading — it is a question with no subject, and# an operator who learns the question is sometimes meaningless is the failure the attestation# contract cannot survive.## So the fence derives ONE word, in an order where nothing overlaps. `LOOKUP_FAILED` is tested# FIRST and that position is load-bearing, not stylistic: a failed lookup also leaves `REMOTE_TIP`# empty, so testing emptiness first would read every unreachable remote as a creation and silence# the fail-closed row entirely — the exact inversion of what fail-closed means.if [[ "$LOOKUP_FAILED" = 1 ]]; thenITER_ANCESTRY=; ITER_READING=unknown # the lookup never answered — ask, fail closedelif [[ -z "$REMOTE_TIP" ]]; thenITER_ANCESTRY=; ITER_READING=creation # answered, and the branch is not there yetelif [[ "$REMOTE_TIP" = "$NEW_HEAD" ]]; thenITER_ANCESTRY=; ITER_READING=up-to-date # the remote already holds this commitelse# Two real object names, so the test has a subject and its three exits mean three things.if /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git merge-base --is-ancestor "$REMOTE_TIP" "$NEW_HEAD"; then ITER_ANCESTRY=0; else ITER_ANCESTRY=$?; fi# 0 contained · 1 not contained · >1 merge-base ERRORED — three readings, never twocase "$ITER_ANCESTRY" in0) ITER_READING=fast-forward ;;1) ITER_READING=rewrite ;;*) ITER_READING=unknown ;; # errored rather than answered — never read as "no"esacfi# Bundled: before Step 2, predicting from the not-yet-rebased refs. BOTH tests must pass before# "no rewrite" may be read; either one alone is unsound. Ancestry says the head already contains# the epic tip, but the operation is `rebase --onto "refs/remotes/origin/${epic}" "backup/pr-<prev>" "${head}"`,# which replays `backup/pr-<prev>..${head}`. Those commits keep their OIDs only when they are# already parented on the destination — i.e. when the cut point IS the destination. With a cut# point behind it, the replay re-parents commits and mints new OIDs while ancestry still reads# "contained", which predicts no rewrite for a push that rewrites.if /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git merge-base --is-ancestor "refs/remotes/origin/${epic}" "refs/remotes/origin/${head}"; then BUNDLED_ANCESTRY=0; else BUNDLED_ANCESTRY=$?; fi# captured IMMEDIATELY: every command below overwrites $?, and a fence whose# final status is the `test` alone reports success for ANCESTRY=1 (a measured# rewrite) and for ANCESTRY=128 (an error) whenever CUT happens to equal DEST.# Measured both. That is the same status-loss defect the `| awk` pipeline had.CUT=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify "backup/pr-<prev>^{commit}") || CUT=DEST=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git rev-parse --verify "refs/remotes/origin/${epic}^{commit}") || DEST=# Unresolvable either side is unknown, not benign — the table below reads it as fail-closed.# "No rewrite" requires ALL FOUR, and is written as one expression so no later command can# silently become the answer.## `LOOKUP_FAILED` is one of the four, added in round 54 because leaving it out was a real hole# rather than a tidiness point. Everything else this fence measures is LOCAL — `refs/remotes/*`# and `backup/pr-*`, all read from the local object store — so all four other predicates can# succeed while the push DESTINATION never resolved. `no-rewrite` would then be published for a# push whose remote tip nobody looked at, and the attestation would be skipped on the strength of# a prediction about a repository the fence could not reach. Measured: with `LOOKUP_FAILED=1# BUNDLED_ANCESTRY=0 CUT=aaaa DEST=aaaa` the previous expression printed `no-rewrite`, while the# reading table classified the same run as `unknown` — the code and the table disagreed, and the# code is the one that ran.# The exit status becomes a WORD before it is composed with anything, because the two ways to# compare it as a number are both wrong here (round 54, measured 2026-08-22). `[ … -eq … ]` calls# the `[` builtin, which an imported `BASH_FUNC_[%%` function outranks — the caller then answers# every predicate in this fence. `[[ … -eq … ]]` is keyword-immune but arithmetic, and arithmetic# reads an EMPTY operand as 0: `[[ "" -eq 0 ]]` is true, so an unset ancestry would publish# `no-rewrite` and skip the attestation. `case` is a keyword and does no arithmetic, so `*` catches# the empty string with everything else and lands it in `errored` — fail-closed by construction# rather than by a predicate somebody has to remember to write.case "$BUNDLED_ANCESTRY" in0) BUNDLED_ANCESTRY_READING=contained ;;1) BUNDLED_ANCESTRY_READING=not-contained ;;*) BUNDLED_ANCESTRY_READING=errored ;;esacif [[ "$LOOKUP_FAILED" != 1 ]] && [[ "$BUNDLED_ANCESTRY_READING" = contained ]] && [[ -n "$CUT" ]] && [[ -n "$DEST" ]] && [[ "$CUT" = "$DEST" ]]; thenBUNDLED_READING=no-rewriteelif [[ "$LOOKUP_FAILED" = 1 ]] || [[ "$BUNDLED_ANCESTRY_READING" = errored ]] || [[ -z "$CUT" ]] || [[ -z "$DEST" ]]; thenBUNDLED_READING=unknown # ask, fail closed — and say "unknown", never "measured rewrite"elseBUNDLED_READING=rewrite # ancestry said no, or the cut point is behind the destinationfi# Every value above dies with this fence: the table below is read by the agent, in a later step,# in a separate shell. Print them. A reading that is only assigned forces whoever needs it to# derive it a second time, and a second derivation is free to disagree with the one measured here.# Round 52 added the last three. `BUNDLED_READING` is derived from `BUNDLED_ANCESTRY`, and the# derivation collapses "ancestry errored" into the same word as "cut point is behind the# destination" — printing only the conclusion loses which of the two was measured.# **`NEW_HEAD` is the per-step arm's subject, and round 59 corrected this sentence.** It used to# read "what the force approval is about", which is true only where this fence runs after Step 3:# it is `git rev-parse HEAD` of whatever is checked out *now*. In bundled mode the fence runs# BEFORE Step 2, so `HEAD` is the ambient checkout — possibly the epic branch, possibly an# unrelated one — and the commit that will actually be pushed does not exist yet, because Step 3# has not minted it. Reading the bundled report's `NEW_HEAD` as the approved commit therefore names# the wrong object, which is why the bundled questions do not quote it and why the commit the push# rewrites is established by the Step 5 re-check instead. `PUSH_URLS_SAFE` is where it lands, and# an approval naming no destination is an approval of a push the operator cannot identify.# The binding key the push fence compares (round 54). Redaction is lossy on purpose, so the# redacted string cannot serve as an identity; this digest can, and it discloses nothing.# One digest per push URL, SHA-256, space separated — a SET, because git invokes the pre-push hook# ONCE PER PUSH URL with that single URL in `$2` (measured 2026-08-22). A digest of the whole list# matches no single call, so it refused every fan-out the operator had configured and approved.# SHA-256 rather than `git hash-object`: `rules/security.md` prohibits SHA-1 where a digest carries# a security decision, and that prohibition is what makes the change mandatory. `hash-object` also# follows the *repository's* object format — measured 2026-08-22, the same URL digests to# `b354136a…` by default and `7524f1f0…` under `--object-format=sha256`, and back to the SHA-1# value outside a repository. Round 59 corrects how much that carries: it does NOT by itself make# the two sides disagree, since the plan side and the hook run for the same repository and read# the same format. It is a reason not to build a cross-process binding on a tool whose algorithm# is chosen by ambient state, and it bites where one side runs outside the repository at all.# A URL that will not hash empties the WHOLE value rather than shortening the set: a partial set# approves fewer destinations than the plan showed, and looks like a successful derivation.# Round 60: SELECT the digest tool, THEN feed it. A `||` chain over a pipeline let the FIRST# command consume stdin and then fail, after which the fallback hashed EOF. Measured 2026-08-22:# `https://gw.example/push?repo=A&token=one` and `…?repo=B&token=two` BOTH digested to# e3b0c442…b855 — the SHA-256 of the empty string — so two different destinations compared EQUAL# and the destination guard passed on a destination that had changed. `command -v` does not read# stdin, so doing the selection with it feeds the input exactly once, to exactly one tool. Same# shape as `scripts/pre-push-gate.sh` § sha256_raw, deliberately: one algorithm, stated once.sha256_raw() { # reads stdin, writes the selected tool's own output line; nonzero only if none exists# Invoked through `/usr/bin/env`, never as a bare word. `command -v` reports an imported shell# function as a perfectly good command, and the known-answer test below only rejects a tool that# answers one CONSTANT. An ADAPTIVE function passes both vectors and then returns one fixed# digest for every real URL, so two different destinations compare EQUAL and the approval is# bound to nothing. `env` resolves PATH only, and bash refuses to import a function whose name# contains a slash, so a function-only match makes `env` fail and the test below correctly# empties the digest. `scripts/pre-push-gate.sh` needs no such spelling and is not inconsistent# with this: its `#!/usr/bin/env -S bash -p` shebang refuses to import functions at all, while# these fences have no shebang of their own. The defence differs because the channel does.if command -v sha256sum >/dev/null 2>&1; then /usr/bin/env sha256sumelif command -v shasum >/dev/null 2>&1; then /usr/bin/env shasum -a 256elif command -v openssl >/dev/null 2>&1; then /usr/bin/env openssl dgst -sha256else return 1fi}sha256_hex() { # the bare hex the tool produced — NO shape check, the KAT below needs the raw answer_H=$(/usr/bin/printf '%s' "$1" | sha256_raw 2>/dev/null) || _H=_H=${_H##*= } # openssl: `SHA2-256(stdin)= <hex>`_H=${_H%% *} # sha256sum / shasum: `<hex> -`/usr/bin/printf '%s' "$_H"}# Known-answer test, two vectors. A tool that answers one constant whatever it is fed makes every# destination compare equal to every approval — and a constant is well-shaped, so the shape check# in the loop cannot see it. The empty vector is precisely the answer the defect above produced.DIGEST_TOOL_OK=if [[ "$(sha256_hex '')" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ]] \&& [[ "$(sha256_hex abc)" = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad ]]; thenDIGEST_TOOL_OK=yesfiPUSH_URLS_DIGEST=while IFS= read -r U; do[[ -n "$U" ]] || continueD=if [[ -n "$DIGEST_TOOL_OK" ]]; then D=$(sha256_hex "$U"); ficase "$D" in *[!0-9a-f]*|'') D= ;; *) [[ ${#D} -eq 64 ]] || D= ;; esacif [[ -z "$D" ]]; then PUSH_URLS_DIGEST=; break; fiPUSH_URLS_DIGEST=${PUSH_URLS_DIGEST:+$PUSH_URLS_DIGEST }$Ddone <<< "$PUSH_URLS"# `remote.<name>.receivepack` names the program that receives the objects on the far side, and a# program is free to ignore the repository the URL named. Measured 2026-08-22: with one configured,# an ordinary branch push printed `To <the approved URL> * [new branch] main -> main` while every# object landed in a DIFFERENT repository and the named one stayed empty. No digest of the URL can# see that, so with one configured the destination is not established and this skill does not push.# The gate refuses it too where the binding reaches it; this line is what covers the projects that# never installed the gate, and `git-workflow.md` § Push safety is why the absent gate moves the# question here rather than deleting it. This read is best-effort and its boundary is measured:# git runs the pre-push hook only after the ref advertisement, so a wrapper that clears its own# config key before serving redirects the objects while every reader here sees nothing (measured# 2026-08-22 — the hook saw `<unset>`, git reported success against the named URL, and the objects# landed elsewhere). What closes that is the push line itself, which spells# `--receive-pack=git-receive-pack`: a command-line value overrides the configured one, while# `-c remote.<name>.receivepack=` does not (git keeps the config value and says "more than one# receivepack given, using the first"). This read still earns its place — it refuses BEFORE the# operator is asked to approve a destination that was never going to receive the objects.PUSH_RECEIVEPACK=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git config --get remote.origin.receivepack 2>/dev/null) || PUSH_RECEIVEPACK=PUSH_RECEIVEPACK_SET=noif [[ -n "$PUSH_RECEIVEPACK" ]]; then PUSH_RECEIVEPACK_SET=yes; fi# **The report is as security-sensitive as the readings above, and until round 59 it was the one# construct here that a caller could answer.** `printf` is a regular builtin, and in bash a# function outranks a builtin, so an exported `BASH_FUNC_printf%%` replaces this line wholesale:# measured 2026-08-22, a fence whose real variables were `ASK=1 ASK_REASON=rewrite` printed# `ASK=[] ASK_REASON=[fast-forward]` to the agent. Every `case` and `[[` above is immune and it# bought nothing, because the verdict left through a channel the caller owned. An ABSOLUTE path is# immune for the reason the Husky stanza already relies on — bash refuses to import a function# whose name contains a slash (`error importing function definition for '/usr/bin/printf'`,# measured). If `/usr/bin/printf` is missing the fence prints no report at all, and that is the# correct failure: **a run with no report line is `unknown`, and unknown asks.**/usr/bin/printf 'REMOTE_TIP=[%s]\nLOOKUP_FAILED=[%s]\nITER_READING=[%s]\nITER_ANCESTRY=[%s]\nBUNDLED_READING=[%s]\nCUT=[%s]\nDEST=[%s]\nBUNDLED_ANCESTRY=[%s]\nNEW_HEAD=[%s]\nPUSH_URLS_SAFE=[%s]\nPUSH_URLS_DIGEST=[%s]\nPUSH_RECEIVEPACK_SET=[%s]\n' \"$REMOTE_TIP" "$LOOKUP_FAILED" "$ITER_READING" "$ITER_ANCESTRY" "$BUNDLED_READING" "$CUT" "$DEST" \"$BUNDLED_ANCESTRY" "$NEW_HEAD" "$PUSH_URLS_SAFE" "$PUSH_URLS_DIGEST" "$PUSH_RECEIVEPACK_SET"
| Mode | Result | Reading | Ask? | |
|---|---|---|---|---|
--per-step | ITER_READING=unknown | The lookup did not answer, or merge-base errored rather than answering. Topology unknown, not benign | Yes — fail closed | |
--per-step | ITER_READING=creation | The lookup answered and the branch is not on the remote yet — there is no history to overwrite | No | |
--per-step | ITER_READING=up-to-date | REMOTE_TIP = NEW_HEAD; nothing to push | No | |
--per-step | ITER_READING=fast-forward | The remote's tip is contained in what replaces it — no rewrite | No | |
--per-step | ITER_READING=rewrite | --is-ancestor answered no | Yes | |
| Bundled | BUNDLED_READING=unknown | LOOKUP_FAILED=1 (the push destination never resolved, so every other predicate here describes only local refs), merge-base exited above 1 (it errored rather than answered), or $CUT or $DEST did not resolve. Topology unknown, not benign | Yes — fail closed | |
| Bundled | BUNDLED_READING=no-rewrite | Ancestry succeeded and $CUT = $DEST: the replay is parented where it already sits, so the rebase mints no new OIDs and nothing is rewritten | No | |
| Bundled | BUNDLED_READING=rewrite | Either ancestry answered no, or it succeeded with the cut point behind the destination — the head contains the epic tip but the replay re-parents commits. Both are treated as a rewrite | Yes |
Ask on every row whose `Ask?` column says Yes — that is the rule; the positions are not. This sentence used to read "ask on the first and last rows, do not ask on the middle two", which described a four-row table and survived the split above into a seven-row one, silently dropping two rows that DO ask: per-step ancestry exit 1, and bundled ancestry success with an unequal cut point. A positional instruction over an editable table is a defect waiting for the next row. The four no-ask readings are exactly: creation, up-to-date, fast-forward, and bundled no-rewrite.
Every row above is a partition, and the fence produces the key (round 53). Each names one value of ITER_READING or BUNDLED_READING, the fence assigns exactly one of each, and no run matches two rows in its own mode — which is the property the table lacked while the rows named overlapping conditions instead of a derived word. The bundled rows were the last to be converted, and they show why the shape matters rather than merely being tidier: with BUNDLED_ANCESTRY=0, an unresolved $CUT and a resolved $DEST, the old wording matched both "a ref did not resolve → unknown" and "ancestry succeeded, $CUT ≠ $DEST → rewrite" — one state, two rows, opposite descriptions of what was measured. The fence had always normalized that state to unknown; only the table disagreed with it. Reading the rows in order is therefore not required; the previous shape needed a precedence to be safe, and stated it only in the rollback table, where it governed a different one. Record that reading in the iteration's report instead, so a transcript with no unshared question is distinguishable from one where it was skipped. The prediction in bundled mode is the weaker of the two by construction — that is the cost of gating before Step 2. It fails toward asking only because both of its tests are required: until 2026-08-21 it ran the ancestry test alone, and that version failed away from asking, which is the opposite of what this paragraph claimed. A head already containing the epic tip whose cut point sat behind the destination read as "no rewrite", the unshared question was skipped, and the rebase then re-parented every replayed commit. Ancestry answers is the destination contained; it never answered will the replay keep its OIDs, and only the second question is the one being asked here.
Iteration 1 asks neither force question. It squash-merges the first PR directly — no rebase, no force-push (§ Phase 2, Iteration 1 (First PR)) — so its gate is the Proceed question alone. Asking there whether anybody else works on the head would collect an attestation for a history rewrite that will not happen, and a per-use approval transcript is worth only what its questions are true about. The counts above and the fields below are for iterations 2..N.
AskUserQuestion fields (bundled mode):
Bundled means one gate over Steps 2-9, and it never meant one question. The unshared question is separate and asked first, in this mode exactly as in --per-step: option A obliges the skill to refuse a force-push it has no unshared evidence for, and evidence is what a question about sharedness collects — the Proceed question below collects approval, which is a different thing. Folding the two together would also make --per-step the only mode that satisfies the obligation, leaving the default path as the one that does not.
| # | Field | Value | |
|---|---|---|---|
| 1 | question | "Is anybody else working on <head>? This iteration force-pushes it to <PUSH_URLS_SAFE><basis>." — <basis> is , rewriting its remote history on a measured rewrite, and , and its topology could not be verified — treated fail-closed on an unknown row. The attestation is owed either way; the factual claim is not. Telling an operator a rewrite was measured when the command that would have measured it failed asks them to authorize under a false premise, and an operator who learns the warning is often false is the one who reads past the true one | |
| 1 | options | Nobody else works on <head> → continue to gate 2; Someone else might → abort this PR's iteration, do not fall through | |
| 2 | question | "Proceed with PR #<N> ('<title>')? Will rebase onto epic, force-push <head> to <PUSH_URLS_SAFE> with --force-with-lease<rewrite-clause>, wait for CI, then squash-merge." — <rewrite-clause> is (a history rewrite) on the rows above that ask, and (measured: no rewrite — the replay is already parented on the epic tip) on the row that does not. A fixed clause is false for exactly the states the table just classified as harmless, and an operator taught that the warning is usually wrong reads past the one time it is not | |
| 2 | options | Proceed, Per-step approval, Dry-run only, Abort and rollback | |
| 2 | description per option | Show diff stats (+X -Y across F files), backup tag SHA, expected unique-commit count from manifest |
AskUserQuestion fields (per-step mode):
| Step | question (short) | |
|---|---|---|
| Before rebase | "Run: git rebase --onto origin/<epic> backup/pr-<prev> <head> ?" | |
| Before push (asked first) | "Is anybody else working on <head>, which this pushes to <PUSH_URLS_SAFE>?<basis>" — Force-with-lease rewrites its remote history. on a measured rewrite; Its topology could not be verified, so this is treated fail-closed. on an unknown row — "Nobody else works on <head>" continues to the question below; "Someone else might" aborts this PR's iteration rather than falling through to it | |
| Before push | "Manifest verified (N commits). Force-push <head> to <PUSH_URLS_SAFE> with --force-with-lease?<rewrite-clause>" — This rewrites remote history, replacing <REMOTE_TIP>. where the reading above says rewrite, with the full object ID the classifier fence printed; Measured: no rewrite — the remote tip is an ancestor of what replaces it. where it does not. Same reason as bundled gate 2: the sentence is a measurement, not decoration — and naming the tip is what makes the approval reusable as a comparison, since Step 5 re-measures and refuses when the answer differs (APPROVED_TIP). Without the object ID the answer is "yes, rewrite something", which carries to a rewrite of anything | |
| Before merge | "CI passed for #<N>. Squash-merge into <epic>?" |
Cut Point Reference
Each iteration uses the previous PR's backup tag as the rebase cut point:
| Iteration | PR | Branch | Cut Point (--base of rebase) | |
|---|---|---|---|---|
| 2 | #101 | feat/B | backup/pr-100 | |
| 3 | #102 | feat/C | backup/pr-101 | |
| ... | ... | ... | ... |
Phase 3: Verification
epic=<quoted epic>/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git log "refs/remotes/origin/$epic" --oneline -<N+5> --
Expect N squash commits (newest first), each with PR number suffix (#NNN):
<sha> feat: ... (#103)<sha> feat: ... (#102)<sha> feat: ... (#101)<sha> feat: ... (#100)<sha> <previous epic commits>
Final report:
| Item | Value | |
|---|---|---|
| Epic branch | epic/xxx | |
| PRs merged | 4 (#100, #101, #102, #103) | |
| Backup tags | backup/pr-100, backup/pr-101, ... (kept for safety) | |
| CI status | All PASS (verdicts via /watch-ci) | |
| Manifests | $(git rev-parse --git-path epic-merge)/{expected,actual}-pr-*.manifest (kept until --cleanup) | |
| Gate transcripts | All AskUserQuestion answers logged in conversation |
Safety Rules
| Rule | Rationale | |
|---|---|---|
One lease form on both pushes, never bare --force — `--force-with-lease=refs/heads/<head>:<the tip that fence just measured>`, $FINAL_TIP on the iteration push and $RB_TIP on the rollback push (round 60 and round 75 respectively) | Binds the credential to the object the classification was made about. The bare lease expects whatever refs/remotes/origin/<head> holds when git resolves it, so a background fetch (IDE, cron) landing a collaborator's commit there makes the lease compare against that fresh ref and the force-push succeeds over it with exit 0 — neither the backup tag nor the manifest contains that commit, so nothing downstream notices. --force-if-includes used to be the answer to that, and it is not one here: it asks whether the remote tip is reachable from any reflog entry of the local branch, not whether the pushed history still contains it, and both of this skill's paths put that tip into the reflog themselves — Step 2 by checking it out, rollback by git switch -C. Measured: a collaborator's commit checked out locally and then dropped by a rewrite is overwritten with exit 0. Measured end to end 2026-08-22 on git 2.55.0 (the equivalent /push-ci fence, same shapes): classifier reads C; divergent D is published; a background fetch moves the tracking ref to D; D is in the branch reflog because it was committed on this branch and reset away (committing writes the branch's own reflog — the one the flag reads; a plain checkout of a branch that does not move writes only HEAD's) — the bare lease plus --force-if-includes published over D with exit 0, and the same tree with the classified tip as the lease value was rejected (stale info). The two are not combined: on the same git a push carrying an explicit lease value and --force-if-includes succeeded (exit 0) exactly where the value alone refuses — git documents the flag as a no-op beside a lease value, and this is that no-op arriving silently. Requires git ≥ 2.30. What the lease still does not prove is that the outgoing history preserves the remote tip; it binds the destination, not the shape of what replaces it | |
| Protected head branches rejected — Phase 0, re-asserted at Step 5 and Rollback | A PR opened from main/master/develop/release/* would make the force-push rewrite a shared branch, which rules/git-workflow.md prohibits; "PR head" is not proof of "not protected" | |
Backup tags from origin/* | Remote is SSOT; local refs may be stale | |
| Tags keyed by PR number | Avoids namespace collisions across branch prefixes | |
| Manifest = subject + count (not SHA) | Survives SHA rewrite during rebase | |
| Mismatch → STOP + restore | Backup tags enable instant rollback. Round 60 made this executable: it was a # comment under a bare diff, so a mismatch printed its diff and the fence pushed anyway — and diff is a command word an imported BASH_FUNC_diff%% outranks, so a forged exit 0 was equally unread. It is now if ! /usr/bin/diff …; then — restore, then exit 1, with a second message if the restore itself fails. Steps 2 and 3 gained the same treatment: a failed checkout or an interrupted rebase used to reach the push | |
| CI must PASS before merge | Rebase can introduce conflicts; CI catches them | |
| Update PR base before CI | CI must run against the correct epic diff | |
| AskUserQuestion before destructive ops | Per @rules/git-workflow.md exception model | |
/watch-ci for CI delegation | Reuses tested timeout/verdict logic; avoids inline gh run watch divergence |
Rollback
If any step fails or user aborts:
The rollback push carries its own AskUserQuestion — it is never covered by an earlier one. Register #4 grants this skill git push --force-with-lease "after explicit per-use user approval", and the Step 5 question ("Manifest verified (N commits). Force-push <head> with --force-with-lease? This rewrites remote history.") authorized that push, with that content. A rollback rewrites the same remote ref to different content, and it can be reached before any push question was asked at all — a Step 3 failure enters here directly. So before the push below, ask:
Whether the rollback push rewrites anything is a measurement, not a given. A Step 3 failure is reached before Step 5 pushed anything, so the remote may still hold exactly what the backup tag names — restoring it then sends no ref and overwrites no history. Compare, do not assume:
# Step 0's bindings are gone — this is a separate shell (§ Names in commands), and both names# below reach a command line. Unset, `"refs/heads/${head}"` is `refs/heads/`, a lookup about no# branch whose empty answer this fence reads as a **deleted head**; and `"backup/pr-${N}^{commit}"`# is `backup/pr-^{commit}`, which resolves nothing, so `BACKUP` empties and every valid rollback# is classified `unknown`. Both verdicts are about an empty name rather than about the rollback# the operator is being asked to approve, and `unknown` routes even a no-op recovery through the# rewrite authorization path. Under an inherited `set -u` the fence aborts on `${head}` before# classifying anything at all. This is the iteration gate's binding, in the iteration gate's# shape, for the one classifier fence that was left without it.head=<quoted head>N=<quoted PR number>if [[ -z "$head" ]] || [[ -z "$N" ]]; thenecho "⛔ rollback gate: head or N is unbound in this fence. The remote lookup would name" >&2echo " refs/heads/ and the backup would be read as backup/pr-, so the classification below" >&2echo " would be about neither the branch nor the tag. Bind both from the chain table" >&2echo " (§ Names in commands) and re-run. Nothing is pushed." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — head or N unbound in the rollback-classifier fence}"fi# No pipe, no `awk` — for the reason spelled out at the iteration gate above: a pipeline reports# its LAST command's status, so `ls-remote | awk` exits 0 on a failed lookup (measured `rc=0`# against a nonexistent remote) and the fail-closed row below became unreachable; and a bare# `awk` deciding whether an attestation is collected is shadowable by an imported function.# `origin` is two destinations, not one: `git ls-remote origin` reads the FETCH url, while# pushing to that same name contacts the PUSH url — `remote.origin.pushurl`, or `url.<x>.pushInsteadOf`# with no explicit pushurl, moves them apart (both measured, git 2.55.0). Probing one and# rewriting the other classifies repository A and force-pushes repository B. `--push --all` is# the oracle that matched what push actually contacted in both cases; `--all` because pushurl is# multi-valued and the singular form reports a fan-out as a single destination.if PUSH_URLS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git remote get-url --push --all origin); thenPUSH_URL=${PUSH_URLS%%$'\n'*}elsePUSH_URLS=; PUSH_URL=fi# Redacted destination — the same derivation, and the same reason, as its first use above.PUSH_URLS_SAFE=while IFS= read -r U; docase "$U" in*://*)REST=${U#*://}; AUTH=${REST%%/*}; AUTH=${AUTH%%\?*}; AUTH=${AUTH%%\#*}case "$AUTH" in*@*) U="${U%%://*}://<redacted>@${AUTH##*@}${REST#"$AUTH"}" ;;esaccase "$U" in*\?*) U="${U%%\?*}?<redacted>" ;;*\#*) U="${U%%\#*}#<redacted>" ;;esac;;*:*)# scp-like `[user@]host:path`. No scheme, so the arm above cannot reach it — until# 2026-08-22 every scp-like user printed verbatim, on the reasoning that it is always `git`.# It is not: `<token>@host:path` is legal, and this value goes into an approval transcript.# The `*/*` guard is the two readings of `:` — git treats one as scp-like only when no `/`# precedes it, so a local path keeps its `@`. Same as `scripts/pre-push-gate.sh`; keep in step._pre=${U%%:*}case "$_pre" in*/*) ;;*@*) U="<redacted>@${_pre##*@}:${U#*:}" ;;esac;;esacPUSH_URLS_SAFE=${PUSH_URLS_SAFE:+$PUSH_URLS_SAFE$'\n'}$Udone <<SAFE_EOF$PUSH_URLSSAFE_EOF# Anything but exactly one URL is fail-closed. $(...) strips trailing newlines, so one URL leaves# none and "$PUSH_URLS" != "$PUSH_URL" is precisely "more than one" — expansion, no command to shadow.if [[ -z "$PUSH_URL" ]] || [[ "$PUSH_URLS" != "$PUSH_URL" ]]; thenREMOTE_TIP=; LOOKUP_FAILED=1# Round 76, the same detector the Step 5 and rollback fences carry. `$PUSH_URL` has already# been through one `url.*.insteadOf` pass, and handing it to another git command applies a# SECOND. Where a chain exists this lookup reads a repository the push never contacts, so its# tip is not about the destination and the ancestry test below would classify the wrong# history. `LOOKUP_FAILED=1` is the honest reading and it fails closed — the classification# becomes `unknown` and the unshared question gets asked rather than skipped. The detector is# purely local: `--get-url` expands the URL through the rewrite table and exits.elif ! REMOTE_REPROBE=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --get-url -- "$PUSH_URL") || [[ "$REMOTE_REPROBE" != "$PUSH_URL" ]]; thenecho "⛔ url.*.insteadOf rewrites the resolved push destination a SECOND time — this" >&2echo " lookup would read a repository the push never contacts. Topology unknown." >&2REMOTE_TIP=; LOOKUP_FAILED=1elif REMOTE_LS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --upload-pack=git-upload-pack -- "$PUSH_URL" "refs/heads/${head}"); thenREMOTE_TIP=${REMOTE_LS%%$'\t'*}; LOOKUP_FAILED=elseREMOTE_TIP=; LOOKUP_FAILED=1fi# `--verify … ^{commit}`, and the `|| BACKUP=` is not defensive dressing. A bare `git rev-parse# "backup/pr-3"` on a ref that does not exist prints the ARGUMENT on stdout and exits 128, so# `BACKUP` came to hold the string `backup/pr-3` — a ref name reported in a field the operator# reads as an object name, and passed to `merge-base` as if it were one. `--verify` prints nothing# on failure, and the fallback makes "did not resolve" an empty value the classifier below can see.BACKUP=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --verify "backup/pr-${N}^{commit}") || BACKUP=# Inequality is not a rewrite. Restoring an older remote tip to a descendant of itself is an# ordinary fast-forward, and `rules/git-workflow.md` § Push safety defines the class by ancestry,# not by difference. This is the test that separates them.## Round 53, and it is the iteration classifier's defect in the same shape: the ancestry test ran# unconditionally, so an empty `REMOTE_TIP` — from a failed lookup OR from a head the remote no# longer has — made `merge-base` exit 128, and the table's first and third rows then described the# same run in opposite terms. That table said "checked first" to settle it, which is a precedence# stated in prose next to rows that overlap, not a decision anything executes. The ORDER below is# the decision, `LOOKUP_FAILED` first for the same reason as above: a failed lookup also empties# `REMOTE_TIP`, and reading that as a deleted head would report a benign creation for a remote# nobody could reach.if [[ "$LOOKUP_FAILED" = 1 ]] || [[ -z "$BACKUP" ]]; thenROLLBACK_ANCESTRY=; ROLLBACK_READING=unknown # no answer, or no backup to restoreelif [[ -z "$REMOTE_TIP" ]]; thenROLLBACK_ANCESTRY=; ROLLBACK_READING=head-deleted # answered: the head is gone from the remoteelif [[ "$REMOTE_TIP" = "$BACKUP" ]]; thenROLLBACK_ANCESTRY=; ROLLBACK_READING=no-op # the remote already holds the backupelseif /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git merge-base --is-ancestor "$REMOTE_TIP" "$BACKUP"; then ROLLBACK_ANCESTRY=0; else ROLLBACK_ANCESTRY=$?; fi# same three readings; >1 is "did not answer", never "answered no"case "$ROLLBACK_ANCESTRY" in0) ROLLBACK_READING=fast-forward ;;1) ROLLBACK_READING=rewrite ;;*) ROLLBACK_READING=unknown ;;esacfi# Printed for the same reason as the iteration classifier above: the table below picks its row# from these values, and it is read in a later step, in a shell that never saw them.# The binding key the push fence compares (round 54). Redaction is lossy on purpose, so the# redacted string cannot serve as an identity; this digest can, and it discloses nothing.# One digest per push URL, SHA-256, space separated — a SET, because git invokes the pre-push hook# ONCE PER PUSH URL with that single URL in `$2` (measured 2026-08-22). A digest of the whole list# matches no single call, so it refused every fan-out the operator had configured and approved.# SHA-256 rather than `git hash-object`: `rules/security.md` prohibits SHA-1 where a digest carries# a security decision, and that prohibition is what makes the change mandatory. `hash-object` also# follows the *repository's* object format — measured 2026-08-22, the same URL digests to# `b354136a…` by default and `7524f1f0…` under `--object-format=sha256`, and back to the SHA-1# value outside a repository. Round 59 corrects how much that carries: it does NOT by itself make# the two sides disagree, since the plan side and the hook run for the same repository and read# the same format. It is a reason not to build a cross-process binding on a tool whose algorithm# is chosen by ambient state, and it bites where one side runs outside the repository at all.# A URL that will not hash empties the WHOLE value rather than shortening the set: a partial set# approves fewer destinations than the plan showed, and looks like a successful derivation.# Round 60: SELECT the digest tool, THEN feed it. A `||` chain over a pipeline let the FIRST# command consume stdin and then fail, after which the fallback hashed EOF. Measured 2026-08-22:# `https://gw.example/push?repo=A&token=one` and `…?repo=B&token=two` BOTH digested to# e3b0c442…b855 — the SHA-256 of the empty string — so two different destinations compared EQUAL# and the destination guard passed on a destination that had changed. `command -v` does not read# stdin, so doing the selection with it feeds the input exactly once, to exactly one tool. Same# shape as `scripts/pre-push-gate.sh` § sha256_raw, deliberately: one algorithm, stated once.sha256_raw() { # reads stdin, writes the selected tool's own output line; nonzero only if none exists# Invoked through `/usr/bin/env`, never as a bare word. `command -v` reports an imported shell# function as a perfectly good command, and the known-answer test below only rejects a tool that# answers one CONSTANT. An ADAPTIVE function passes both vectors and then returns one fixed# digest for every real URL, so two different destinations compare EQUAL and the approval is# bound to nothing. `env` resolves PATH only, and bash refuses to import a function whose name# contains a slash, so a function-only match makes `env` fail and the test below correctly# empties the digest. `scripts/pre-push-gate.sh` needs no such spelling and is not inconsistent# with this: its `#!/usr/bin/env -S bash -p` shebang refuses to import functions at all, while# these fences have no shebang of their own. The defence differs because the channel does.if command -v sha256sum >/dev/null 2>&1; then /usr/bin/env sha256sumelif command -v shasum >/dev/null 2>&1; then /usr/bin/env shasum -a 256elif command -v openssl >/dev/null 2>&1; then /usr/bin/env openssl dgst -sha256else return 1fi}sha256_hex() { # the bare hex the tool produced — NO shape check, the KAT below needs the raw answer_H=$(/usr/bin/printf '%s' "$1" | sha256_raw 2>/dev/null) || _H=_H=${_H##*= } # openssl: `SHA2-256(stdin)= <hex>`_H=${_H%% *} # sha256sum / shasum: `<hex> -`/usr/bin/printf '%s' "$_H"}# Known-answer test, two vectors. A tool that answers one constant whatever it is fed makes every# destination compare equal to every approval — and a constant is well-shaped, so the shape check# in the loop cannot see it. The empty vector is precisely the answer the defect above produced.DIGEST_TOOL_OK=if [[ "$(sha256_hex '')" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ]] \&& [[ "$(sha256_hex abc)" = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad ]]; thenDIGEST_TOOL_OK=yesfiPUSH_URLS_DIGEST=while IFS= read -r U; do[[ -n "$U" ]] || continueD=if [[ -n "$DIGEST_TOOL_OK" ]]; then D=$(sha256_hex "$U"); ficase "$D" in *[!0-9a-f]*|'') D= ;; *) [[ ${#D} -eq 64 ]] || D= ;; esacif [[ -z "$D" ]]; then PUSH_URLS_DIGEST=; break; fiPUSH_URLS_DIGEST=${PUSH_URLS_DIGEST:+$PUSH_URLS_DIGEST }$Ddone <<< "$PUSH_URLS"# `remote.<name>.receivepack` names the program that receives the objects on the far side, and a# program is free to ignore the repository the URL named. Measured 2026-08-22: with one configured,# an ordinary branch push printed `To <the approved URL> * [new branch] main -> main` while every# object landed in a DIFFERENT repository and the named one stayed empty. No digest of the URL can# see that, so with one configured the destination is not established and this skill does not push.# The gate refuses it too where the binding reaches it; this line is what covers the projects that# never installed the gate, and `git-workflow.md` § Push safety is why the absent gate moves the# question here rather than deleting it. This read is best-effort and its boundary is measured:# git runs the pre-push hook only after the ref advertisement, so a wrapper that clears its own# config key before serving redirects the objects while every reader here sees nothing (measured# 2026-08-22 — the hook saw `<unset>`, git reported success against the named URL, and the objects# landed elsewhere). What closes that is the push line itself, which spells# `--receive-pack=git-receive-pack`: a command-line value overrides the configured one, while# `-c remote.<name>.receivepack=` does not (git keeps the config value and says "more than one# receivepack given, using the first"). This read still earns its place — it refuses BEFORE the# operator is asked to approve a destination that was never going to receive the objects.PUSH_RECEIVEPACK=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git config --get remote.origin.receivepack 2>/dev/null) || PUSH_RECEIVEPACK=PUSH_RECEIVEPACK_SET=noif [[ -n "$PUSH_RECEIVEPACK" ]]; then PUSH_RECEIVEPACK_SET=yes; fi# **The report is as security-sensitive as the readings above, and until round 59 it was the one# construct here that a caller could answer.** `printf` is a regular builtin, and in bash a# function outranks a builtin, so an exported `BASH_FUNC_printf%%` replaces this line wholesale:# measured 2026-08-22, a fence whose real variables were `ASK=1 ASK_REASON=rewrite` printed# `ASK=[] ASK_REASON=[fast-forward]` to the agent. Every `case` and `[[` above is immune and it# bought nothing, because the verdict left through a channel the caller owned. An ABSOLUTE path is# immune for the reason the Husky stanza already relies on — bash refuses to import a function# whose name contains a slash (`error importing function definition for '/usr/bin/printf'`,# measured). If `/usr/bin/printf` is missing the fence prints no report at all, and that is the# correct failure: **a run with no report line is `unknown`, and unknown asks.**/usr/bin/printf 'PUSH_URLS_SAFE=[%s]\nPUSH_URLS_DIGEST=[%s]\nPUSH_RECEIVEPACK_SET=[%s]\nREMOTE_TIP=[%s]\nLOOKUP_FAILED=[%s]\nBACKUP=[%s]\nROLLBACK_READING=[%s]\nROLLBACK_ANCESTRY=[%s]\n' \"$PUSH_URLS_SAFE" "$PUSH_URLS_DIGEST" "$PUSH_RECEIVEPACK_SET" "$REMOTE_TIP" "$LOOKUP_FAILED" "$BACKUP" "$ROLLBACK_READING" "$ROLLBACK_ANCESTRY"
| Result | Reading | Ask the unshared question? | |
|---|---|---|---|
ROLLBACK_READING=unknown | The lookup did not answer, backup/pr-<N> did not resolve, or merge-base errored rather than answering — a corrupt or incomplete graph, an unreadable object. Exit 1 is the answer "no"; anything above it is the absence of an answer, and the iteration table and /push-ci both separate the two | Yes — fail closed, as unknown, not as a measured rewrite | |
ROLLBACK_READING=no-op | REMOTE_TIP = BACKUP: the remote already holds the backup. Nothing is rewritten | No — and say so in the report rather than pushing | |
ROLLBACK_READING=head-deleted | The lookup answered and the head no longer exists on the remote. Restoring it would be a creation, not a rewrite | No — and do not push: report and hand back, exactly as the row above. The push below leases against the tracking ref, which still holds the pre-deletion OID, so git refuses it with stale info (measured 2026-08-21); the form that would work is not on the Anchor grant, and somebody else deleted this branch | |
ROLLBACK_READING=fast-forward | The backup is a descendant of what the remote holds — an ordinary fast-forward. Different OIDs, no rewrite: inequality alone never was the test | No | |
ROLLBACK_READING=rewrite | The backup does not reach what the remote holds — the restore rewrites it | Yes |
These five are a partition, produced by the fence, exactly as the iteration table's are (round 53). The first row used to say "checked first", which is what a table needs when its rows can both match — and they could: a failed lookup and a deleted head both left REMOTE_TIP empty and both made merge-base exit 128. Precedence now lives in the if chain that assigns the word, so no reader has to apply it and no row can be reached by two different runs.
Where it is owed, the unshared question comes first here too, and the reason is sharper on this path than on the others. Not because no gate has run — one has: bundled mode gates before Step 2 and --per-step before Step 3, so a Step 3 failure is always downstream of at least one. It is that none of them asked about this push. The Step 5 question authorized a rebased head; the bundled Proceed covered Steps 2-9 of the iteration; an unshared attestation given for either was given about a different rewrite of the ref, to different content. An attestation from an earlier iteration carries even less. So a gate having run is not evidence for this push, and where the table above says ask, there is no earlier answer to lean on.
| # | Field | Value | |
|---|---|---|---|
| 1 | question | "Is anybody else working on <head>? The rollback force-pushes it to <PUSH_URLS_SAFE> over <REMOTE_TIP><basis>." — <basis> is , rewriting its remote history only when ROLLBACK_READING=rewrite; on unknown it is , and its topology could not be verified — treated fail-closed, and <REMOTE_TIP> is an unknown tip when the lookup itself failed rather than the empty string, which reads as a ref named nothing | |
| 1 | options | Nobody else works on <head> → continue to gate 2; Someone else might → stop; leave the remote as it is | |
| 2 | question | "Rollback: force-push <head> to <PUSH_URLS_SAFE> back to backup/pr-<N> with --force-with-lease?<effect>" — <effect> states what the classifier above measured, never one fixed sentence: This rewrites remote history, replacing <REMOTE_TIP>. — with the full object ID the classifier fence printed — only when ROLLBACK_READING=rewrite; Its topology could not be verified — treated fail-closed as a rewrite. on unknown; The remote's tip is contained in the backup, so this is a fast-forward, not a rewrite. on fast-forward. The unconditional sentence this replaced was false on exactly the rows the classifier exists to identify, and a warning the operator learns is sometimes false is the one the attestation contract cannot afford. The object ID belongs in this question and not only in row 1: row 1 is the attestation, and the rollback fence compares APPROVED_TIP against what the approval covered — an approval that says only "this rewrites remote history" authorizes rewriting whatever happens to be there when the push runs | |
| 2 | options | Restore from backup, Stop and leave the remote as it is | |
| 2 | description | Name the failed step, the backup tag SHA, and the SHA the remote currently holds |
If the operator declines, stop and report the state — but report it, do not assert it. Declining stops before the block below, and that block is where the local restore happens (git switch -C from the backup tag), so at this point neither side has been restored. An earlier version of this line read "the local branch is restored, the remote is not", which describes a state the decline path never reaches and would send the developer looking for a repair that was never made.
Report three measured values, each re-derived through the same normalized prefix the block below uses, and name them as what they are:
| Value | Command | Why it is the one that matters | |
|---|---|---|---|
| Local head | git rev-parse HEAD | Where the working branch actually sits — mid-rebase, restored, or untouched | |
| Backup tag | git rev-parse backup/pr-<N> | The commit a later restore would return to. A missing tag is itself the finding | |
| Remote head | git ls-remote --upload-pack=git-upload-pack -- "$PUSH_URL" refs/heads/<head> | What everyone else sees at the repository this push will reach. Read from the remote, never from origin/<head>, which is only as fresh as the last fetch — and never from the name origin, which resolves to the fetch URL here and the push URL at the push |
Do not push, and do not restore. The developer decides with those three in front of them.
Steps 1 and 2 of the block below run on every row that reaches rollback; step 3 does not. The clean-tree check and the local git switch -C restore touch nothing anybody else holds, so they are owed even on the two rows that push nothing — REMOTE_TIP = BACKUP and a head the remote no longer has. Those rows are exactly the ones where rollback was invoked because the local branch is mid-rebase, and reading the block's prerequisite as covering all three steps would leave them with no defined recovery at all.
# PUSH_BLOCKED is this fence's own refusal record, and it exists because `exit` cannot be trusted# to end the fence. `exit` is a builtin, so an imported `BASH_FUNC_exit%%` function outranks it —# measured on bash 3.2.57: a refusal printed in full and the force-push then ran, exit status 0. No# keyword terminates a shell (`return` is a builtin too), so the fix is not a better terminator: a# refusal RECORDS itself in an assignment, and the push below is reached only through `[[ ]]`,# which the parser resolves before any name is looked up.# The record is FROZEN, not merely written — this paragraph used to say an assignment is something# "nothing outranks", which confuses the command with the value. The command cannot be outranked;# the value it wrote can be erased by whatever runs next, and under this vector that is the hostile# function itself: `BASH_FUNC_exit%%='() { PUSH_BLOCKED=; return 0; }'` cleared the flag and the# push ran at status 0 (measured 2026-08-22, bash 3.2.57 and 5.3.15). `readonly` at every pre-push# refusal site below closes it — the erasing assignment, `unset` and `declare -g` each fail against# a readonly name and the refusal held on both shells. The post-push sites that only accumulate a# status stay plain assignments: no `exit` runs between them and the guard, so the vector needs a# terminator it never gets. What none of this closes is injection — an environment that can define# `exit` can define `git`, measured the same day intercepting a whole push. The record defends the# case where the terminator alone was trusted; it was never a fence against imported functions.# `exit 1` stays —# in an ordinary shell it is still right, and it is no longer the only thing standing between a# refusal and a force-push. Cleared here rather than defaulted, so an exported value of the same# name cannot pre-approve anything either.## The guard sits on its own physical line, ending in `&& \`, so the push line's own bytes stay# out of it: everything after the `push` subcommand on that line is read as this push's argv,# by the byte pin and by the forbidden-flag scan alike, and a guard written INTO the line would# put words there that git never sees.# This paragraph used to say the two force-pushes are byte-identical by design. They are not,# and have not been since round 60 gave Step 5 an explicit `--force-with-lease=<ref>:<expect>`# and dropped `--force-if-includes` from it — measured on git 2.55.0, the flag is a silent no-op# once the lease carries a value. Round 75 put the rollback push on the same shape, so what# separates them now is one variable name: `$FINAL_TIP` here, `$RB_TIP` there. What the two share# is the refspec — an object ID on the left, under the same name, so neither publishes something# later than what it classified — and now the lease as well, each bound to the tip its own fence# measured. Both are# pinned; the pins are what make the difference visible in a diff rather than something a reader# has to notice.PUSH_BLOCKED=# 1. Working tree must be clean — enforced, not observed. A trailing comment refuses# nothing: the command exits 0 on a dirty tree, so the restore below runs anyway and# `git switch -C` overwrites the branch with uncommitted work still in the tree. This# is the recovery path, which is exactly where losing changes is least recoverable.# Status and output are captured SEPARATELY, because `$( )` reports neither on its own: a# `git status` that fails prints nothing, the substitution yields the empty string, and# `[[ -n "" ]]` reads it as **clean**. Measured 2026-08-22 outside a repository — exit 128, zero# bytes, and both bash 3.2 and zsh 5.9 fell through to the restore. That is the worst place in the# document for a fail-open: `git switch -C` below overwrites the branch, and this is the recovery# path, where losing uncommitted work is least recoverable. Three states, not two: could not look,# looked and dirty, looked and clean — and only the third may continue.WT_STATUS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git status --porcelain) || {echo "⛔ the working tree could not be read (git status failed) — that is not the same as clean," >&2echo " and the restore below overwrites the branch. Nothing is restored and nothing is pushed." >&2readonly PUSH_BLOCKED=1SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the working tree could not be read}"}if [[ -n "$WT_STATUS" ]]; thenecho "⛔ working tree is not clean — commit or stash before restoring from backup" >&2readonly PUSH_BLOCKED=1SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the working tree is not clean}"fi# 2. Restore branch from backup tag — the rollback force-pushes <head> too, so the# same protected-branch guard applies; a protected head means manual recoveryhead=<quoted head> # bind once, as in Step 0 — § Names in commandscase "$head" inmain|master|develop|release/*)echo "⛔ PR head '$head' is a protected branch — rollback push refused, recover manually" >&2readonly PUSH_BLOCKED=1; exit 1;;esacif ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git switch -C "$head" "refs/tags/backup/pr-<N>"; thenecho "⛔ the local restore from refs/tags/backup/pr-<N> did not happen — refs/heads/${head}" >&2echo " still holds whatever the rollback was called to replace. Pushing it now would" >&2echo " rewrite the remote TO the broken state, under an approval whose question named the" >&2echo " backup tag. Nothing is pushed. STOP; recover manually." >&2readonly PUSH_BLOCKED=1; exit 1fi# The object the restore produced, read once and pushed by ID — same reason as Step 5, and the# same name so the two pushes keep saying the same thing. The check above proves the branch now# holds the backup; without this, what the push publishes is whatever `refs/heads/${head}` holds# when git resolves it, which is a different question asked later.# Measured 2026-08-22 (git 2.55.0) when this fence still carried `--force-if-includes`, because at# the time the rollback push was believed to be the one path where that flag was live: with the# lease deliberately satisfied, a SHA-source push carrying both flags was rejected `(remote ref# updated since checkout)` exactly as the branch-source form was, exit 1 both times; the same# SHA-source push with the lease alone succeeded. The measurement stands — the flag does survive# an object-ID source — but its premise did not: `switch -C` puts the branch's previous OID into# the reflog the flag certifies against, so on *this* path it certified something the path itself# had arranged. Round 75 replaced the pair here with a lease bound to the tip part (b) measures.PUSHED=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --verify --quiet "refs/heads/${head}") || PUSHED=# 3. Push the restored head. Reached on the three rows the classifier sends here: a measured# rewrite, a topology it could not verify (fail-closed), and an ordinary fast-forward. Gate 2# ("Restore from backup") is required on all three; gate 1 — the unshared attestation — only on# the two the table marks **Yes**, because asking whether anybody else holds a branch this push# is about to rewrite is not a question about a fast-forward. Either gate answered otherwise# means this line does not run. "BOTH questions, always" is what this replaced, and it was# wrong in both directions at once: it demanded an attestation the fast-forward row is not# owed, and it read as the prerequisite of the local restore above, which no row is exempt from.# Same lease declaration as Step 5, and for the same reason — restoring a backup rewinds# the remote ref, which is non-fast-forward too. ALLOW_PUSH_PROTECTED stays cleared.# …and never to a different repository than the approval named. Same divergence as the probe# above, one step later: re-resolve the push destination here and compare it against the redacted# destination the approval **named in its own question text** — every bundled, per-step and# rollback gate carries `<PUSH_URLS_SAFE>`, because a fence comparing against a value the# operator was never shown detects a later config change while authorizing nothing. A config# change between the question and the push would otherwise redirect an approved history rewrite# to another repository, silently.PUSH_URLS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git remote get-url --push --all origin) || PUSH_URLS=# Redacted destination — the same derivation, and the same reason, as its first use above.PUSH_URLS_SAFE=while IFS= read -r U; docase "$U" in*://*)REST=${U#*://}; AUTH=${REST%%/*}; AUTH=${AUTH%%\?*}; AUTH=${AUTH%%\#*}case "$AUTH" in*@*) U="${U%%://*}://<redacted>@${AUTH##*@}${REST#"$AUTH"}" ;;esaccase "$U" in*\?*) U="${U%%\?*}?<redacted>" ;;*\#*) U="${U%%\#*}#<redacted>" ;;esac;;*:*)# scp-like `[user@]host:path`. No scheme, so the arm above cannot reach it — until# 2026-08-22 every scp-like user printed verbatim, on the reasoning that it is always `git`.# It is not: `<token>@host:path` is legal, and this value goes into an approval transcript.# The `*/*` guard is the two readings of `:` — git treats one as scp-like only when no `/`# precedes it, so a local path keeps its `@`. Same as `scripts/pre-push-gate.sh`; keep in step._pre=${U%%:*}case "$_pre" in*/*) ;;*@*) U="<redacted>@${_pre##*@}:${U#*:}" ;;esac;;esacPUSH_URLS_SAFE=${PUSH_URLS_SAFE:+$PUSH_URLS_SAFE$'\n'}$Udone <<SAFE_EOF$PUSH_URLSSAFE_EOF# Round 54: identity is the DIGEST, not the redaction. Two destinations differing only in the# query redact to one string (measured), so comparing the redaction alone binds this approval to a# host and a path — and a `.git/config` edit between the question and the push then redirects an# approved history rewrite to another repository with this guard still passing. The digest is# one-way and carries no credential; `git hash-object` needs no repository. An EMPTY digest# refuses rather than matching an empty expectation.# One digest per push URL, SHA-256, space separated — a SET, because git invokes the pre-push hook# ONCE PER PUSH URL with that single URL in `$2` (measured 2026-08-22). A digest of the whole list# matches no single call, so it refused every fan-out the operator had configured and approved.# SHA-256 rather than `git hash-object`: `rules/security.md` prohibits SHA-1 where a digest carries# a security decision, and that prohibition is what makes the change mandatory. `hash-object` also# follows the *repository's* object format — measured 2026-08-22, the same URL digests to# `b354136a…` by default and `7524f1f0…` under `--object-format=sha256`, and back to the SHA-1# value outside a repository. Round 59 corrects how much that carries: it does NOT by itself make# the two sides disagree, since the plan side and the hook run for the same repository and read# the same format. It is a reason not to build a cross-process binding on a tool whose algorithm# is chosen by ambient state, and it bites where one side runs outside the repository at all.# A URL that will not hash empties the WHOLE value rather than shortening the set: a partial set# approves fewer destinations than the plan showed, and looks like a successful derivation.# Round 60: SELECT the digest tool, THEN feed it. A `||` chain over a pipeline let the FIRST# command consume stdin and then fail, after which the fallback hashed EOF. Measured 2026-08-22:# `https://gw.example/push?repo=A&token=one` and `…?repo=B&token=two` BOTH digested to# e3b0c442…b855 — the SHA-256 of the empty string — so two different destinations compared EQUAL# and the destination guard passed on a destination that had changed. `command -v` does not read# stdin, so doing the selection with it feeds the input exactly once, to exactly one tool. Same# shape as `scripts/pre-push-gate.sh` § sha256_raw, deliberately: one algorithm, stated once.sha256_raw() { # reads stdin, writes the selected tool's own output line; nonzero only if none exists# Invoked through `/usr/bin/env`, never as a bare word. `command -v` reports an imported shell# function as a perfectly good command, and the known-answer test below only rejects a tool that# answers one CONSTANT. An ADAPTIVE function passes both vectors and then returns one fixed# digest for every real URL, so two different destinations compare EQUAL and the approval is# bound to nothing. `env` resolves PATH only, and bash refuses to import a function whose name# contains a slash, so a function-only match makes `env` fail and the test below correctly# empties the digest. `scripts/pre-push-gate.sh` needs no such spelling and is not inconsistent# with this: its `#!/usr/bin/env -S bash -p` shebang refuses to import functions at all, while# these fences have no shebang of their own. The defence differs because the channel does.if command -v sha256sum >/dev/null 2>&1; then /usr/bin/env sha256sumelif command -v shasum >/dev/null 2>&1; then /usr/bin/env shasum -a 256elif command -v openssl >/dev/null 2>&1; then /usr/bin/env openssl dgst -sha256else return 1fi}sha256_hex() { # the bare hex the tool produced — NO shape check, the KAT below needs the raw answer_H=$(/usr/bin/printf '%s' "$1" | sha256_raw 2>/dev/null) || _H=_H=${_H##*= } # openssl: `SHA2-256(stdin)= <hex>`_H=${_H%% *} # sha256sum / shasum: `<hex> -`/usr/bin/printf '%s' "$_H"}# Known-answer test, two vectors. A tool that answers one constant whatever it is fed makes every# destination compare equal to every approval — and a constant is well-shaped, so the shape check# in the loop cannot see it. The empty vector is precisely the answer the defect above produced.DIGEST_TOOL_OK=if [[ "$(sha256_hex '')" = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ]] \&& [[ "$(sha256_hex abc)" = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad ]]; thenDIGEST_TOOL_OK=yesfiPUSH_URLS_DIGEST=while IFS= read -r U; do[[ -n "$U" ]] || continueD=if [[ -n "$DIGEST_TOOL_OK" ]]; then D=$(sha256_hex "$U"); ficase "$D" in *[!0-9a-f]*|'') D= ;; *) [[ ${#D} -eq 64 ]] || D= ;; esacif [[ -z "$D" ]]; then PUSH_URLS_DIGEST=; break; fiPUSH_URLS_DIGEST=${PUSH_URLS_DIGEST:+$PUSH_URLS_DIGEST }$Ddone <<< "$PUSH_URLS"# `remote.<name>.receivepack` names the program that receives the objects on the far side, and a# program is free to ignore the repository the URL named. Measured 2026-08-22: with one configured,# an ordinary branch push printed `To <the approved URL> * [new branch] main -> main` while every# object landed in a DIFFERENT repository and the named one stayed empty. No digest of the URL can# see that, so with one configured the destination is not established and this skill does not push.# The gate refuses it too where the binding reaches it; this line is what covers the projects that# never installed the gate, and `git-workflow.md` § Push safety is why the absent gate moves the# question here rather than deleting it. This read is best-effort and its boundary is measured:# git runs the pre-push hook only after the ref advertisement, so a wrapper that clears its own# config key before serving redirects the objects while every reader here sees nothing (measured# 2026-08-22 — the hook saw `<unset>`, git reported success against the named URL, and the objects# landed elsewhere). What closes that is the push line itself, which spells# `--receive-pack=git-receive-pack`: a command-line value overrides the configured one, while# `-c remote.<name>.receivepack=` does not (git keeps the config value and says "more than one# receivepack given, using the first"). This read still earns its place — it refuses BEFORE the# operator is asked to approve a destination that was never going to receive the objects.PUSH_RECEIVEPACK=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git config --get remote.origin.receivepack 2>/dev/null) || PUSH_RECEIVEPACK=if [[ -n "$PUSH_RECEIVEPACK" ]] || [[ -z "$PUSH_URLS" ]] || [[ -z "$PUSH_URLS_DIGEST" ]] \|| [[ "$PUSH_URLS_DIGEST" != "<the PUSH_URLS_DIGEST value the classifier fence printed for this iteration, written literally and quoted>" ]] \|| [[ "$PUSH_URLS_SAFE" != "<the redacted destination this iteration's approval named — the PUSH_URLS_SAFE value the question showed>" ]]; thenecho "⛔ push destination '${PUSH_URLS_SAFE:-unresolvable}' is not the one approved — refusing" >&2echo " (identity is a digest of the raw destination, so a change the redaction hides still refuses)" >&2if [[ -n "$PUSH_RECEIVEPACK" ]]; thenecho " (remote.origin.receivepack is configured, so the URL does not decide where the objects land; read it with: git config --get remote.origin.receivepack)" >&2fireadonly PUSH_BLOCKED=1; exit 1fi# ── Round 74. Two things the rollback push was missing that the Step 5 push has had since round# 60, both of which decided nothing here because neither value existed in this fence.## (a) The classifier's own verdict. The table above sends `no-op` and `head-deleted` through the# LOCAL restore and says, in both rows, do not push. Nothing in this fence read that: the only# push predicates were an empty `PUSH_BLOCKED` and a non-empty `PUSHED`, and both are true on# those rows. So a `head-deleted` reading — somebody else deleted this branch — reached a# force-push that can recreate it, and `no-op` reached one that publishes what the remote already# holds. Bound the way `PUSH_URLS_DIGEST` is bound just above, because a fence is a separate# shell and a value the classifier printed is not in it (§ 4.52, § 4.54: the harness supplying# what the fence should bind is how the same class survived a whole round of tests).ROLLBACK_READING=<the ROLLBACK_READING value the classifier fence printed for this iteration, written literally and quoted>UNSHARED_ATTESTED=<empty unless the operator answered YES to the unshared question for this rollback; then the literal string refs/heads/ followed by the head branch name, written literally and quoted># The remote tip the pre-restore classifier PRINTED as `REMOTE_TIP=[...]` — bound here for the# same reason as the two fields above, and it earns its place more plainly in this fence than in# Step 5's: `rewrite` is the ORDINARY reading for a rollback, because the backup by construction# does not contain the head this skill just pushed. So the reading alone cannot tell "undoing our# own push" from "somebody else published on top of it", and the tip is what separates them.APPROVED_TIP=<the REMOTE_TIP value the pre-restore classifier fence printed for this iteration, written literally and quoted># `case` over the WORD with a `*` catch-all, never a negated list: a reading this fence has not# heard of must land in the refusing arm by construction — including the empty string, which is# what an unsubstituted field leaves behind.case "$ROLLBACK_READING" inrewrite|unknown|fast-forward) ;;no-op)echo "⛔ the classifier read 'no-op': the remote already holds backup/pr-<N>. The local" >&2echo " restore is done; there is nothing to publish. Report and hand back." >&2readonly PUSH_BLOCKED=1; exit 1 ;;head-deleted)echo "⛔ the classifier read 'head-deleted': refs/heads/${head} no longer exists on the" >&2echo " remote — somebody else deleted it. The local restore is done. Recreating the branch" >&2echo " is not what this approval asked for; report and hand back." >&2readonly PUSH_BLOCKED=1; exit 1 ;;*)echo "⛔ ROLLBACK_READING is '${ROLLBACK_READING:-unbound}' — this fence did not receive the" >&2echo " classifier's verdict, so it cannot tell a rewrite from a deletion. Nothing is pushed." >&2readonly PUSH_BLOCKED=1; exit 1 ;;esac# (b) A topology measurement taken AFTER the restore, in this fence. The classifier ran in an# earlier fence, before the approval and before `git switch -C` produced the object being pushed;# Step 5 re-measures for exactly that reason and rollback did not. The gap is not theoretical:# `switch -C` puts the branch's previous OID into its reflog, which is what `--force-if-includes`# certifies against, and the bare lease reads the tracking ref rather than the remote — so a# remote that changed after the classifier ran can be overwritten while both flags pass and the# only classification the operator saw said `fast-forward`. Measured against `$PUSHED`, the object# on the left of the refspec, never against `refs/heads/${head}`, which git resolves again later.if [[ -z "$PUSH_URLS" ]] || [[ "$PUSH_URLS" == *$'\n'* ]]; thenRB_TIP=; RB_LOOKUP_FAILED=1# Round 76: the same second-rewrite detector Step 5 and `/push-ci` carry, for the same measurement# — `$PUSH_URLS` has already been through one `url.*.insteadOf` pass, and handing it to another git# command applies another. Where a chain exists the probe reads a repository this push never# contacts, so both the reading and the lease value would be about the wrong remote. `unknown` is# the honest classification and the arm below refuses it whatever was attested.elif ! RB_REPROBE=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --get-url -- "$PUSH_URLS") || [[ "$RB_REPROBE" != "$PUSH_URLS" ]]; thenecho "⛔ url.*.insteadOf rewrites the resolved push destination a SECOND time — the push goes to" >&2echo " the once-rewritten URL while a probe of that URL reads the twice-rewritten one, so this" >&2echo " fence cannot measure what the rollback would overwrite. STOP." >&2RB_TIP=; RB_LOOKUP_FAILED=1elif RB_LS=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git ls-remote --upload-pack=git-upload-pack -- "$PUSH_URLS" "refs/heads/${head}"); thenRB_TIP=${RB_LS%%$'\t'*}; RB_LOOKUP_FAILED=elseRB_TIP=; RB_LOOKUP_FAILED=1fi# Fail-closed rows FIRST — a failed lookup also leaves the tip empty, and testing emptiness first# would read every unreachable remote as a creation. Same ordering discipline as Step 5.if [[ -z "$PUSHED" ]] || [[ "$RB_LOOKUP_FAILED" = 1 ]]; thenRB_ANCESTRY=; RB_READING=unknownelif [[ -z "$RB_TIP" ]]; thenRB_ANCESTRY=; RB_READING=creationelif [[ "$RB_TIP" = "$PUSHED" ]]; thenRB_ANCESTRY=; RB_READING=up-to-dateelseif /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git merge-base --is-ancestor "$RB_TIP" "$PUSHED"; then RB_ANCESTRY=0; else RB_ANCESTRY=$?; ficase "$RB_ANCESTRY" in0) RB_READING=fast-forward ;;1) RB_READING=rewrite ;;*) RB_READING=unknown ;;esacfi/usr/bin/printf 'RB_READING=[%s]\nRB_TIP=[%s]\nRB_LOOKUP_FAILED=[%s]\nUNSHARED_ATTESTED=[%s]\n' \"$RB_READING" "$RB_TIP" "$RB_LOOKUP_FAILED" "$UNSHARED_ATTESTED"case "$RB_READING" inup-to-date|fast-forward) ;;creation)# Round 75. This row sat in the arm above until now, while the comment below the `case` and the# contract table at § the rollback reading table both said the opposite — "do not push: report# and hand back". Two documents describing a refusal, and an executable that published. The# pre-restore classifier's `head-deleted` row cannot cover this: it answers about the remote as# it was BEFORE the local restore, and a branch somebody else deletes in between arrives here# instead. Nor is the value-less lease a substitute for the arm — it refuses only while a# tracking ref still holds the pre-deletion OID, so a `fetch --prune` in the same window# removes the very anchor the refusal was resting on.echo "⛔ refs/heads/${head} no longer exists on the remote — somebody deleted it after this" >&2echo " rollback was classified. Restoring it would be a CREATION, not the rewrite this" >&2echo " rollback was approved to undo. STOP: report and hand back to the developer." >&2readonly PUSH_BLOCKED=1; exit 1 ;;rewrite)if [[ "$UNSHARED_ATTESTED" != "refs/heads/${head}" ]]; thenecho "⛔ the remote moved since the rollback was classified: refs/heads/${head} now reads" >&2echo " '${RB_TIP}', which backup/pr-<N> does not contain, and no unshared attestation" >&2echo " covers this ref. STOP. Two things are owed, and the ORDER is the contract" >&2echo " (git-workflow.md § Push safety: the question comes BY NAME and BEFORE the approval):" >&2echo " 1) put the unshared question to the operator by name;" >&2echo " 2) on a yes, ask the rollback force approval AGAIN, with a plan that states this" >&2echo " push rewrites the ref and shows the lease it will carry. The approval you hold" >&2echo " described this rollback as undoing a rewrite, not as performing one over a tip" >&2echo " that appeared afterwards." >&2echo " Only then re-run this fence with UNSHARED_ATTESTED=refs/heads/${head}." >&2echo " Do not set ALLOW_FORCE_UNSHARED — that variable is the developer's, not this skill's." >&2readonly PUSH_BLOCKED=1; exit 1fi# And the attestation, being a credential, says nothing about WHICH commit is destroyed. The# arm above passes on the ordinary rollback — the remote holds what this skill pushed, which# the backup does not contain — and on the hazardous one alike. This is the test that tells# them apart, and it reads the movement as evidence against the attestation it would otherwise# proceed on: a ref nobody else holds does not acquire commits nobody here published.if [[ "$RB_TIP" != "$APPROVED_TIP" ]]; thenecho "⛔ refs/heads/${head} points at '${RB_TIP:-<none>}' but this rollback was classified" >&2echo " against '${APPROVED_TIP:-<none>}' — the commit it would destroy is not the one the" >&2echo " approval covered, and the lease, carrying the new tip, would not refuse it." >&2echo " The attestation you hold says this ref is not shared; the tip moving since the" >&2echo " classification is evidence against it. STOP." >&2echo " 1) put the unshared question to the operator by name, for the tip as it reads NOW;" >&2echo " 2) on a yes, ask the rollback force approval AGAIN, with a plan naming that tip." >&2echo " Do not set ALLOW_FORCE_UNSHARED — that variable is the developer's, not this skill's." >&2readonly PUSH_BLOCKED=1; exit 1fi ;;*)echo "⛔ post-restore topology reads '${RB_READING}' for refs/heads/${head} — the measurement" >&2echo " did not answer, so nothing here knows what this push would overwrite. An attestation" >&2echo " answers whether the ref is shared; it is not evidence that the measurement worked." >&2readonly PUSH_BLOCKED=1; exit 1 ;;esac# Comparison rather than a push to the validated URL, for the reason and the 2026-08-22 measurement# recorded at the Step 5 push above — `insteadOf` rewrites a command-line URL too, so the swap pins# nothing. Stated once there; do not restate it here, only one of the two copies would be re-read.# **Round 75: the lease now carries the tip part (b) measured**, the shape Step 5 has had since# round 60, and `--force-if-includes` goes with the bare form it was compensating for. The# previous wording called the value-less lease the stronger anchor, on the grounds that it leases# against `refs/remotes/origin/<head>` — the tip the last fetch saw — and so refuses anything# pushed since. That premise is only as good as "nothing re-fetches": a background fetch moves# that ref, after which the lease expects the collaborator's commit and the push overwrites it,# and `--force-if-includes` does not stop it because `switch -C` put the branch's previous OID in# the reflog it certifies against. Measured end to end 2026-08-22 (git 2.55.0) on the equivalent# `/push-ci` fence: the bare pair published over a divergent `D` with exit 0 while the only# classification anybody saw said `fast-forward`; the same tree with the classified tip as the# lease value was rejected `(stale info)`. Binding to `$RB_TIP` also makes the credential and the# classification name **one** object — the property this whole file keeps asserting elsewhere.# The user's 2026-08-22 ruling settles the authorization question the old wording deferred to:# `--force-with-lease=<ref>:<expect>` is the granted flag in its value-bearing form, not a# separate grant. What is **not** reopened by that ruling is the creation row: re-publishing a# branch somebody else deleted is not the rewrite rollback was asked to undo, so it is refused in# the `case` above on that ground alone. Exactly three rows reach this line: `up-to-date` and# `fast-forward` unconditionally, and `rewrite` once `UNSHARED_ATTESTED` names this ref. On all# three the remote still holds <head>, so `$RB_TIP` is non-empty and the lease value can never# collapse into the "this ref must not exist yet" form by accident.# `SD0X_PUSH_DEST_DIGEST` is the other half of the destination check above, and the half that is# not a race. The comparison a few lines up re-reads the destination in THIS shell; the push is a# different process, so a `.git/config` edit or a `url.<x>.pushInsteadOf` landing in between still# redirects it. git closes that window itself and hands the answer to the pre-push hook as `$2` —# the destination it is about to reach, resolved inside the pushing process, after every rewrite.# Measured 2026-08-22 (git 2.55.0): under `url.<B>.pushInsteadOf=<A>` a push naming `origin` gives# `$1=origin` and `$2=<B>`, and the digest of `$2` equals the digest of `git remote get-url --push# --all origin` byte for byte, with the rewrite and without it. Wired end to end: the rewrite was# refused and nothing reached B; the same push carrying B's own digest went through.## **This is not an ALLOW_* variable and the Prohibited list does not cover it.** Those are# developer attestations, which is why this skill must never set them and must clear the ones it# inherits. This one is the opposite direction: it is a constraint the skill imposes on its own# push, it can only ever cause a refusal, and setting it inline is what stops an inherited value# from deciding. Where the hook is not installed it does nothing at all — monotone, the same# property the lease value above has: both can only ever turn a push into a refusal.[[ -z "$PUSH_BLOCKED" ]] && [[ -n "$PUSHED" ]] && \/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT ALLOW_PUSH_PROTECTED= ALLOW_FORCE_UNSHARED= SD0X_PUSH_DEST_DIGEST="$PUSH_URLS_DIGEST" GIT_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 ALLOW_FORCE_WITH_LEASE=1 git push --force-with-lease="refs/heads/${head}:${RB_TIP}" --receive-pack=git-receive-pack "origin" -- "${PUSHED}:refs/heads/${head}"# 3. If a merge already happened, manually revert via GitHub UI# (no scripted revert — too dangerous for stacked chain)
Conflict Handling
A conflict ends Claude's execution of the chain. The Anchor grant in @rules/git-workflow.md is the exact form git rebase --onto — --continue, --abort and --skip are not on it, and a grant for one rebase form is not a grant for the family. So the skill stops with the repository mid-rebase, prints the commands below, and the developer runs them:
# resolve the conflicted paths, then:/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rebase --continue # never --skip — commits must not be lost# or, if it cannot be resolved:/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rebase --abort # then restore from the Phase 1 backup tag
Re-verify the manifest after the developer reports the rebase finished (subject set must match) before offering to continue the chain. Resuming without that check merges an unverified tree.
Resume / Checkpoint (long chains)
For chains of 10+ PRs, mid-failure recovery without restart:
- Determine where the chain stopped — the binding and its use go in one block, because two
inline spans are two shells and the second would expand an unset (or ambient) $epic (§ Names in commands):
``bash if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git tag -l 'backup/pr-*'; then echo "⛔ resume: the backup tags could not be listed. They are the cut points this whole" >&2 echo " step reads, so an empty listing and an unreadable one mean opposite things. STOP." >&2 SD0X_EPIC_MERGE_REFUSED= : "${SD0X_EPIC_MERGE_REFUSED:?refusing — the backup tags could not be listed}" fi epic=<quoted epic> # Captured, then filtered — never git log … | grep …. A pipeline exits with its LAST # stage's status, and grep is a bare word an imported function can claim, so a forged # merged-PR list returns 0 over a git log that fataled. Read the two statuses apart: # grep exit 1 is "no match", a valid empty answer here; anything above 1 is grep failing. if ! EPIC_LOG=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git log "refs/remotes/origin/$epic" --oneline --); then echo "⛔ resume: refs/remotes/origin/${epic} could not be read. An epic with no merged" >&2 echo " PRs and an epic nobody could read need opposite responses. STOP." >&2 SD0X_EPIC_MERGE_REFUSED= : "${SD0X_EPIC_MERGE_REFUSED:?refusing — the epic log could not be read}" fi # The if carries the status for the reason Step 5 above states: under an inherited # errexit a following capture line never runs, and grep exit 1 — no merged PRs yet — is # the ordinary first resume rather than a failure. if MERGED_PRS=$(/usr/bin/grep -E '\(#[0-9]+\)' <<< "$EPIC_LOG"); then GREP_STATUS=0; else GREP_STATUS=$?; fi if [[ $GREP_STATUS -gt 1 ]]; then echo "⛔ resume: the merged-PR filter itself failed (exit ${GREP_STATUS}). STOP." >&2 SD0X_EPIC_MERGE_REFUSED= : "${SD0X_EPIC_MERGE_REFUSED:?refusing — the merged-PR filter failed}" fi /usr/bin/printf '%s\n' "$MERGED_PRS" ``
- **Cut the first remaining PR over to the epic yourself — Phase 0 will not do it, and will abort
until you have. There is no already-merged detection anywhere in this skill: Phase 0 aborts on any PR that is not OPEN, and separately requires the first listed PR's base to equal `<epic-branch>`. After an interruption the first still-open PR is based on the *previous* PR's head, so a bare re-invocation hits that second abort. Worse, if you retarget the base and stop there, Phase 0 accepts the chain and Iteration 1 runs a direct squash with no rebase** — the already-merged commits of the previous PR would land on the epic a second time. So do both, in this order, before re-invoking:
``bash epic=<quoted epic>; head=<quoted first-remaining-head>; prev=<quoted previous-merged-PR-number> # a. Refresh the epic's remote-tracking ref, THEN rebase off the previous PR's backup tag — # that tag IS the cut point. The destination must be the remote epic: step 1 above says the # previous PR was squash-merged into origin/$epic, so that is where the new tip lives. RESUME_OK=1 if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git fetch --upload-pack=git-upload-pack "origin" -- "+refs/heads/${epic}:refs/remotes/origin/${epic}"; then echo "⛔ resume: refreshing refs/remotes/origin/${epic} failed. The rebase below would use" >&2 echo " the stale tip and the force-push would write it out. Fix the fetch first. STOP." >&2 RESUME_OK= fi if [[ -n "$RESUME_OK" ]] && ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rebase --onto "refs/remotes/origin/${epic}" "refs/tags/backup/pr-${prev}" -- "$head"; then echo "⛔ resume: the rebase failed. Do NOT force-push and do NOT retarget the base — the" >&2 echo " branch is mid-rebase or unchanged, and neither is what step b assumes." >&2 RESUME_OK= fi # then, ONLY while $RESUME_OK is still set, force-push ${head} with the Step 5 command # verbatim (§ Iteration 2..N). It is # referenced rather than copied: this document carries exactly the two push sites the # skill executes, and a third literal would read as one more place authorization lives # b. Only now retarget the base, so Phase 0's first-base check passes on a rebased branch [[ -n "$RESUME_OK" ]] && /usr/bin/env -u BASH_ENV -u ENV gh pr edit "<first-remaining-PR>" --base "$epic" ``
> `refs/heads/$epic` is not `origin/$epic`, and resume is where that bites. An earlier > version of step 2a rebased onto the local branch. Nothing in steps 1–2 creates or > refreshes it, so it is stale whenever the developer has not pulled since the last merge, and > absent whenever they never checked the epic out at all — resume commonly runs in a fresh > clone after an interruption. Measured in this repository, the two refs differ right now: > git rev-list --left-right --count refs/heads/main...refs/remotes/origin/main → 1 0. > The manifest check in § Iteration Step 4 does not catch it: origin/$epic..$head can hold > exactly the expected subjects while $head sits on a stale ancestor, so the force-push and > squash that follow would publish the wrong tree with every gate green. The ordinary iteration > never had this defect — it rebases onto origin/$epic and refreshes that ref at Step 9.
- Re-invoke
/epic-merge <epic> <remaining-PRs>, listing only the still-open PRs. Iteration 1's
direct squash is now correct, because step 2 already performed the rebase it does not do.
- Backup tags from prior session remain valid as long as the corresponding branches have not been
re-pushed — step 2a re-pushes the first remaining head, so re-tag it if you interrupt again.
This is a manual procedure, not an implemented resume. A real resume would identify the mergedPR numbers from the epic log, keep the last merged PR's backup tag as the cut point, and route thefirst remaining PR through the Iteration 2 rebase path automatically. None of that exists here; thesteps above are what a person must do instead.
Post-Merge Cleanup (--cleanup flag)
# Remove local merged branches — one per invocation, each name bound and separated.# Deleting is destructive, so an option-shaped name reaching git as a flag is worse here# than anywhere else in this document; `--` after `-D` makes the name an operand.branch=<quoted branch>if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git branch -D -- "$branch"; thenecho "⛔ cleanup: git branch -D failed. The backup tags below are the recovery state" >&2echo " for exactly this case, so they are NOT touched. STOP." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the local branch could not be deleted}"fi# Inspect backup tags (kept by default)if ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git tag -l 'backup/pr-*'; thenecho "⛔ cleanup: the backup tags could not be listed. The deletion below is gated on" >&2echo " confirming what they are, and there is nothing to confirm. STOP." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the backup tags could not be listed}"fi# Remove backup tags only after confirming nothing went wrongif ! /usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git tag -d backup/pr-100 backup/pr-101 ...; thenecho "⛔ cleanup: git tag -d failed. Some tags may be gone and some may remain, so the" >&2echo " run-owned manifests below are left in place as the record. STOP." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the backup tags could not be deleted}"fi# Remove manifest files — the whole run-owned directory, expected and actual alike.# The earlier per-glob removal named only the expected manifests and left the actual# ones behind, so "cleanup" left state on disk that the next run then read as its own.# The path is derived FIRST, with its own status checked, because `rm -rf "$(…)"` cannot fail# here: a `rev-parse` that fails prints nothing, `rm -rf ""` returns 0 on both bash 3.2 and# zsh 5.9 (measured 2026-08-22), and this is the fence's last command — so the manifests survive# and cleanup reports success. The next run then reads them as its own.MANIFEST_DIR=$(/usr/bin/env -u BASH_ENV -u ENV -u GIT_EXEC_PATH -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_NAMESPACE -u GIT_CEILING_DIRECTORIES -u GIT_GLOB_PATHSPECS -u GIT_ICASE_PATHSPECS -u GIT_NOGLOB_PATHSPECS -u GIT_LITERAL_PATHSPECS -u GIT_CONFIG -u GIT_CONFIG_PARAMETERS -u GIT_CONFIG_COUNT -u GIT_CONFIG_NOSYSTEM -u GIT_CONFIG_GLOBAL -u GIT_CONFIG_SYSTEM -u GIT_IMPLICIT_WORK_TREE -u GIT_GRAFT_FILE -u GIT_SHALLOW_FILE -u GIT_PREFIX -u GIT_REPLACE_REF_BASE -u GIT_EXTERNAL_DIFF -u GIT_SSH_COMMAND -u GIT_SSH -u GIT_PROXY_COMMAND -u GIT_SSH_VARIANT git rev-parse --git-path epic-merge) || MANIFEST_DIR=if [[ -z "$MANIFEST_DIR" ]]; thenecho "⛔ the manifest directory could not be derived — nothing was removed, and the next run" >&2echo " would read this run's manifests as its own. Resolve it and re-run cleanup." >&2SD0X_EPIC_MERGE_REFUSED=: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the manifest directory could not be derived}"fi# Absolute, like every other word in this document that decides something: `rm` is claimable by# an imported function exactly as `wc` is (§ 4.35), and this is the fence's last command, so a# shadowed `rm` that returns 0 removes nothing and makes cleanup report success./bin/rm -rf "$MANIFEST_DIR"
Arguments
| Argument | Description | Default | |
|---|---|---|---|
<epic-branch> | Target epic branch name | Required | |
<PR-list> | Comma-separated PR numbers | Auto-detect from chain | |
--dry-run | Show plan + commands; performs one bounded local fetch — remote unchanged, but see § --dry-run for the local residue (objects, FETCH_HEAD, possible auto-maintenance) | off | |
--per-step | Four gates per iteration 2..N (rebase / unshared question / push / merge); iteration 1 is unaffected — it has no rebase and no force-push | off (the unshared question plus one bundled gate) | |
--cleanup | Delete local branches + manifests after success | off | |
--keep-backup-tags | Keep backup tags even with --cleanup | off | |
--ci-timeout <min> | Timeout passed to /watch-ci | 15 |
Examples
# Auto-detect chain into epic branch (iteration 1: one gate; iterations 2..N: unshared question + bundled gate)/epic-merge epic/gas-account-OK-49808# Dry-run to see plan first/epic-merge epic/feature-xxx --dry-run# Explicit PR list with finer per-step gates (first-time use)/epic-merge epic/feature-xxx 100,101,102,103 --per-step# Full cleanup after success/epic-merge epic/feature-xxx --cleanup
Prerequisites
ghCLI authenticated (gh auth status)- Repository uses squash-merge only (GitHub repo setting)
- All PRs in the chain are OPEN and CI passing on their current base
- Working tree clean (
git status --porcelainempty) - Backup tag namespace
backup/pr-*not already in use for unrelated purposes
Limitations
- Linear chains only (no diamond / parallel merges)
- Squash-merge repos only
- One epic branch per invocation
- CI delegation requires
/watch-cito be available
Verification
- [ ] Phase 0: Chain validated as linear, all PRs OPEN, working tree clean
- [ ] Phase 1: Backup tags created from
origin/*refs, keyed by PR number - [ ] Phase 1: Per-PR subject-only manifests written
- [ ] Phase 2: AskUserQuestion answer recorded BEFORE the first destructive op of each iteration
- [ ] Phase 2: no push command contains the bare
--forceflag (token-level check, not substring —--force-with-leaseis allowed); the iteration push carries--force-with-lease=refs/heads/<head>:$FINAL_TIPand no--force-if-includes(the flag is a documented no-op beside a lease value — § Safety), the rollback push carries--force-with-lease=refs/heads/<head>:$RB_TIPand no--force-if-includeseither (round 75 — same reason, and § Safety records why the flag was never live on that path) - [ ] Phase 2:
/watch-ciinvoked with--sha <sha> --branch <head>; PASS verdict received beforegh pr merge - [ ] Phase 2: Manifest
diffexits 0 after each rebase - [ ] Phase 3: Final epic log shows N squash commits with
(#NNN)suffix in expected order - [ ] No commit/push performed without an AskUserQuestion answer in the conversation transcript
References
| File | Purpose | When to Read | |
|---|---|---|---|
@rules/git-workflow.md | Push/rebase exception model (this skill is one of three exceptions) | Before any destructive op | |
skills/smart-rebase/SKILL.md | Single-PR squash-merge rebase pattern | Phase 2 rebase logic | |
skills/merge-prep/SKILL.md | Pre-merge analysis primitives | Phase 0 chain analysis | |
skills/watch-ci/SKILL.md | CI verdict polling — argument contract | Phase 2 Step 7 delegation | |
skills/push-ci/SKILL.md | AskUserQuestion gate pattern for git push | Iteration Gate Design |