| 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+1
epic: 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 exists
f0fb083 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
sequenceDiagram
participant U as User
participant E as /epic-merge
participant W as /watch-ci
participant GH as GitHub
E->>E: Phase 0 — analyze chain (linear?)
E->>GH: Phase 1 — fetch + create backup tags
Note over E,GH: Iteration 1 — direct squash (no rebase needed)
E->>U: AskUserQuestion (bundled gate)
U-->>E: Proceed / Dry-run / Abort
E->>GH: gh pr merge --squash
E->>GH: fetch updated epic
Note over E,GH: Iteration 2..N — rebase + force-push + CI + merge
loop For each remaining PR
E->>U: AskUserQuestion (bundled gate)
U-->>E: Proceed / Per-step / Dry-run / Abort
E->>E: rebase --onto epic backup/pr-<prev>
E->>E: verify manifest (subject + count)
E->>GH: push --force-with-lease
E->>GH: gh pr edit --base epic
E->>W: /watch-ci --sha <sha> --branch <head> --timeout <ci-timeout>
W-->>E: PASS / FAIL verdict
E->>GH: gh pr merge --squash
E->>GH: fetch updated epic
end
E->>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 be
relied on, and the in-session approval is the only credential where the opt-in pre-push hook is
not 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" ]]; then
echo "⛔ transport variables set in this environment: ${TRANSPORT_PRESENT}" >&2
echo " Each one decides where a push lands, so neither honouring nor clearing them lets this" >&2
echo " phase describe the destination that would be reached." >&2
echo " Move the setting to ~/.ssh/config or 'git config core.sshCommand' — per-host, durable," >&2
echo " 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; then
echo "⛔ Phase 0: the PR could not be read — the view command exited nonzero. head, base and" >&2
echo " the commit count below all derive from its output, and there is none. STOP." >&2
SD0X_EPIC_MERGE_REFUSED=
: "${SD0X_EPIC_MERGE_REFUSED:?refusing — the PR could not be read}"
fi
head=<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 --porcelain non-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 (, or at its prompt).
The one exclusion is a ref the protected prompt already covers, so . That is the claim; "one push never asks twice" was the earlier wording and it
overstates it — a push carrying a protected fast-forward 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.
— 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 installed
nothing at asks anything, so the per-iteration AskUserQuestion is the only attestation
there will be — which is why the gate table asks the unshared question 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 -u prefix, 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 at exit 0 with no gate. ,
and are in the prefix for a related but distinct reason — git runs
each as the connection itself, so they choose (see above). is
deliberately 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=1
for pr in <PR-numbers>; do
head_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>; do
head=$(/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" ]]; then
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
ITER1_REFRESHED=1
else
echo "⛔ PR <first-PR> merged, but refreshing refs/remotes/origin/${epic} failed. The merge" >&2
echo " stands and must not be repeated; the RUN must stop, because iteration 2 would rebase" >&2
echo " onto the pre-merge tip and force-push history without PR <first-PR> in it." >&2
echo " Re-run the fetch by hand, confirm origin/${epic} moved, then resume (§ Recovery)." >&2
fi
fi
# 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" ]]; then
echo "⛔ the manifest directory could not be derived — Step 4 would compare against a file" >&2
echo " written at the filesystem root, and the checkout and rebase below would already have" >&2
echo " happened. Nothing is checked out and nothing is pushed." >&2
readonly PUSH_BLOCKED=1
SD0X_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"; then
echo "⛔ could not check out refs/remotes/origin/$head for PR <N> — the start point every later" >&2
echo " step is measured against does not exist here. STOP; nothing after this means anything." >&2
readonly PUSH_BLOCKED=1; exit 1
fi
# Step 3: Rebase — cut already-squashed commits, replay unique ones onto epic
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 rebase --onto "refs/remotes/origin/$epic" "refs/tags/backup/pr-<prev>" -- "$head"; then
echo "⛔ the rebase did not complete for PR <N>. A rebase is probably still in progress and the" >&2
echo " working tree holds a partial replay — pushing it would publish a branch nobody approved." >&2
echo " STOP. Resolve and continue, or abort and restore from the backup tag:" >&2
echo " git rebase --abort" >&2
echo " git switch -C \"$head\" refs/tags/backup/pr-<N>" >&2
echo " No abort is issued here: it would discard conflict resolution the operator may have done." >&2
readonly PUSH_BLOCKED=1; exit 1
fi
# 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"; then
echo "⛔ could not write the actual manifest for PR <N> — there is nothing to compare, so the" >&2
echo " verification did not happen. Nothing is pushed. STOP." >&2
readonly PUSH_BLOCKED=1; exit 1
fi
if ! /usr/bin/diff "${MANIFEST_DIR}/expected-pr-<N>.manifest" "${MANIFEST_DIR}/actual-pr-<N>.manifest"; then
echo "⛔ manifest mismatch for PR <N>: the rebased branch is not the branch that was approved." >&2
echo " Nothing is pushed. Restoring the branch from its backup tag:" >&2
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/tags/backup/pr-<N>"; then
echo "⛔ and the restore FAILED — the working tree is in neither state. Do not push." >&2
echo " git status, then: git rebase --abort (if one is in progress) and re-run the switch." >&2
fi
readonly PUSH_BLOCKED=1; exit 1
fi
# 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 only
case "$head" in
main|master|develop|release/*)
echo "⛔ PR head '$head' is a protected branch — force push to shared branches is prohibited" >&2
readonly 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); then
PUSH_URL=${PUSH_URLS%%$'\n'*}
else
PUSH_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; do
case "$U" in
*://*)
REST=${U#*://}; AUTH=${REST%%/*}; AUTH=${AUTH%%\?*}; AUTH=${AUTH%%\#*}
case "$AUTH" in
*@*) U="${U%%://*}://<redacted>@${AUTH##*@}${REST#"$AUTH"}" ;;
esac
case "$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
;;
esac
PUSH_URLS_SAFE=${PUSH_URLS_SAFE:+$PUSH_URLS_SAFE$'\n'}$U
done <<SAFE_EOF
$PUSH_URLS
SAFE_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 sha256sum
elif command -v shasum >/dev/null 2>&1; then /usr/bin/env shasum -a 256
elif command -v openssl >/dev/null 2>&1; then /usr/bin/env openssl dgst -sha256
else return 1
fi
}
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 ]]; then
DIGEST_TOOL_OK=yes
fi
PUSH_URLS_DIGEST=
while IFS= read -r U; do
[[ -n "$U" ]] || continue
D=
if [[ -n "$DIGEST_TOOL_OK" ]]; then D=$(sha256_hex "$U"); fi
case "$D" in *[!0-9a-f]*|'') D= ;; *) [[ ${#D} -eq 64 ]] || D= ;; esac
if [[ -z "$D" ]]; then PUSH_URLS_DIGEST=; break; fi
PUSH_URLS_DIGEST=${PUSH_URLS_DIGEST:+$PUSH_URLS_DIGEST }$D
done <<< "$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>" ]]; then
echo "⛔ push destination '${PUSH_URLS_SAFE:-unresolvable}' is not the one approved — refusing" >&2
echo " (identity is a digest of the raw destination, so a change the redaction hides still refuses)" >&2
if [[ -n "$PUSH_RECEIVEPACK" ]]; then
echo " (remote.origin.receivepack is configured, so the URL does not decide where the objects land; read it with: git config --get remote.origin.receivepack)" >&2
fi
readonly PUSH_BLOCKED=1; exit 1
fi
# ⚠️ 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" ]]; then
FINAL_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" ]]; then
echo "⛔ url.*.insteadOf rewrites the resolved push destination a SECOND time — the push" >&2
echo " goes to the once-rewritten URL while a probe of that URL reads the twice-" >&2
echo " rewritten one, so nothing here can measure the destination. STOP." >&2