Files
claude/.claude/tasks/plans/2026-10-06-manual-push-mode-1632.md
T

14 KiB
Raw Blame History

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 when GITFLOW_NO_PUSH=1 OR git config --bool --default true gitflow.autopush is false. 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_remote KEEP [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0 as 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_off fires (NO_PUSH already returned above, so this is autopush=false), origin exists, and git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null succeeds (no network), print to stderr gitflow: origin/<br> left in place (manual push mode) — by hand: git push origin --delete <br>; return 0 either way. Every rev-parse --verify probe added by this plan ends in >/dev/null: gitflow_start's stdout is the branch name only (T11).
  • lib/gitflow.sh — gitflow_delete: after gitflow_merged_into_base passes, 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). Then git branch -q --unset-upstream "$br" 2>/dev/null || true BEFORE git branch -q -d "$br". Comment citing LRN-161: -d judges 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 two git pull --ff-only -q 2>/dev/null || true lines (gitflow_start, _gitflow_merge_into): _gitflow_timeout git pull --ff-only -q >/dev/null 2>&1 && return 0; then if git rev-parse -q --verify '@{u}' succeeds and git rev-list --count HEAD..@{u} > 0 → stderr gitflow: <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 0 immediately (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 fresh start branch with 0 commits is silent, LRN-091); else list the ahead branches via git for-each-ref --format='%(refname:short)' refs/heads filtered on git 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: extract ahead_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: append Manual-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 chk T18m0 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 fetches o into 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/manual AND ! 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 develop in 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_out contains left in place, git ls-remote --exit-code --heads origin feature/lag still 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_out does NOT contain left 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 contain behind, 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), stderr grep -q 'behind origin/develop' "$div_err", branch exists. Every *_out/*_rc/dev_remote_before read only inside chk evals gets # shellcheck disable=SC2034 on 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 -- a first): git config gitflow.autopush false T10 manual + clean + in sync: SessionStart → silent; Stop → silent. T11 one local commit on HEAD, plus git 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 → contains manual push mode, 2 commit(s), side, and NOT unpushed 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) — assert 2 commit(s); then git checkout -q - . T13 dirty tree only (push the two commits by hand in the test: git push -q origin HEAD side, then echo d>>a): SessionStart → contains manual push mode and uncommitted, NOT commit(s) not on origin; Stop → silent. git checkout -q -- a. T14 invalid value: git config gitflow.autopush flase; one more local commit; SessionStart → contains not a boolean AND unpushed work (treated as auto); Stop → contains 1 commit(s) (auto behaviour). T15 toggle back: git config --unset gitflow.autopush; Stop → contains 1 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 → contains manual push mode and no 'origin' remote; Stop → silent.

Edge cases

  • gitflow.autopush set --global on the work machine: git config --bool --default true reads the merged value → every repo, no code difference. Toggle is human-set (static deny on git 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: --bool fails → 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_delete now 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 false must not be set on the work machine before runs B (push-guard, settings) and C (skills that push) are merged; until then /close STEP 5C still pushes develop.

Disposition (STEP 0.6 + challenge r1)

  • honors BDR-095 by extending the existing gitflow.autopush opt-out (amendment c), not a new key.
  • honors BDR-100 / LRN-113 by (1) one shared predicate _gitflow_push_off for every lib push site, (2) surface grep widened to grep -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-upstream before -d (the ancestor check is the gate; -d must judge against HEAD) and by re-reading the --ff-only pulls (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=0 in manual mode.
  • BDR-087: Stop hook stays systemMessage-only; no control flow.