14 KiB
PLAN — manual-push-mode (run A) — REVISED after challenge r1 + confirmation r2
Contract: .claude/tasks/contracts/2026-10-06-manual-push-mode-1632.md
Context
gitflow.autopush (git config, default true) already silences the post-commit/post-merge push hooks and _gitflow_delete_remote. Gap: _gitflow_push_branch (lib/gitflow.sh:78-88) only reads GITFLOW_NO_PUSH, so start/finish push even in manual mode. hooks/unpushed-guard.sh nags at every Stop regardless of mode. Doctrine says unpushed = defect, which would drive Claude to push by hand.
Challenge r1 added: (a) in manual mode a branch's upstream lags, and git branch -d checks the UPSTREAM when one is set (LRN-161), so gitflow_delete would refuse after a successful merge (rc 5, false "unmerged"); (b) git pull --ff-only … || true swallows a diverged base silently, which only auto-push used to surface; (c) _gitflow_delete_remote skipping leaves origin/<br> behind with no word; (d) skills push on their own (/capitalize STEP 5C git push origin develop, client-handover, release-candidate/tour "already on origin" claims) and settings.json prose says unpushed = defect → run C (skills) and run B (settings), see contract.
Checklist
- lib/gitflow.sh — add
_gitflow_push_off()right above_gitflow_push_branch: rc 0 whenGITFLOW_NO_PUSH=1ORgit config --bool --default true gitflow.autopushisfalse. Comment: "GITFLOW_NO_PUSH=1 (throwaway test repos) or gitflow.autopush=false (manual-push mode, human-set: work machine, foreign clone)". Call it as the first line of_gitflow_push_branch. In_gitflow_delete_remoteKEEP[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0as the first line (test repos stay silent), then replace the inline autopush line with_gitflow_push_off && { <left-in-place note, item 2>; return 0; }. Grep claim, scoped: outside the hook-emitter heredocs (_gitflow_emit_push_hook, untouched per AC7) and that one documented NO_PUSH line, no inline reader of the two flags remains in lib/gitflow.sh. - lib/gitflow.sh —
_gitflow_delete_remote: when_gitflow_push_offfires (NO_PUSH already returned above, so this is autopush=false), origin exists, andgit rev-parse -q --verify "refs/remotes/origin/$br" >/dev/nullsucceeds (no network), print to stderrgitflow: origin/<br> left in place (manual push mode) — by hand: git push origin --delete <br>; return 0 either way. Everyrev-parse --verifyprobe added by this plan ends in>/dev/null:gitflow_start's stdout is the branch name only (T11). - lib/gitflow.sh —
gitflow_delete: aftergitflow_merged_into_basepasses, check out the base that CONTAINS the branch:if git merge-base --is-ancestor "$br" "$GITFLOW_DEVELOP" 2>/dev/null; then git checkout -q "$GITFLOW_DEVELOP"; else git checkout -q "$GITFLOW_MAIN"; fi(replaces the current develop-else-main fallback at line ~195; T22j = merged into main only must stay deletable). Thengit branch -q --unset-upstream "$br" 2>/dev/null || trueBEFOREgit branch -q -d "$br". Comment citing LRN-161:-djudges against the upstream when one is set, against HEAD otherwise; the ancestor check is the real gate, so HEAD must be the containing base and the upstream must be out of the way. Keep the ≤25-logic-line budget: extract_gitflow_checkout_containing_base <br>if needed. - lib/gitflow.sh — add
_gitflow_sync_base()(≤10 lines) replacing the twogit pull --ff-only -q 2>/dev/null || truelines (gitflow_start, _gitflow_merge_into):_gitflow_timeout git pull --ff-only -q >/dev/null 2>&1 && return 0; then ifgit rev-parse -q --verify '@{u}'succeeds andgit rev-list --count HEAD..@{u}> 0 → stderrgitflow: <branch> is behind origin/<branch> by <n> and cannot fast-forward — reconcile by hand (git pull, then push); always return 0 (never blocks). Silent when: no upstream (@{u}unresolvable), or offline with no RECORDED divergence (HEAD..@{u} = 0). Offline after an earlier fetch recorded the base as behind → still warns (the recorded fact is true). The@{u}probe ends in>/dev/null. - hooks/unpushed-guard.sh — mode detection after
br=:raw=$(git config gitflow.autopush);manual=0;[ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ] && manual=1;invalid=0;[ -n "$raw" ] && ! git config --bool gitflow.autopush >/dev/null 2>&1 && invalid=1. Stop + manual →exit 0immediately (BDR-087: message only, and the user chose silence at Stop). ONE clause function kept (unpushed_clause), mode-aware: auto path unchanged byte for byte (T1–T9). Manual path:n=$(git rev-list --count --branches --not --remotes)(ALL local branches, not just HEAD — a session usually starts on develop after a local finish);n -eq 0→ empty (so a freshstartbranch with 0 commits is silent, LRN-091); else list the ahead branches viagit for-each-ref --format='%(refname:short)' refs/headsfiltered ongit rev-list --count <b> --not --remotes> 0, joined by,→ clause<n> commit(s) not on origin (<b1>, <b2>), push by hand: git push -u origin <first listed ahead branch>(never HEAD's name: HEAD may hold no unique commit); no origin remote →no 'origin' remote, <n> commit(s) on this disk only. Prefix chosen at the single emit site: auto⚠ unpushed work:, manualℹ manual push mode:. SessionStart keeps the; <d> uncommitted change(s) in <cwd>clause in both modes (dirty-only manual →ℹ manual push mode: <d> uncommitted change(s) in <cwd>).invalid=1→ SessionStart appends; gitflow.autopush='<raw>' is not a boolean, treated as auto (pushes run). Header comment: +3 lines on manual mode. Functions ≤25 logic lines: extractahead_branches(). - CLAUDE.global.md — gitflow section: replace the two sentences
Foreign clone: \git config gitflow.protect false` / `gitflow.autopush false`; `GITFLOW_NO_PUSH=1` only for throwaway test repos. A branch ahead of its upstream is a defect, not a state.(lines 186-188) with ONE statement:Human-set opt-outs: `git config gitflow.protect false` (foreign clone) and `gitflow.autopush false` = manual-push mode (work machine): branches, commits and local merges run as usual, nothing is pushed, Claude never pushes (`/close` included) unless the user asks; `GITFLOW_NO_PUSH=1` only for throwaway test repos. Outside manual mode a branch ahead of its upstream is a defect, not a state.Line 229 bullet: appendManual-push mode (above) is the one exception.` Net +3 to +4 lines (312 → ≤316, budget 320). No heading or bold label changes (doctrine-citers census unaffected). - lib/gitflow-test.sh — NEW isolated block after T18g, before T19:
echo "T18m — manual-push mode: gitflow.autopush=false (human-set) → nothing pushed, finish still deletes";newrepo manual; echo a>a; hookon; gitflow_init; bare origin;git push -q -u origin main develop(-u: develop MUST track origin/develop for T18l/T18n — gitflow_init creates develop untracked, and manual mode never sets it); precondition chkT18m0 develop tracks origin/develop:git rev-parse -q --verify 'develop@{u}' >/dev/null;git config gitflow.autopush false. ORDER inside the block: T18i, T18j, T18k, T18o, T18n, T18l (T18l fetchesointo refs/remotes/origin/develop and nothing reconciles it, so an offline test after it would warn — T18n runs first, while develop is ahead-only). T18i:gitflow_start feature manual→git rev-parse --verify -q refs/heads/feature/manualAND! git ls-remote --exit-code --heads origin feature/manual. T18j:echo m>m.txt; git add m.txt; git commit -q -m m;# shellcheck disable=SC2034+dev_remote_before=$(git -C "$bare" rev-parse develop);fin_rc=0; gitflow_finish >/dev/null 2>&1 || fin_rc=$?→ rc 0,Merge feature/manual into developin local develop log, origin develop == dev_remote_before, branch deleted. T18k (lagging upstream):git config gitflow.autopush true; gitflow_start feature lag(pushed -u);git config gitflow.autopush false; echo l>l.txt; git add l.txt; git commit -q -m l;lag_out=$(gitflow_finish 2>&1); lag_rc=$?→ rc 0,! git rev-parse --verify -q refs/heads/feature/lag, origin/develop still == dev_remote_before,lag_outcontainsleft in place,git ls-remote --exit-code --heads origin feature/lagstill exists. T18o (NO_PUSH stays silent on the remote copy):git config gitflow.autopush true; gitflow_start feature np(pushed -u);git config gitflow.autopush false; echo n>n.txt; git add n.txt; git commit -q -m n;np_out=$(GITFLOW_NO_PUSH=1 gitflow_finish 2>&1); np_rc=$?→ rc 0, branch deleted,np_outdoes NOT containleft in place, origin/feature/np still exists. T18n (offline, no recorded divergence → silent):git remote set-url origin /nonexistent/x.git; off2_out=$(gitflow_start feature off2 2>&1)→ does NOT containbehind,git rev-parse --verify -q refs/heads/feature/off2;git remote set-url origin "$bare"; git checkout -q develop. T18l (diverged base warning):other="$WORK/manual-other"; git clone -q "$bare" "$other"; in other: hooks off, identity,git checkout -q develop; echo o>o.txt; git add o.txt; git commit -q -m o; git push -q origin develop; local (on develop, ahead by the local merges):div_err="$WORK/div.err"; div_out=$(gitflow_start feature div 2>"$div_err")→ stdout[ "$div_out" = feature/div ](no SHA leak), stderrgrep -q 'behind origin/develop' "$div_err", branch exists. Every*_out/*_rc/dev_remote_beforeread only inside chk evals gets# shellcheck disable=SC2034on the line above (lib/gitflow-test.sh idiom, lines 296/344/353). - lib/tests/unpushed-guard.test.sh — append before the PASS line (repo has origin, upstream on main/master, in sync after T8's push; tree dirty from T7/T8 →
git checkout -q -- afirst):git config gitflow.autopush falseT10 manual + clean + in sync: SessionStart →silent; Stop →silent. T11 one local commit on HEAD, plusgit branch side HEAD; git checkout -q side; echo s>s; git add s; git commit -q -m s; git checkout -q -(second ahead branch): Stop →silent; SessionStart → containsmanual push mode,2 commit(s),side, and NOTunpushed work. T12 fresh branch with no upstream and 0 extra commits (git checkout -q -b fresh): SessionStart → still reports the 2 commits (they are reachable from other branches; count is repo-wide) — assert2 commit(s); thengit checkout -q -. T13 dirty tree only (push the two commits by hand in the test:git push -q origin HEAD side, thenecho d>>a): SessionStart → containsmanual push modeanduncommitted, NOTcommit(s) not on origin; Stop → silent.git checkout -q -- a. T14 invalid value:git config gitflow.autopush flase; one more local commit; SessionStart → containsnot a booleanANDunpushed work(treated as auto); Stop → contains1 commit(s)(auto behaviour). T15 toggle back:git config --unset gitflow.autopush; Stop → contains1 commit(s)(positive control, auto path intact). T16 no-origin manual (LAST, nothing restored after):git config gitflow.autopush false; git remote remove origin; SessionStart → containsmanual push modeandno 'origin' remote; Stop → silent.
Edge cases
gitflow.autopushset--globalon the work machine:git config --bool --default truereads the merged value → every repo, no code difference. Toggle is human-set (static deny ongit config gitflow.*, BDR-095 c); the deny is prefix-based and run B widens it (git config * gitflow.*,git -c gitflow.*,GIT_CONFIG_COUNT=*).- Garbage value:
--boolfails → auto mode (fail-open toward pushing, pre-existing in the emitted hooks, which run A may not edit — AC7); the guard now SAYS so at SessionStart. Fail-closed is a run B question (hook emitters). - Count scope: manual mode counts every local branch (
--branches --not --remotes); auto mode keeps the current-branch count (unchanged contract, T5/T6). - Diverged base: warning only, never blocks
start/finish; the user reconciles by hand. No upstream → silent; offline with no recorded divergence → silent; offline after a fetch already recorded the base as behind → warns (true fact). gitflow_deletenow ends on the base that contains the branch (main for a main-only merge, develop otherwise) instead of always develop; no test asserts HEAD after a delete.- No emitter (
_gitflow_emit_*) touched → T19 drift gate needs no regeneration. - Deployment order (contract):
gitflow.autopush falsemust not be set on the work machine before runs B (push-guard, settings) and C (skills that push) are merged; until then/closeSTEP 5C still pushes develop.
Disposition (STEP 0.6 + challenge r1)
- honors BDR-095 by extending the existing
gitflow.autopushopt-out (amendment c), not a new key. - honors BDR-100 / LRN-113 by (1) one shared predicate
_gitflow_push_offfor every lib push site, (2) surface grep widened togrep -rn "git push\|autopush\|GITFLOW_NO_PUSH" lib hooks githooks skills agents settings.json CLAUDE.global.md— the skill/agent/settings hits are assigned to runs B and C in the contract, not silently dropped. - honors LRN-161 by
--unset-upstreambefore-d(the ancestor check is the gate;-dmust judge against HEAD) and by re-reading the--ff-onlypulls (now warn on divergence, wrapped in_gitflow_timeout). - honors LRN-104 by locking every new output string in a test: manual line (T11), dirty-only (T13), invalid value (T14), no-origin (T16), "left in place" (T18k) and its NO_PUSH silence (T18o), "behind origin" (T18l) and its offline silence (T18n), stdout purity of
start(T18l). - honors LRN-091 / LRN-047 by silence at Stop and at
n=0in manual mode. - BDR-087: Stop hook stays systemMessage-only; no control flow.