| name | push-ci |
| description | Push to remote and monitor CI. Validates branch safety, executes git push WITH explicit user approval, then monitors CI run status via gh CLI. Use when: user says 'push', 'push and watch CI', 'ship it', 'push-ci'. Not for: committing (use /smart-commit), creating PRs (use /create-pr), merging (use /merge-prep). |
| disable-model-invocation | true |
| allowed-tools | Bash(git:*), Bash(gh:*), Bash(bash:*), Bash(/bin/bash:*), Read, Grep, Glob, AskUserQuestion |
Push & CI Monitor
Push to remote with user approval, then monitor CI run until completion.
Authorization
⚠️ This skill is one of two authorized paths for Claude to execute `git push`.
⚠️ The other is /epic-merge (--force-with-lease for stacked PR chains, per-iteration AskUserQuestion gate).
⚠️ This skill may also use --force-with-lease, but only when the caller passes the flag — and NEVER onto a protected branch; bare --force is forbidden everywhere.
⚠️ All other skills and rules MUST output push commands only (not execute).
⚠️ Push REQUIRES explicit user approval via AskUserQuestion — no exceptions.
| Rule | This Skill | /epic-merge | All Other Skills |
|---|
git push | Execute (after user approval) | Forbidden (uses --force-with-lease only) | Forbidden (output only) |
git push --force | Forbidden | Forbidden | Forbidden |
git push --force-with-lease | Execute — only when --force-with-lease is explicitly passed, after user approval naming the force form; never onto a protected branch (Phase 0 hard-aborts, Phase 2 re-asserts) | Execute (after per-iteration AskUserQuestion) | Forbidden |
| Push to protected branches (main/master/develop/release/*) | Warn + pre-approval via AskUserQuestion (final gate is the terminal hook when installed, otherwise this approval); with --force-with-lease → hard abort, no question asked | Protected PR heads rejected — Phase 0 validation, re-asserted before Step 5 and Rollback (a PR head is not inherently unprotected) | Forbidden |
Defense in Depth: Push Safety
| Layer | Mechanism | Scope | Reliability |
|---|
| L1: git pre-push hook (opt-in) | pre-push-gate.sh reads /dev/tty for terminal confirmation | Two classes reach the prompt (since 2026-08-21): a protected branch with ALLOW_PUSH_PROTECTED unset, and a history-rewriting push whose rewritten refs are not already covered by that first prompt, with ALLOW_FORCE_UNSHARED unset. Non-fast-forward is an earlier, orthogonal refusal — this hook's own exit 1 only when git hands it the ref (the force form); otherwise git refuses first and this hook never sees it (§ below). Skipped by ALLOW_FORCE_WITH_LEASE=1, after which the push falls through to the unshared attestation first and the protected check second — not straight to the protected check (scripts/pre-push-gate.sh: the refusal at the force-form check, then the rewrite gate, then the protected gate) | Immune to Claude Code permission caching — when installed, and only for the classes it prompts on |
| L2: AskUserQuestion | In-session prompt before push | All pushes | May be auto-approved by session caching |
| L3: git-workflow rules | Claude forbidden from raw git push | All contexts | Behavioral enforcement |
Which layer authorizes depends on whether L1 is installed. The pre-push hook is opt-in (/codex-setup init --with-push-gate, or sync --with-push-gate on an existing project). /install-scripts copies pre-push-gate.sh into .claude/scripts/ and never wires up a hook, so having run it is not evidence the gate exists.
L1 prompts for two classes of push, and they are different questions. The protected prompt asks may this branch be pushed to at all; it fires when the ref set includes a protected branch and ALLOW_PUSH_PROTECTED is unset. The unshared attestation asks is anybody else working on the refs this push rewrites; it fires when the push rewrites a ref and ALLOW_FORCE_UNSHARED is unset, over the rewritten refs the protected prompt will not already cover. Rewrite is read per ref class, because ancestry is the branch rule: a branch update is a rewrite when the remote tip is not provably an ancestor of what replaces it, while every update to an existing tag is one — git requires force semantics for any change to an existing refs/tags/* ref, forward moves included, because a tag names one commit rather than a line of history (scripts/pre-push-gate.sh § is_tag_ref, and the hook prints a separate line for tags precisely because a forced tag update can be a textbook fast-forward). A tag creation has no history to overwrite and is not asked about, and neither is a deletion of any ref class — the gate's rewrite test needs a non-null OID on both sides, so removing an existing tag or branch reaches no prompt (rules/git-workflow.md § Push safety states the boundary; the maintainer decided on 2026-08-22 to leave it there). Reading the class as "non-fast-forward" alone would wave through exactly the moves git itself classifies as forced — which is every rewritten ref when ALLOW_PUSH_PROTECTED=1 has silenced that prompt. Neither variable clears the other. Every push the hook permits without meeting either condition exits 0 (scripts/pre-push-gate.sh). A push the hook refuses is a third outcome and not a row below: it never happens, so nothing authorized it. So the gate is chosen by both axes, and an installed hook never demotes L2 for a push the hook does not prompt on:
| Push (one the hook permits) | L1 installed | L1 not installed |
|---|
Protected branch, ALLOW_PUSH_PROTECTED unset | L1 authorizes — /dev/tty confirmation, immune to permission caching. L2 stays required but advisory | L2 authorizes — the AskUserQuestion in Phase 0/1 |
History-rewriting push — a branch update that is not provably a fast-forward, read fail-closed (the gate negates merge-base --is-ancestor, so an ancestry test that errors lands here exactly as one that answers no), or any update to an existing tag (forward moves included) — with ALLOW_FORCE_UNSHARED unset | L1 authorizes — the /dev/tty unshared attestation. L2 stays required but advisory | L2 authorizes — and Phase 1 must put the unshared question to the user itself (below), because without the hook nothing else will |
| Every other permitted push, an ordinary fast-forward included | L2 authorizes — L1 exits without prompting, so there is no terminal confirmation to defer to | L2 authorizes |
The tag half of that second row is a hook-level fact, not a /push-ci one. This skill only ever
builds a branch refspec, so a tag update cannot arise through it. The row states it anyway because
this table describes pre-push-gate.sh for whoever invokes it — a developer pushing a tag by hand
meets the same gate — and because a class stated as "non-fast-forward" would be read as a complete
definition by exactly the reader who then pushes a tag from the shell.
Non-fast-forward is an orthogonal refusal, not a third row. It is decided before the protected check and never produces a confirmation of its own: without ALLOW_FORCE_WITH_LEASE=1 the push is refused outright (exit 1) — by the hook when it is given the ref, by git before that when no force flag was passed, and a refusal either way is not an authorization: nothing was approved, the push simply did not happen. With the variable set the hook's refusal is skipped entirely and the push falls through to the same protected-branch decision as any other. So it lands in whichever row its branch puts it in. Measured, all five shapes — these are hook-level facts, describing pre-push-gate.sh for whoever invokes it:
| Push | ALLOW_FORCE_WITH_LEASE | Result | Reachable via /push-ci? |
|---|
| Unprotected, non-fast-forward, no force flag | unset or empty | git rejects the ref client-side; the hook runs with an empty ref list and exits 0 having never seen the branch | Yes — a flagless /push-ci on a diverged branch. The gate refused nothing here |
Unprotected, non-fast-forward, --force-with-lease | unset | exit 1 — the hook's own refusal, before the protected check, no prompt | No — Phase 2 sets the variable in the same branch that passes the flag. Only a manual force push reaches this cell |
Unprotected, non-fast-forward, --force-with-lease | 1 | Reaches /dev/tty for the unshared attestation — the force row above, L1 authorizes. exit 0 only once attested (or with ALLOW_FORCE_UNSHARED=1); no terminal ⇒ exit 1 | Yes — this is the one force path this skill has |
Protected, non-fast-forward, --force-with-lease | 1 | Reaches /dev/tty once — the protected prompt; the attestation excludes refs that prompt covers | No — Phase 0 hard-aborts protected × --force-with-lease before any question, and Phase 2 re-asserts it. Only a manual git push --force-with-lease reaches this cell |
Protected, non-fast-forward, --force-with-lease, ALLOW_PUSH_PROTECTED=1 | 1 | Reaches /dev/tty for the unshared attestation — the protected prompt is silenced, so the rewritten ref is no longer excluded from it | No — this skill never sets ALLOW_PUSH_PROTECTED. Only a manual push reaches this cell, and it is the combination that used to pass in total silence |
Row 1 exists because git does not hand the hook a ref it has already rejected. Measured on git 2.55.0 with a hook that dumps its stdin: a flagless push of a diverged branch runs the hook with zero ref lines — so pre-push-gate.sh finds no branches, detects no non-fast-forward, prompts on nothing and exits 0 — and the ! [rejected] … (non-fast-forward) that stops the push is git's. Add --force-with-lease to the same push and the hook receives the ref line and its own refusal fires. Two consequences: the hook's non-fast-forward exit 1 is reachable only on a push carrying a force flag, and a flagless /push-ci that fails on a diverged branch was stopped by git, not by the gate. Reading row 1 as a gate refusal would credit a credential to an operation no gate ever saw — the same error in the same direction as reading an absent hook as "no approval needed".
The last column is the part a reader of this skill needs, and it is why the table is not just the hook's. Three of the five shapes cannot happen through /push-ci at all: describing them as this skill's paths would promise a terminal confirmation on a route the skill refuses to take. The hook-level facts stay documented because the developer who pushes by hand still meets them.
The trap in both directions: grouping non-fast-forward with protected pushes claims a terminal credential for a push that is merely refused, and grouping it against them denies one for the third shape, where the terminal prompt genuinely happens — for a manual caller.
Reading "the hook is installed" as "something stronger will always ask" is the error this table exists to prevent — and the table cannot supply its own counter-example, because every cell in it either refuses the push or reaches /dev/tty. Rows 1 and 2 are refusals: the push does not happen, so nothing authorized anything. Rows 3–5 all prompt, so with the hook installed L1 is the credential there. The pushes whose only credential is the in-session approval are precisely the ones this table does not contain: every permitted push in neither prompting class — the row above it — and every push at all when the hook is not installed. Generalizing "installed ⇒ prompted" from these five rows is getting the wrong rule from the one table where it happens to hold.
And the "L1 installed" column is a state of the world, not something Phase 0 can establish. What Phase 0 reads is whether an executable hook references the gate — PUSH_GATE=referenced, never installed — and reference cannot prove invocation: a script that merely names the gate in a live command satisfies the same test. So the demotion in the top-left cell is earned by the operator seeing the /dev/tty prompt, never by the detection predicting one: if the approval is given and no prompt appears, that in-session approval was the only approval, whatever PUSH_GATE reported. This is why the approval is unconditionally required rather than skipped when L1 looks present — the check is allowed to be wrong in the unsafe direction precisely because nothing is skipped on its word.
Why AskUserQuestion is the weaker of the two: session permission caching can auto-approve AskUserQuestion calls in long-running sessions, especially with -c continue mode (GitHub Issue #15400). That weakness is the reason L1 is worth installing — it is not a licence to push without approval when L1 is absent. An uninstalled gate lowers the strength of the authorization; it never removes the requirement for one.
Workflow
sequenceDiagram
participant C as Claude
participant U as User
participant GH as GitHub
C->>C: Phase 0: Preflight
alt Protected branch + --force-with-lease
C->>U: ⛔ Hard abort — prohibited combination, no question asked, no push
else Permitted combination
opt Protected branch (plain push)
C->>U: ⚠️ Warning + ask pre-approval
U->>C: Continue / Abort
end
C->>U: Phase 1: Show push plan + ask approval
U->>C: Approve / Reject
alt Approved
C->>C: Phase 2: Protected×lease guard + command assembly
C->>GH: Execute git push
C->>GH: Phase 3: Delegate to /watch-ci
GH-->>C: CI verdict
else Rejected
C->>U: Abort (no push)
end
end
Phase 0: Preflight
Run all checks. Hard-abort on infrastructure failures; warn-and-confirm on protected branches — except protected branch × --force-with-lease, which hard-aborts (prohibited combination, see step 0 below).
# 0. Bind the two flag variables from THIS invocation, before anything reads them.
# Write the literal value on each line: `true` only when that flag appeared in the
# /push-ci invocation, `false` otherwise. Both lines are always written, always in
# this order, and never guarded by `:=`, `-z` or `[ -n … ]` — a guard would let an
# inherited environment value survive, which is the entire defect this step closes.
# Measured: with these two unbound and an ambient `FORCE_WITH_LEASE=true` exported,
# the Phase 2 assembly below runs `git push --force-with-lease=refs/heads/<b>:<tip>`
# for an invocation that passed no flag and a plan that showed a plain push. The
# approval must name the force form (`rules/git-workflow.md` § Exception), so the
# form has to come from the arguments and from nothing else.
FORCE_WITH_LEASE=false # `true` only if the invocation contained --force-with-lease
SET_UPSTREAM=false # `true` only if the invocation contained --set-upstream
# ── 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 the destination this phase digests for
# the approval, 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_PUSH_CI_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_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_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
# 0b. 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_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — transport variables set in this environment}"
fi
# 1. Current branch
BRANCH=$(/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 --abbrev-ref HEAD)
# 1b. Detached HEAD, **or no branch name at all** — hard-abort. On a detached HEAD the command
# above returns the literal string `HEAD`, which is not a branch name; when it *fails* it returns
# nothing, which is not one either, and the empty case was the one this check could not see.
# Every later step treats $BRANCH as a branch name: the protected-branch match below compares it
# against main/master/develop, the upstream probe builds `origin/$BRANCH`, and the push builds a
# branch refspec — so an empty one silently compares against nothing and builds `origin/`. The
# same refusal is already carried in Phase 1; Phase 0 is where the plan is built, so it belongs
# here first.
if [[ -z "$BRANCH" ]] || [[ "$BRANCH" = "HEAD" ]]; then
echo "⛔ Phase 0: no branch name here — detached HEAD, or rev-parse failed. /push-ci pushes a" >&2
echo " named branch, and every check below names it. Nothing is planned and nothing is pushed." >&2
# Not `exit`: a builtin is outranked by an imported `BASH_FUNC_exit%%`, and this arm sets no
# flag a later step reads, so a shadowed `exit` would leave the whole phase running on a
# branch name that is not one.
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — no branch name could be derived in Phase 0}"
fi
# 2. Protected branch detection
# If main, master, develop, or release/* → warn + AskUserQuestion pre-approval
# (do NOT hard-abort; let user decide) — EXCEPT when --force-with-lease was passed:
# that combination hard-aborts, because force push to shared branches is prohibited
# (rules/git-workflow.md § Prohibited) and no approval can authorize it
# 3. Remote exists. The status is CAPTURED and acted on, not discarded: this line used to
# run bare with `>/dev/null 2>&1`, so a failing `ls-remote` left `$?` for the next command
# to overwrite and the fence reported a normal-looking preflight for a remote that does not
# resolve. The table below calls this an Abort, and an abort a later step cannot see is a
# table row nothing implements.
# No `--exit-code`, and that is the whole question this check asks. Measured 2026-08-22
# against a local bare repo: `--exit-code` returns 2 for a remote that answered and simply
# has no refs yet, 128 for one that could not be reached — one status for "reachable and
# empty", another for "not there", and the flag conflates the first with a failure. An
# empty remote is the first push of a new repository, which is a case /push-ci exists for.
# Without the flag the two separate: 0 reachable (refs or not), 128 unreachable.
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 ls-remote --upload-pack=git-upload-pack origin >/dev/null 2>&1; then
echo "⛔ remote 'origin' did not answer — check the remote, or that you can reach it" >&2
# Same terminator class as step 1b: no flag is set here, so `exit` alone is the whole refusal.
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — remote 'origin' did not answer}"
fi
# 4. Working tree status. The status is checked, not merely printed: Phase 0's contract is to
# hard-abort on infrastructure failure, and a `git status` that fails prints nothing — which
# reads exactly like a clean tree to whoever is about to approve a push plan built from it.
/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 --short || {
echo "⛔ Phase 0: the working tree could not be read. The plan below would describe a tree" >&2
echo " nobody looked at. Nothing is planned and nothing is pushed." >&2
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — the working tree could not be read}"
}
# 5. Commits ahead of remote. Two steps, never `… 2>/dev/null || echo "new branch"`: that form
# cannot tell "there is no remote-tracking ref" from a `rev-list` that fataled on a corrupt or
# unreadable object — it discards the diagnostic, prints the reassuring reading and exits 0, in a
# phase whose stated contract is to hard-abort on infrastructure failure. Existence is asked
# first, and only a ref that exists but cannot be counted is an error. Fully qualified for the
# reason § Names in commands gives: `origin/<name>` is DWIM and git resolves `refs/tags/` before
# `refs/remotes/`, so a tag named `origin/feat/x` would answer this range in place of the branch.
# The status is CAPTURED and switched on, never negated. `git rev-parse --verify --quiet` exits
# 1 for a ref that is absent and 128 for a repository or lookup failure, and `if !` collapses
# the two into one branch — so an unreadable repository prints "new branch" and this phase
# continues, in a phase whose stated contract is to hard-abort on infrastructure failure. It is
# the same fail-open shape the note above rejects one level out, pointing the same way: the
# collapsed reading is the reassuring one, which is why nobody notices it.
# `if` rather than a following `VERIFY_STATUS=$?` line: under an inherited `errexit` the shell
# aborts AT the lookup, so exit 1 — the ref genuinely is not there, which is this phase's
# ordinary new-branch case — would never reach the arm below that reports it. A command whose
# status the `if` consumes is not one `set -e` acts on.
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 rev-parse --verify --quiet "refs/remotes/origin/${BRANCH}" >/dev/null; then
VERIFY_STATUS=0
else
VERIFY_STATUS=$?
fi
case "$VERIFY_STATUS" in
1)
echo "new branch (no refs/remotes/origin/${BRANCH})"
;;
0)
if COMMITS_AHEAD=$(/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-list --count "refs/remotes/origin/${BRANCH}..HEAD" --); then
echo "${COMMITS_AHEAD}"
else
echo "⛔ Phase 0: refs/remotes/origin/${BRANCH} exists but the commits-ahead count could" >&2
echo " not be read. That is a repository failure, not a new branch, and the two need" >&2
echo " opposite responses. Nothing is planned and nothing is pushed." >&2
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — the commits-ahead count could not be read}"
fi
;;
*)
echo "⛔ Phase 0: refs/remotes/origin/${BRANCH} could not be looked up — git exited" >&2
echo " ${VERIFY_STATUS}, which is a repository or lookup failure and not an absent ref." >&2
echo " Reading it as a new branch would plan a push against a repository nothing read." >&2
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — the remote-tracking ref could not be looked up}"
;;
esac
# 5b. --set-upstream auto-detect. This may only ever turn SET_UPSTREAM ON: `-u` on a
# branch that already has an upstream is a no-op, while dropping a requested `-u`
# would silently change what the plan promised. It runs after step 1b, so a detached
# HEAD has already aborted rather than reaching an `@{u}` probe that cannot mean
# anything there.
if [[ "$SET_UPSTREAM" != "true" ]] &&
! /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 --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
SET_UPSTREAM=true
fi
# 6. Local HEAD SHA (for CI run matching later). Checked for the same reason as step 4, and with
# a sharper consequence: this value is what the approval names and what `/watch-ci` is later sent
# after. A failed lookup makes it empty, and an empty `HEAD_SHA` in the plan is a plan the
# operator cannot approve meaningfully. Phase 2 re-derives and compares, so this is not a route to
# an unsafe push — it is an approval collected for a plan that was never valid.
HEAD_SHA=$(/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) || HEAD_SHA=
if [[ -z "$HEAD_SHA" ]]; then
echo "⛔ Phase 0: HEAD could not be resolved. The push plan names the commit being pushed;" >&2
echo " an empty one is not a plan. Nothing is planned and nothing is pushed." >&2
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — HEAD could not be resolved}"
fi
# 7. pre-push gate detection — reports whether a hook referencing the gate exists.
# It informs how the push plan describes the credential; it never decides one, for
# the reason spelled out below: reference is not invocation.
# --git-path resolves core.hooksPath (and therefore Husky, which sets it), so a
# hook installed in any of the modes /codex-setup supports is found here.
# Existence is not enough: an unrelated pre-push hook (lint, test runner, a Husky
# shim that never sources the gate) is executable too, and reporting it as the
# terminal credential would promise a /dev/tty prompt that never comes.
# Comment lines are excluded before matching, because a disabled gate keeps its
# name in the file — `# pre-push-gate disabled during migration` followed by
# `exit 0` is executable, mentions the gate, and prompts for nothing.
# What this predicate establishes is *reference*, not invocation — and no static
# check of a shell script can establish invocation. `printf pre-push-gate >/dev/null`
# satisfies it and runs nothing. So the result is evidence, never proof, and the
# consequence is bounded below: an approval is required in every case, and L2 is
# demoted to advisory by the operator SEEING the terminal prompt, not by this check
# predicting one (see the note under the push-class table).
# This detection reads only THIS file and does not follow indirection:
# a Husky shim that execs a second script naming the gate reads as `absent`. That
# is the safe direction and the reason the whole check is written to fail toward
# it — an under-claimed credential costs one extra confirmation, an over-claimed
# one manufactures an approval nobody gave.
# Wrapped in an ABSOLUTE `/bin/bash -c` so the compound command matches this skill's
# allowed-tools (`Bash(/bin/bash:*)`, granted alongside `Bash(bash:*)` for this line); a bare
# `grep` here is outside the grant (see the Footguns table in CLAUDE.md). The absolute spelling
# is not cosmetic. The body below already runs `/usr/bin/grep` and `/bin/echo` for exactly one
# reason — every bare word is claimable by an imported function — and a bare `bash` wrapping
# them is that same hole one level up, where it swallows the whole probe rather than one word
# of it: an imported `BASH_FUNC_bash%%` never runs the body at all and prints what it likes.
# `PUSH_GATE=referenced` forged that way makes the push plan promise the operator a `/dev/tty`
# prompt that no hook is going to show.
PUSH_GATE=$(/bin/bash -c '
hook="$(/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 hooks)/pre-push"
# Absolute paths, and `echo` included: the stdout of this subshell IS `PUSH_GATE`, and every
# word here is claimable by an imported function — `grep` and `echo` alike, since function
# lookup precedes both the builtins and PATH. The probe selects no credential, but it decides
# what the push plan tells the operator to expect. (No apostrophes in this block: it is the
# body of a single-quoted `bash -c`, so one would close the string.)
if [[ -x "$hook" ]] && /usr/bin/grep -v "^[[:space:]]*#" "$hook" 2>/dev/null | /usr/bin/grep -q "pre-push-gate"; then
/bin/echo referenced
else
/bin/echo absent
fi
')
# 8. The effective push destination. `origin` is a name, not a destination: it can
# carry one URL for fetching and another for pushing, and `pushurl` is multi-valued,
# so the push may fan out. Derived HERE, in the same shell as the steps above and
# before the first question is asked — the protected-branch pre-approval below and
# the Phase 1 plan both name it, and Phase 2 refuses when it no longer matches.
# Until round 49 the only pre-approval derivation lived inside Phase 1's
# `--force-with-lease` branch, so an ordinary push reached that comparison with a
# value no approval had shown; round 50 moved it in here, because a value assigned
# in a fence of its own dies with that fence (see Phase 2 on separate shells).
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_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git remote get-url --push --all origin); then
PUSH_URL=${PUSH_URLS%%$'\n'*}
else
PUSH_URLS=; PUSH_URL=
fi
# 7c. `--force-with-lease` × a fan-out destination: refuse HERE, before any question is asked.
# A `url.<x>.pushInsteadOf` rewrite is NOT a way in, and the earlier draft said it was: git applies
# the single longest matching prefix, so a rewrite maps one URL to one URL, and where an explicit
# `remote.<name>.pushurl` exists `pushInsteadOf` is not consulted for that remote at all.
# `"$PUSH_URLS" != "$PUSH_URL"` is this document's own idiom for "more than one" (the two lines
# below say why it is exact). A FAILED derivation leaves both empty and equal, so it is not this
# row — that one is fail-closed downstream, where the destination guard compares the digest.
if [[ "$FORCE_WITH_LEASE" == "true" ]] && [[ "$PUSH_URLS" != "$PUSH_URL" ]]; then
echo "⛔ --force-with-lease with more than one push destination — refusing before asking." >&2
echo " 'origin' resolves to a fan-out: remote.origin.pushurl is multi-valued, or — with no" >&2
echo " pushurl configured — remote.origin.url is. A lease is measured against ONE" >&2
echo " remote tip, so the topology re-check before the push cannot say what each destination" >&2
echo " would be rewritten from — it reads 'unknown' and refuses. That refusal is correct;" >&2
echo " asking you to approve the push first is not." >&2
echo " Push the rewrite one destination at a time, naming each URL explicitly, so each one" >&2
echo " gets its own lease and its own answer to who else holds ${BRANCH}." >&2
# Not `exit 1`: measured 2026-08-22, an imported `BASH_FUNC_exit%%` that returns leaves the
# refusal printed and execution continuing into the report and the approval. Assign-then-expand,
# as in step 0a — syntax and an expansion, nothing a function can outrank. The `echo`s above are
# shadowable too; that costs the message, not the refusal, which is the right way round.
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — --force-with-lease with more than one push destination}"
fi
# Anything but exactly one URL is the fail-closed row, not a benign one. $(...) strips trailing
# newlines, so one URL leaves none and "$PUSH_URLS" != "$PUSH_URL" is precisely "more than one".
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
# 9. Report everything above. Each fence is a separate shell, so a value assigned here and
# not printed here reaches nobody — not the Phase 1 plan, not the questions, not Phase 2's
# re-derivation check. Until round 52 this block printed `PUSH_URLS_SAFE` alone, and every
# other line above was a value only this fence ever saw: the plan then named a branch, an
# upstream decision, a HEAD and a gate state that the model had to supply from somewhere
# else, which is the same failure as deriving them in a fence of their own. One field per
# line, name first, value bracketed — brackets so an empty value is visible as `[]` rather
# than as a line that looks truncated.
# 8b. The binding key, round 54. `PUSH_URLS_SAFE` is what a HUMAN reads, and redaction is lossy
# by design — it deletes the whole query and fragment. Two different repositories therefore
# redact to one string: `https://gw.example/push?repo=A&token=one` and `…?repo=B&token=two` both
# become `https://gw.example/push?<redacted>` (measured). Comparing THAT in Phase 2 binds the
# approval to a host and a path, not to a destination, so a `.git/config` edit between approval
# and push moves the push to another repository and the guard passes.
# So identity travels as a digest of each RAW URL, and the redaction is what a human reads. The
# digest IS printed — on the line below, in the plan, and by the gate when it refuses — so the
# honest claim is not "nothing is displayed" but that a SHA-256 preimage is not recoverable from
# it. What it does leak is equality: two transcripts carrying the same digest pushed to the same
# destination, and a guessable URL can be confirmed offline. Both are accepted; neither is a
# reason to show the raw URL instead, which would leak the credential itself.
# 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=
PUSH_RECEIVEPACK_SET=no
if [[ -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 'BRANCH=[%s]\nSET_UPSTREAM=[%s]\nFORCE_WITH_LEASE=[%s]\nHEAD_SHA=[%s]\nPUSH_GATE=[%s]\nPUSH_URLS_SAFE=[%s]\nPUSH_URLS_DIGEST=[%s]\nPUSH_RECEIVEPACK_SET=[%s]\n' \
"$BRANCH" "$SET_UPSTREAM" "$FORCE_WITH_LEASE" "$HEAD_SHA" "$PUSH_GATE" "$PUSH_URLS_SAFE" "$PUSH_URLS_DIGEST" \
"$PUSH_RECEIVEPACK_SET"
| Check | Pass | Fail |
|---|
| Branch is not protected | Continue | With --force-with-lease → Abort (prohibited combination); otherwise Warn + AskUserQuestion (see below) |
| Remote exists | Continue | Abort: "No remote 'origin' configured" |
| Has commits ahead | Continue | Abort: "Nothing to push (0 commits ahead)" |
--force-with-lease resolves to exactly one push destination | Continue | Abort (step 7c), before the plan and before any question. A lease is measured against one remote tip, so the topology re-check in Phase 2 can only read a fan-out as unknown and refuse. Refusing here changes which push is possible not at all — it changes when the operator finds out, and stops an approval being collected for a push that was never going to run |
PUSH_RECEIVEPACK_SET is no | Continue | Abort: "remote.origin.receivepack is configured — the destination this plan shows is not the one the objects reach". Measured 2026-08-22: a configured receivepack sent every object into a different repository while git printed To <the approved URL>. The report says only whether it is set, never the value: it is a command line, so it can carry a token, and rules/security.md forbids logging those. Read it with git config --get remote.origin.receivepack |
pre-push hook references the gate | Note PUSH_GATE=referenced — a hook referencing the gate was found; for protected-branch pushes and for history-rewriting pushes (the two classes it prompts on) expect a terminal confirmation, and treat its absence at push time as evidence the detection over-claimed | Note PUSH_GATE=absent — not an abort, and not a finding that no gate exists: the probe reads one file and does not follow indirection, so a Husky shim that execs the gate reads absent while the gate is installed |
PUSH_GATE is reported to the user, never acted on silently, and it never selects the credential
(@rules/git-workflow.md § Push safety). Report the observed fact and let the push decide:
absent means no direct reference was found in this file, not no terminal credential exists. For
a protected-branch push, say so in both directions — if a /dev/tty prompt appears, that prompt is
the terminal authorization and this in-session approval was advisory; if none appears, this approval
was the only one there will be. Either way the approval is required first. Detection is a read, and this skill never installs the hook — installing is /codex-setup's opt-in, and a push flow that quietly armed a gate would be making that choice for the developer mid-push.
PUSH_URLS_SAFE is the redacted destination every later step reads. The brackets bound it because
more than one line inside them means the push fans out to several repositories — say so wherever it
is shown. Empty brackets mean the destination could not be resolved, which is not a reason to
proceed quietly: say that too, and let Phase 2's comparison refuse rather than inventing a value.
A push URL may carry user:token@, so the raw value never leaves the shell — see § Redaction.
Protected branch pre-approval flow — advisory where the terminal hook is installed, and the authorization itself where it is not:
When branch is main, master, develop, or release/*:
-
--force-with-lease hard-aborts here — no question is asked. Force push to
shared branches is prohibited (rules/git-workflow.md § Prohibited), and this
skill reads the protected set as the decidable part of the shared set — a
deliberate reading, not a definition the rules supply. No rule enumerates "shared":
it is a fact about who else has the branch, which nothing here can observe. The
protected names are the branches that are shared by construction, and they are
the only ones a preflight can decide from the ref alone, so refusing there is the
conservative half of an undecidable question.
What that used to leave open, and what now closes it (2026-08-21, option A): a
feature branch two people are working on is shared too, and no preflight can decide
that from the ref. It is decided by asking. pre-push-gate.sh refuses any force-form
push whose targets include a non-protected branch unless the operator attests they are
unshared — ALLOW_FORCE_UNSHARED=1, or yes at its /dev/tty prompt. This skill must
never set that variable (see § Prohibited), so where the hook is installed the
attestation is the operator's and is immune to session caching; where it is not, the
Phase 1 AskUserQuestion is the only approval there will be and must name the force form.
The residual hazard is what an attestation cannot reach — the operator answering yes
about a branch someone else does hold — and the lease is weaker than it looks:
-
Overwrite — narrowed, not closed, and it is worth being exact about which
slice the flags cover. --force-with-lease with no =<refname>:<expect> value
takes the local remote-tracking ref as its expectation, and git-push(1) is
explicit that this form "interacts very badly" with anything running git fetch
in the background and that the protection is "trivially defeated" when those refs
are updated. Measured: a collaborator's commit fetched but never seen locally is
overwritten with exit 0 under the bare form, and rejected with exit 1 once
--force-if-includes is present — which is why Phase 2 passed both until round 75.
But --force-if-includes asks whether the remote tip is reachable from any
reflog entry of the local branch, not whether the history being pushed still
contains it. Measured on the same remote: fetch the collaborator's commit, move
the local branch onto it, then rewrite to a history that drops it — the reflog
still holds it, the check passes, and the push .
runs after whatever rewrite the operator just performed, so that is not
a theoretical ordering. — and only the
half of it where the operator had never held the fetched commit.
Phase 1: Push Plan + User Approval
Present push summary and ask user for explicit approval using AskUserQuestion:
## Push Plan
- Branch: `<branch>`
- Remote: `origin` → `<the effective push destination — the `PUSH_URLS_SAFE` value Phase 0 step 8 derived and printed, never the raw one: a push URL may carry `user:token@` and this line goes into the approval transcript>`. Show the destination, not just the remote name: `origin` can name a different URL for fetching than for pushing (an explicit `remote.origin.pushurl`, or a `url.<x>.pushInsteadOf` rewrite), and the name alone cannot tell the approver which repository is about to change. More than one URL means the push fans out to all of them — say so
- Commits: <N> ahead
- HEAD: `<the full object ID from Phase 0 step 6 — whatever width `git rev-parse HEAD` returned, not an abbreviation>`. Phase 2 re-derives it and refuses the push if it no longer matches, so this line is what that comparison is made against: shown short, it would either compare short (and pass on a prefix collision) or compare against a value the user never saw
- Overwrites: `<on a `--force-with-lease` push whose Phase 0 step 8 reading is `rewrite`: the full `REMOTE_TIP` object ID that step printed — the commit on the remote this push replaces. On every other reading: `nothing (<the ASK_REASON word>)`>`. **This line is why the plan can be reused as a comparison at all.** Phase 2 re-measures the remote and refuses when the answer differs from what is written here, exactly as it does for `HEAD:` above — and without the line there is nothing to compare against, so an approval given for destroying one commit would silently carry to a push destroying another. Full object ID, for the same reason `HEAD:` is: a prefix can collide, and a value shown short is not the value compared. `nothing` is a measurement too, not a blank — a reading of `rewrite` that appears only at Phase 2 is a topology change the approval never described, and it is refused there
- Push gate: state the **probe result and its limits**, never a credential verdict — `pre-push` hook found referencing the gate / **no direct reference found in the hook file (the probe does not follow indirection, so a gate reached through a shim would read this way too)**. Then, for a protected-branch push: if a `/dev/tty` prompt appears it is the terminal authorization and this approval was advisory; if none appears, **this approval is the only one there will be**
Command to execute: `<the exact command Phase 2 will run, flags included>`
When `--force-with-lease` was passed, the plan **must** show it, the `Overwrites:` line **must**
carry the measured tip, and the approval option **must** say so.
Two steps, and the second is the one this round added: approving `git push origin feat/x` is not approval of a history rewrite,
and approving a history rewrite in the abstract is not approval of destroying the particular commit
that is there.
Gate: Use AskUserQuestion with options:
- "Approve push" (or "Approve force-with-lease push to
<branch>" when the flag is set) — proceed to execute
- "Abort" — stop, do not push
When --force-with-lease was passed, ask a second and separate question first — the unshared
attestation (2026-08-21, option A of
docs/features/push-gate-optin/requests/2026-08-20-push-ci-force-with-lease-r5.md):
First establish whether this push rewrites anything at all. rules/git-workflow.md § Push
safety measures the topology, not the declared flag — "a --force-with-lease that turns out to
be an ordinary fast-forward rewrites nothing and is not asked about" — and that is the same test the
hook applies at /dev/tty. A prompt that asserts a rewrite the push does not perform trains the
operator to answer past a question that is usually wrong, which is how the one that matters gets
answered the same way:
# Bound HERE, not inherited. Phase 0 says every fence is a separate shell, and this one reached
# `"refs/heads/$BRANCH"` with nothing binding `BRANCH` in it. Unset, that refspec is
# `refs/heads/` — the exact-ref lookup returns no line, `REMOTE_TIP` is empty, and the classifier
# below reads `ASK_REASON=creation`: a push that rewrites `feat/x` skips the unshared question
# entirely. The failure is silent in the only direction that matters, because "creation" is also
# the honest answer for a genuinely new branch.
BRANCH=$(/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 --abbrev-ref HEAD)
if [[ -z "$BRANCH" ]] || [[ "$BRANCH" == HEAD ]]; then
echo "⛔ Phase 1: the branch name could not be derived here (detached HEAD, or rev-parse failed)." >&2
echo " Every classification below names refs/heads/\${BRANCH}; an empty one asks about" >&2
echo " refs/heads/ and answers 'creation'. Nothing is planned and nothing is pushed." >&2
SD0X_PUSH_CI_REFUSED=
: "${SD0X_PUSH_CI_REFUSED:?refusing — the branch name could not be derived in this fence}"
fi
# No pipe, and no `awk`. Both are deliberate, and each closes a defect this block shipped with for
# one round: a pipeline reports the status of its LAST command, so `git ls-remote … | awk …`
# exits 0 when `ls-remote` fails — measured `rc=0 tip=<>` against a nonexistent remote — which is
# byte-for-byte the "branch does not exist" reading below, and the fail-closed row underneath it
# became unreachable. A bare `awk` is a second hole in the same line: this classifier decides
# whether an attestation is collected, and an imported `BASH_FUNC_awk%%` answering it emits an
# empty tip from real output. `${x%%<tab>*}` is a parameter expansion — no command, nothing to
# shadow — and the exact `refs/heads/` refspec means at most one line comes back.
# And it probes the **push destination**, not `origin`. A remote can name two different URLs, and
# the push uses the one a probe of `origin` does not read. Measured, both mechanisms:
#
# remote.origin.pushurl set ls-remote --get-url origin -> FETCH url; push contacts pushurl
# url.<x>.pushInsteadOf, no pushurl ls-remote --get-url origin -> FETCH url; push contacts rewrite
#
# In both, `git remote get-url --push --all origin` returned exactly what the push contacted, so it
# is the oracle and `origin` is not. A probe reading the fetch URL classifies repository A as a
# creation or a fast-forward and the push then rewrites repository B — with no attestation ever
# collected for the repository that actually changed. L1 is opt-in, so that skipped question can
# be the only attestation layer there was.
#
# `--all`, never the singular form: `pushurl` is multi-valued and git pushes to every one of them,
# while `get-url --push` returns only the first — the reading that makes a fan-out look like a
# single destination. Anything other than exactly one URL is the fail-closed row, not a benign one.
# The count is a parameter expansion for the same reason the field split is: `$(...)` strips
# trailing newlines, so one URL leaves no newline and PUSH_URLS != PUSH_URL is exactly "more than
# one" — no `wc`, no `grep`, nothing an imported function can answer.
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_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 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
if [[ -z "$PUSH_URL" ]] || [[ "$PUSH_URLS" != "$PUSH_URL" ]]; then
REMOTE_TIP=
LOOKUP_FAILED=1
# Round 76, the same detector Phase 2 carries and for the same measurement: `$PUSH_URL` has already
# been through one `url.*.insteadOf` pass, and handing it to another git command applies a second.
# Where a chain exists the probe reads a repository the push never contacts, so the reading is not
# about the destination. `unknown-lookup` is the honest classification and it fails closed — the
# unshared question gets asked rather than skipped. Detector is local: `--get-url` expands 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" ]]; then
REMOTE_TIP=
LOOKUP_FAILED=1
elif 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_GRAFT_FILE=/dev/null GIT_NO_REPLACE_OBJECTS=1 git ls-remote --upload-pack=git-upload-pack -- "$PUSH_URL" "refs/heads/$BRANCH"); then
REMOTE_TIP=${REMOTE_LS%%$'\t'*}
LOOKUP_FAILED=
else
REMOTE_TIP=
LOOKUP_FAILED=1
fi
# The classifier the table below documents — executable, because a table is a reading and a
# reading cannot lose an exit status. Each arm is one expression, and `merge-base`'s status is
# captured IMMEDIATELY into its own variable: exit 1 is its answer "not contained", anything
# above it is the ABSENCE of an answer, and `if ! git merge-base …` collapses the two into
# the SAME asking branch — so an errored ancestry is reported as a *measured* rewrite. The bit
# survives; the reason does not, and the reason is what the question states.
# ASK_REASON is carried because "unknown" and "measured rewrite" are different claims and the
# question below states which one the operator is answering.
if [[ -n "$LOOKUP_FAILED" ]]; then
ASK=1; ASK_REASON=unknown-lookup
elif [[ -z "$REMOTE_TIP" ]]; then
ASK=; ASK_REASON=creation
elif ! /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 cat-file -e "${REMOTE_TIP}^{commit}" 2>/dev/null; then
ASK=1; ASK_REASON=unknown-tip
else
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" HEAD; then ANCESTRY=0; else ANCESTRY=$?; fi
# 0 contained · 1 not contained · >1 merge-base ERRORED — three readings, never two
# `case`, not `[ … -eq … ]` and not `[[ … -eq … ]]`. `[` is a builtin, so an imported
# `BASH_FUNC_[%%` function outranks it and answers every one of these tests for the caller
# (measured 2026-08-22: `[ a = a ]` returns the forged status). `[[` is a keyword and immune to
# that, but its `-eq` is arithmetic, and arithmetic is the wrong tool twice over: an empty
# operand reads as 0, so `[[ "" -eq 0 ]]` is TRUE where `[ ]` errors to false — fail-closed
# inverted — and bash evaluates arithmetic operands recursively, so a non-numeric one is
# dereferenced as a variable name. `case` is a keyword that does neither.
case "$ANCESTRY" in
0) ASK=; ASK_REASON=fast-forward ;;
1) ASK=1; ASK_REASON=rewrite ;;
*) ASK=1; ASK_REASON=unknown-ancestry ;;
esac
fi
# Report what was measured, for the reason Phase 0 step 9 gives: this fence is a shell of its
# own, and the next thing to run is a question whose wording depends on every field below.
# `ASK` alone would not be enough even if it were printed — the table's seven rows collapse to
# two values of it, and "unknown-lookup" and "rewrite" ask the operator different questions
# while agreeing that one must be asked.
# **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