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

49 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.