Run D1 of manual-push mode (BDR-114). `git config --bool --default true
gitflow.autopush` only covered a MISSING key: an unparseable value made
git die with empty output, the `= false` test failed, and every push ran
again. A typo on a work machine silently re-enabled the pushes it was
meant to stop.
- lib/gitflow.sh: `_gitflow_push_off` reads the mode through the lib
verb (`push-mode`); anything but `auto` is push-off, and the verb's
stderr line names an invalid value during start/finish.
- Emitted post-commit/post-merge hooks (POSIX sh, standalone): push only
when the key reads `true` or is unset; `false` exits quietly; any
other result prints one stderr line ("NOT pushed, treated as manual
push mode") and exits 0. Mirrors gitflow_push_mode.
- .githooks/ and githooks/ regenerated files-only through `emit-hook`
(no config read or write; .git/config hash unchanged).
- hooks/unpushed-guard.sh: mode from the lib verb (absolute lib path
resolved before any cd, no temp file); anything but auto is manual;
the SessionStart line names an invalid or unreadable value.
- Tests: gitflow-test T18q block (invalid → start, hook and finish push
nothing and say so; `true` → the hook pushes; emitted hook is
POSIX-clean), unpushed-guard T14 rewritten.
Closes the non-gap observations the gates left on runs C1/C2:
- capitalize STEP 5C/6: heading no longer says "+ push"; the --no-push
fact read is its own paragraph and scoped to that path; the
auto-persisted line requires finish rc 0 AND ahead = 0; rc 5/2/6
(merged, branch not deleted) still report the push state; the
"not on origin" line carries the once-a-remote-exists hint.
- gitflow.sh push-mode: the raw config value echoed on stderr is reduced
to printable characters (LC_ALL=C, BSD tr safe) and capped at 64.
- gitflow-test.sh exports the hermetic git config env in the file, so a
bare run on a global-manual machine stays green.
- client-handover-writer: the branch allowlist refuses a leading dash.
Run C2 of manual-push mode (BDR-111/BDR-112). The four flows that pushed
on their own, or claimed the branch was on origin, now read the truth
after the fact and hand the user the exact command:
- client-handover-writer: the "Push to origin now?" question and its
push block are gone (the hooks had already pushed in auto-push mode;
push-guard denies it in manual mode). A reusable PUSH STATE READ
(branch, origin probe, `git rev-list --count origin/<br>..<br>`, the
verb only to word the reason) runs after commit-change, at the top of
the deploy pause, after "Deployed" and before each end report. The
branch name is validated against an allowlist before it is placed in
any command or hint (a hostile branch name is otherwise a shell
injection). Pending → the user pushes BEFORE the deploy pause; the
deploy brief says "after your push". `Push:` line in both reports.
- release-candidate STEP 6: two ahead counts + the verb; anything other
than auto with both counts 0 prints one user command
`! git push --atomic origin main develop v<X.Y.Z>` and stops; the tag
gate stays for auto mode; `hold` notes --follow-tags; version regex.
- release-executor: push claims qualified (auto-push mode, best effort).
- tour: mode-agnostic rule; STEP 3 reads one `git -C <project>` fact per
project (suffix-aware branch, --remotes=origin, origin probe) and the
summary row says on origin / local only with the user command.
Run C1 of manual-push mode (BDR-111/BDR-112).
- lib/gitflow.sh: `gitflow.sh push-mode` prints auto | manual | invalid
(rc 0; an invalid value is named on stderr). It is the one reader a
skill may call: the bare `git config … gitflow.*` read is denied to
Claude since run B. Ignores GITFLOW_NO_PUSH by design (documented).
- skills/capitalize/SKILL.md STEP 5C: the explicit `git push origin
develop` is gone — `finish` has pushed develop itself since BDR-095,
mode-aware since run A. 5C is now three separate read-only calls
(finish; push-mode; `git rev-list --count origin/develop..develop`)
and prose outcomes keyed on the real ahead count: pushed / manual push
mode, you push / not on origin / push FAILED / invalid value named,
plus a finish-failure outcome (merge vs delete rc distinguished).
STEP 6 closing lines and the recap carry every outcome; the
`--no-push` line reads the branch's own ahead count ("this disk only"
only when true). Invariant: no `git push` inside any Bash call; the
user hints are prose.
- skills/close/SKILL.md, lib/gitflow-aiguillage.md: "push" claims
qualified "in auto-push mode".
- lib/gitflow-test.sh T11b: six cases for the verb (default, true,
false, non-boolean with stderr + rc 0, corrupt config, usage).
Polish items from the gates are listed in TODO.md (C1 polish).
Hardening after the security gate on a2ac018 (3 MEDIUM, all closed and
re-measured):
- dir tokens are deduplicated and capped: more than 20 distinct cd/-C
targets in one command denies before any git fork (20000 tokens: 0.15 s
against the 10 s hook timeout that used to turn a flood into an allow)
- a git or cd failure while reading gitflow.autopush denies instead of
reading as auto (git absent, usage error, unenterable dir); the key
being unset is the only "auto" answer; the decision is recorded only
after one candidate was evaluated cleanly, else the EXIT trap denies
- cd/pushd/-C targets that follow a quote or backtick (bash -c '…') are
extracted; quote characters are excluded from unquoted tokens
User decision (contract, gated): both fail-closed cases also fire in
auto mode on such pathological commands; silence in auto mode holds for
every ordinary push. Header limits list the residual misses (quotes or
backslashes inside a token, cumulative relative cd, unparseable payload)
backed by the soft_deny rule. Tests: 71 checks (T48–T50b added).
Run B of manual-push mode (BDR-111). With `gitflow.autopush false`
nothing stops Claude from typing `git push` itself: the `ask` tier is
inert under auto mode. This adds the mechanical block the user chose.
- hooks/push-guard.sh (PreToolUse, matcher Bash|Monitor, timeout 10):
detects a push in the command text (strict, quote-stripped loose and
alias patterns; backslash-newline folded in bash, BSD sed/grep only),
reads gitflow.autopush in the payload cwd and in every literal -C/cd
dir the command names (global config counts outside a repo), denies
with the documented JSON form and a reason that tells the user to run
the command with `!`. Unparseable value = manual (fail closed); once
a push is detected an EXIT trap emits a static deny on any internal
error. jq missing = one stderr warning, allow (sibling-hook policy).
- settings.json: hook wiring; 18 deny entries closing the write forms
of the human-only toggle (any `git … config` spelling, section
removal/rename, `-c`, config env overrides, direct edits of git config
files); one soft_deny on pushing in manual mode in any form, with no
per-turn clearance; the routing-around rule names hook refusals.
- hooks/session-start.sh: `🔒 push : manual (autopush=false) — ! git push`
banner line when the key reads false (padding in bytes).
- lib/tests/push-guard.test.sh: 61 checks (push forms, over-blocks,
invalid value, global key, fail-closed trap, no-jq, wiring, banner).
Known limits are listed in the hook header; the soft_deny rule is the
backstop. Run C (skills that push on their own) and run D (fail-closed
readers everywhere) follow. Do not enable manual mode at work before C.
`gitflow.autopush false` (human-set git config) now means "nothing is
pushed" end to end, not only in the post-commit/post-merge hooks:
- lib/gitflow.sh: `_gitflow_push_off` is the single reader of
GITFLOW_NO_PUSH / gitflow.autopush for the lib's push sites; `start`
and `finish` stop pushing in manual mode. `gitflow_delete` checks out
the base that contains the branch and drops a lagging upstream before
`git branch -d` (LRN-161: `-d` judges against the upstream when set).
Skipped remote deletes say `left in place`; `_gitflow_sync_base`
replaces the silent `pull --ff-only || true` and warns when a base is
behind origin and cannot fast-forward.
- hooks/unpushed-guard.sh: manual mode is silent at Stop and gives one
`ℹ manual push mode:` line at SessionStart counting every local
branch; an unparseable value is named and treated as auto.
- CLAUDE.global.md: manual-push mode doctrine, "ahead = defect" scoped
to auto mode.
- Tests: gitflow-test T18m block (T18m0, T18i-T18o, 7 cases),
unpushed-guard T10-T16.
Follow-ups (TODO.md): run B push-guard hook + settings deny widening +
banner; run C skills that push on their own (/close STEP 5C, …).
Do not enable manual mode on the work machine before B and C land.
The suite and seven libs assumed a GNU userland: `cmd | grep -q` under
pipefail (grep exits at the first match, the producer takes SIGPIPE,
rc 141 → 15 false "merged into" FAILs in gitflow-test), `sed -i` with no
suffix, `wc -l` padding compared as a string, `stat -c`, `touch -d`,
`realpath -m` (gstack-links refusal never fired), bare `timeout` off the
sanitized PATH (design gate READY BUT UNVERIFIED), `grep -oP` (update-all
emptied the plugin list). Thirteen suites were red on this machine.
Portable forms on the native userland of both OS: producer captured out
of the pipeline, `sed -i.bak` in tests and a temp-sibling `sed_profile`
on the user dotfile, `tr -d ' '`, python3 perms, `touch -t`, a
`realpath -m` emulation that refuses `..`, perl `alarm` for the 15 s
bound, `sed -n` token extraction. Regression tests: gstack-links T4b,
doctrine-citers Alphabet/Alpha flip, profile-set-managed T18 (failing CLI
no longer aborts `set`), new portability-census suite over the tracked
shell files. Linux run deferred (see TODO).
The literal deny patterns miss spellings such as `npm i <pkg> -g`.
The classifier entry covers every form and asks for a vetting summary
(publisher, age, downloads, install scripts, advisories) first.