Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c3dc8fbb | ||
|
|
afd6073371 | ||
|
|
e6cccc1740 | ||
|
|
2fc88304ac | ||
|
|
fa67664bac | ||
|
|
4d81f5a8fc |
@@ -1364,3 +1364,10 @@ Branch feature/user-writing-web-rules, UNMERGED (human gate).
|
||||
- **Alternatives rejected**: prepend coreutils/gnu-sed to PATH in Makefile+hooks; `grep X >/dev/null` (GNU grep treats /dev/null stdout like `-q`, race stays); broad `| grep -q` census (119 hits, fixtures, false confidence).
|
||||
- **Gates**: 3 lenses (FATAL 6 / CONCERNS 4 / FATAL 2) + confirmation CONCERNS(2), all closed r3; GATE 0 MET 8/8 ×2; verifier CONFORME 9/9; security PASS (1 MEDIUM hardened). Linux run `[deferred]`.
|
||||
- **Refs**: contract `.claude/tasks/contracts/2026-10-06-macos-portability-1105.md`, plan `.claude/tasks/plans/2026-10-06-macos-portability-1030.md`, commit 0efdff0 (bugfix/macos-portability), [[BLK-026]], [[LRN-189]], [[LRN-190]].
|
||||
|
||||
## BDR-111 — Manual-push mode = `gitflow.autopush false` end to end, no new key [accepted] (2026-10-06)
|
||||
- **Decision**: user need (work machine): same flow, branches + commits + local merges, nothing pushed, push only by hand. Reuse existing human-set `gitflow.autopush` (static deny on `git config gitflow.*`), no `gitflow.mode`. Run A: lib `_gitflow_push_off` single reader for `start`/`finish`/`delete_remote`; `gitflow_delete` checks out CONTAINING base + `--unset-upstream` before `-d`; `_gitflow_sync_base` warns "behind origin, cannot fast-forward" instead of silent `|| true`; `unpushed-guard` silent at Stop, one `ℹ manual push mode:` SessionStart line counting ALL local branches; doctrine line CLAUDE.global.md. Run B (queued): PreToolUse `hooks/push-guard.sh` denies `git push` when autopush=false (user: block, `! git push` only), widen `gitflow.*` deny (`git config * gitflow.*`, `git -c`, `GIT_CONFIG_COUNT=`), settings prose, banner, fail-CLOSED on unparseable value in every reader at once. Run C (queued): skills that push alone (`/capitalize` STEP 5C `git push origin develop`, client-handover, release-candidate/tour claims). ORDER: no autopush=false at work before B+C.
|
||||
- **Why**: `autopush false` already silenced hooks + remote delete; lib push sites ignored it (bug). Merge is NOT the user's concern (local merge wanted), push is. Fail-open on invalid value kept in A for consistency with untouched hook emitters (AC7).
|
||||
- **Alternatives rejected**: new `gitflow.mode auto|manual` (duplicates autopush); tty-only lock on `finish` (user wants local merges); `-D` after ancestor gate (statically denied form, reviewers' red flag); fail-closed in lib only (hooks would still push → inconsistent).
|
||||
- **Gates**: 3 lenses CONCERNS(1/2/2) + confirmation FATAL(4) → r2 fixes (T22j containing base, `-u` fixture, T18n before T18l); feater ×2; GATE 0 7/8 (AC6 = env red); verifier ECARTS(1) = AC6 only; security PASS ×2 (1 MEDIUM fail-open → run B).
|
||||
- **Refs**: contract `.claude/tasks/contracts/2026-10-06-manual-push-mode-1632.md`, plan `.claude/tasks/plans/2026-10-06-manual-push-mode-1632.md`, commit 2fc8830 (feature/manual-push-mode, UNMERGED). Extends [[BDR-095]] (c); [[LRN-161]], [[LRN-191]], [[LRN-192]], [[LRN-193]], [[BLK-022]].
|
||||
|
||||
@@ -567,3 +567,5 @@ rules:
|
||||
- Reconcile 2026-10-06: TODO:15 + TODO:10 closed (npm soft_deny covers), TODO:892 re-verified open; 6 BLK external/open unchanged; BLK-018 due at the running release.
|
||||
- /prune-memory 2026-10-06: A none, D none; B BLK-019+020 → BLK-028 (merge); C bounded 37 entries ≥9% filler → 37 edited, 1 untouched (LRN-075 all-negation), cuts 1-11% only (negation guard protects "X not Y" lessons); fidelity + index OK. 110 bloated entries left for a later run.
|
||||
- /doc global audit 2026-10-06 (opus): 45 items, 6 docs. Applied 34 (24 AUTO + 9 HUMAN drafts + clone URL → Gitea): README components/slash/flow, Makefile help (11 profiles), USAGE /health→make doctor + GSD 3.0.0, ARCHITECTURE layout, MIGRATION retitled + "Upgrading to 2.0.0", SETTINGS package-install guard, CHANGELOG SemVer + default model + upgrade pointer → c6fb2e4. 10 deferred logged in TODO (LICENSE, Known-residual vs release, README restructure, USAGE narrative, templates/settings.json ask inert).
|
||||
- Release 2.0.0 cut (user go x3: release, tag push, MIT): bugfix merged 370f35a; develop merged into release/2.0.0 (b47bba7, CHANGELOG conflict resolved: upgrade pointer under [2.0.0]); suite 46/46 green on release; 9ef66e2 MIT LICENSE + README License + Linux residual reworded; gitflow finish by release-executor (BLK-018 did not fire) -> main 4093cca, tag v2.0.0 pushed on user go. Open after release: Linux make test (TODO), make plugin + .env on this machine (BLK-027).
|
||||
- /feat manual-push-mode run A (user: work machine, same flow, never push alone): `gitflow.autopush false` = manual-push mode end to end. Plan challenged 3 lenses + 1 confirm → 2 MAJOR (`-d` re-arms on lagging upstream LRN-161; /close STEP 5C pushes develop) + 3 BLOCKER in r2 (T22j regress, develop untracked in fixture, T18l/T18n order) all closed by named changes. feater ×2 (gaps: pipefail flake `git log | grep -q`, 9 SC2034 suppressions removed), GATE 0 7/8, verifier ECARTS(1) = AC6 env red only (design-tool-gate, 21st CLI present, same on develop fa67664), security PASS ×2. Commit 2fc8830 on feature/manual-push-mode, UNMERGED. Runs B (push-guard hook, settings deny widening, banner) + C (skills that push) queued in TODO; do NOT enable manual mode at work before B+C.
|
||||
|
||||
@@ -1700,3 +1700,15 @@ Rule: when editing a doctrine file under structure locks, grep the test's lock s
|
||||
## LRN-190 — Oracle hygiene: wrapped lines, baselines, no rm -rf via variable
|
||||
- **Context**: GATE 0 criterion 4 NOT-MET while code correct: executor wrapped `grep -q … \` + `<<<"$(…)"` at 80 cols (my own style rule), single-line regex missed it. Criterion 7 `shellcheck` bare would fail on pre-existing info notes outside Health Stack scope. Criterion 2 CHECK held `rm -rf "$d"` (destructive-tools rule), executor's copy refused by permission system.
|
||||
- **Apply**: join continuations first (`sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n[[:space:]]*/ /g'`); lint criteria compare counts against base ref (`git show base:file | shellcheck -`); planted fixtures cleaned with `rm -f file; rmdir dir`. Oracle edits after a red floor logged in CLARIFICATIONS as "oracle maintenance", criterion text never loosened. Extends [[LRN-188]].
|
||||
|
||||
## LRN-191 — `cmd | grep -q` under pipefail reintroduced one commit after BDR-110 banned it
|
||||
- **Context**: feater wrote T18j as `git log develop --format=%s | grep -q …` in a `set -uo pipefail` suite. Green alone ×3, red once under load (3 suites + agents in parallel): `grep -q` exits early → SIGPIPE on `git log` → rc 141 → `&&` chain fails. Demo: `seq 1 200000 | grep -q 1` fails 300/300 under pipefail, `grep -q 1 < <(seq …)` 0/300.
|
||||
- **Apply**: [[BDR-110]] form `grep -q PAT < <(cmd)` in tests, `<<<"$(cmd)"` in prod. Census can't catch it by text (BDR-110 chose no rule) → executor brief + verifier lens must name it: "no multi-line producer piped into `grep -q`". A flake seen ONCE under load is a bug, not noise: reproduce the mechanism before calling it flaky. Single-write `printf '%s' "$v" | grep -q` is safe.
|
||||
|
||||
## LRN-192 — Turning auto-push off re-arms `git branch -d`'s upstream check (LRN-161 inverted)
|
||||
- **Context**: [[LRN-161]]: auto-push kept upstream in sync → `-d` a no-op guard. Manual-push mode: upstream lags → `-d` REFUSES a branch merged into HEAD ("not yet merged to origin/<br>") → `finish` merges then rc 5 false "unmerged". First fix `--unset-upstream` then `-d` regressed T22j (hotfix merged into main only, HEAD=develop → `-d` refuses).
|
||||
- **Apply**: after the explicit ancestor gate, checkout the base that CONTAINS the branch (`merge-base --is-ancestor br develop` ? develop : main), `--unset-upstream`, then `-d`. Any change to push/upstream config → re-read every `-d`, `--ff-only`, `@{u}` site AND the tests that assume upstream in sync (T22j class). Tests: gitflow-test T18k, T22j.
|
||||
|
||||
## LRN-193 — A revised plan gets a fresh challenger, not a re-read: r2 found 3 BLOCKERs inside r1's fixes
|
||||
- **Context**: manual-push-mode plan. r1 (3 lenses) → 2 MAJOR, I rewrote 5 checklist items. Confirmation pass (1 fresh correctness challenger on the REVISED file) → FATAL(4): my `--unset-upstream` fix broke T22j; my T18l fixture never set develop's upstream (`push` without `-u`, init creates develop untracked); my T18n/T18l order made offline silence vacuous. All three were in text I had just written and re-read.
|
||||
- **Apply**: `challenge-plan.md` "re-challenge once if materially changed" is load-bearing, never skip it to save a dispatch. Brief the confirmation challenger on the NEW mechanics explicitly (state machine of new tests, fixture preconditions, ordering). Fixes to tests need the same fixture trace as the code (`-u`, upstream, what an earlier test leaves behind).
|
||||
|
||||
@@ -2055,3 +2055,13 @@ dans un runner; capitalize reste main-loop.
|
||||
- [ ] P33 USAGE token figures ("Budget Pro ~11k tokens/5h", per-pattern) have no source in code — verify or drop
|
||||
- [ ] P34 USAGE + agents/plugin-advisor.md "gstack ON/OFF", "context7 ON" vocabulary — gstack is per-profile, ctx7 is a CLI; move both together
|
||||
- [ ] P41 templates/settings/settings.json: `permissions.ask` entries (npx, docker rm, make deploy, psql…) inert under defaultMode auto → config fix, not doc
|
||||
|
||||
## manual-push-mode (2026-10-06, /feat × 3)
|
||||
- [x] run A — `gitflow.autopush=false` honoured by `_gitflow_push_branch`, quiet unpushed-guard, doctrine line; plan `.claude/tasks/plans/2026-10-06-manual-push-mode-1632.md` → commit 2fc8830 on feature/manual-push-mode; verifier ECARTS(1) = AC6 only (design-tool-gate env red, pre-existing on develop) → human waiver; merge human-gated
|
||||
- [ ] run B — `hooks/push-guard.sh` PreToolUse (deny `git push` in manual mode) + test + settings.json (hook wiring, widen `gitflow.*` deny: `git config * gitflow.*`, `git -c gitflow.*`, `GIT_CONFIG_COUNT=*`; environment prose ~480/~499) + session-start banner push mode
|
||||
- [ ] run C — skills that push on their own, gate on `gitflow.autopush`: capitalize STEP 5C (`git push origin develop`), client-handover SKILL:48 + agents/client-handover-writer.md:586, release-candidate:96 + tour:273 "already on origin" claims
|
||||
- [ ] run B also: fail-CLOSED on an unparseable `gitflow.autopush` value in every reader at once (lib `_gitflow_push_off`, the two emitted push hooks, unpushed-guard) — run A keeps fail-open for consistency with the untouched emitters (security gate MEDIUM, 2026-10-06); `--end-of-options`/`--` on refname args and `printf %q` in copy-paste hints (LOW); `gitflow_delete`: check `_gitflow_checkout_containing_base` rc before `--unset-upstream` (LOW, 2nd gate)
|
||||
- [ ] ORDER: do not set `gitflow.autopush false` on the work machine before B + C are merged (until then `/close` still pushes develop)
|
||||
|
||||
## test hermeticity (2026-10-06, found during manual-push-mode run A)
|
||||
- [ ] `lib/tests/design-tool-gate.test.sh` reds on any machine with the 21st CLI installed ("FAIL precondition: system-wide 21st present, CLI_ABSENT case not hermetic") — pre-existing on develop (fa67664), independent of the diff. Make the CLI_ABSENT case hermetic (PATH shim / stubbed probe) so `make test` is green on a design-profile machine. Until then full-suite oracles (`make test` exit 0) cannot be MET here.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# CONTRACT — manual-push-mode
|
||||
- date: 2026-10-06 | flow: feat | branch: feature/manual-push-mode (run A of 2; run B = push-guard hook + banner)
|
||||
- status: active
|
||||
|
||||
## REQUEST (verbatim — IMMUTABLE)
|
||||
User (fr): "est-ce qu'on a un moyen de regler le flow automatique de git. Activer / desactiver le fait que ca pousse tout seul, que ca ne merge pas tout seul etc. Q`'il y ai forcement la demande ou l'authorisation humaine pour cela ? Il faut pouvoir le toggle on ou toggle off"
|
||||
User (fr): "ok donc ou sera la cle gitflow.mode ? Pour expliaquer, c'est pour pouvoir utiliser la config au taff. Il faut tout faire pareil, juste rien push seul. Mais faire les branches locale,ment, faire les commits localements etc. Juste il faut pas push. seulement manuel"
|
||||
/feat args: Manual-push mode via the existing `gitflow.autopush` git-config key (no new key). Scope: (1) lib/gitflow.sh `_gitflow_push_branch` must honour `gitflow.autopush=false` like the hooks and `_gitflow_delete_remote` do (today `start`/`finish` push regardless, bug); (2) guard-bash: when `git config --bool --default true gitflow.autopush` is false in the cwd repo, deny any `git push` from Claude with a message pointing to `! git push` (human runs it); (3) hooks/unpushed-guard.sh: in manual mode, SessionStart emits "push manuel : N commit(s) à pousser" info only, Stop emits nothing; (4) hooks/session-start.sh banner shows push mode (auto/manual); (5) CLAUDE.global.md: one line in the gitflow section, autopush=false → unpushed work is expected, never push unless the user asks; (6) tests updated (guard-bash.test.sh, unpushed-guard.test.sh, gitflow-test.sh). User decisions already taken: mechanical block of git push (chosen), guard info at SessionStart only (chosen).
|
||||
|
||||
## CLARIFICATIONS
|
||||
Q: Mechanical block of `git push` when autopush=false? / A: yes, block (user, pre-flow) [gated 2026-10-06]
|
||||
Q: unpushed-guard behaviour in manual mode? / A: info at SessionStart only, silent at Stop (user, pre-flow) [gated 2026-10-06]
|
||||
Q: scope split — request spans ~10 files (> /feat max 5) / A: run A (this contract) = items 1, 3, 5 + their tests; run B = items 2, 4 as `hooks/push-guard.sh` + test + settings.json wiring + banner. `hooks/guard-bash.sh` does not exist (BLK-022), so item 2 lands in a new dedicated hook, and `guard-bash.test.sh` (spec of an absent hook) is left untouched. [gated 2026-10-06, orchestrator — scope class, surfaced to user in pass B]
|
||||
Q: manual-mode SessionStart message language / A: English, consistent with the hook family. Exact line: `ℹ manual push mode: <N> commit(s) on '<branch>' to push by hand (git push)`; no-upstream variant: `ℹ manual push mode: '<branch>' has no upstream (<N> commit(s) on this disk only), push by hand: git push -u origin <branch>`; the existing `; <d> uncommitted change(s) in <cwd>` clause follows when the tree is dirty. [gated 2026-10-06]
|
||||
Q: run B hook name / A: `hooks/push-guard.sh` + `lib/tests/push-guard.test.sh` [gated 2026-10-06]
|
||||
Q: challenge r1 — skills push on their own (`skills/capitalize/SKILL.md:338` `git push origin develop` after the BDR-068 auto-finish; `skills/client-handover/SKILL.md:48` + `agents/client-handover-writer.md:586` `git push`; `skills/release-candidate/SKILL.md:96` and `skills/tour/SKILL.md:273` claim the branch is already on origin) and `settings.json` environment prose (lines ~480, ~499) says unpushed = defect / A: out of run A's 5-file scope. Run B (settings.json: hook wiring + widen the `gitflow.*` deny to `git config * gitflow.*`, `git -c gitflow.*`, `GIT_CONFIG_COUNT=*` + prose) and run C (the 5 skill/agent files: gate each push on `git config --bool --default true gitflow.autopush`, report `manual push mode: <ref> not pushed`). DEPLOYMENT ORDER: `gitflow.autopush false` is not to be set on the work machine before B and C are merged. [gated 2026-10-06, orchestrator — scope class, surfaced to the user]
|
||||
Q: challenge r1 — manual-mode count scope / A: all local branches (`--branches --not --remotes`), listing the ahead branches; the gated sentence shape stays (`ℹ manual push mode: <n> commit(s) not on origin (<b1>, <b2>), push by hand: git push -u origin <branch>`). Auto mode unchanged. [gated 2026-10-06, orchestrator — refinement of the chosen wording, surfaced to the user]
|
||||
|
||||
## ACCEPTANCE CRITERIA
|
||||
1. `_gitflow_push_branch` returns without pushing when `gitflow.autopush` is false: `gitflow start` under autopush=false creates the branch locally and origin has no copy; `gitflow finish` under autopush=false merges locally and origin's develop tip is unchanged. A branch whose upstream lags (pushed once by hand, then committed to) is still deleted by `finish` (rc 0): `--unset-upstream` before `-d` (LRN-161). Skipped remote delete says `left in place`. A base that cannot fast-forward from origin warns `behind origin/<base>`; offline stays silent. Locked by the new isolated gitflow-test block T18i–T18n.
|
||||
CHECK: out=$(make test suite=lib/gitflow-test.sh 2>&1); printf '%s' "$out" | grep -q ' FAIL ' && exit 1; for t in T18m0 T18i T18j T18k T18o T18n T18l; do printf '%s' "$out" | grep -q "ok $t" || exit 1; done; echo GITFLOW-MANUAL-OK
|
||||
EXPECT: GITFLOW-MANUAL-OK
|
||||
EVIDENCE: MET exit=0 marker-found :: GITFLOW-MANUAL-OK
|
||||
2. Auto mode unchanged: existing T18a–T18h, T24a–T24f and the T19 installed==emitted drift gate stay green (no hook emitter touched).
|
||||
CHECK: out=$(make test suite=lib/gitflow-test.sh 2>&1); printf '%s' "$out" | grep -q ' FAIL ' && exit 1; for t in T18a T18b T18c T18h T18f T19a T19b T19c T22i T22j T24b T24f; do printf '%s' "$out" | grep -q "ok $t" || exit 1; done; echo GITFLOW-AUTO-OK
|
||||
EXPECT: GITFLOW-AUTO-OK
|
||||
EVIDENCE: MET exit=0 marker-found :: GITFLOW-AUTO-OK
|
||||
3. `hooks/unpushed-guard.sh` in manual mode (`gitflow.autopush=false` in the cwd repo): Stop emits nothing even with unpushed commits; SessionStart emits the manual-mode info line (`ℹ manual push mode: <n> commit(s) not on origin (<branches>), push by hand: …`) counting every local branch, silent at n=0 with a clean tree, plus the existing uncommitted-changes clause; an invalid `gitflow.autopush` value is named at SessionStart and treated as auto; no "⚠ unpushed work" wording in manual mode. Auto mode output unchanged (T1–T9). Locked by new test cases T10–T16.
|
||||
CHECK: out=$(make test suite=lib/tests/unpushed-guard.test.sh 2>&1); printf '%s' "$out" | grep -q '^FAIL' && exit 1; printf '%s' "$out" | grep -qE 'PASS=(1[6-9]|[2-9][0-9]) FAIL=0' && echo GUARD-OK
|
||||
EXPECT: GUARD-OK
|
||||
EVIDENCE: MET exit=0 marker-found :: GUARD-OK
|
||||
4. `CLAUDE.global.md` gitflow section gains one statement: `gitflow.autopush false` = manual-push mode, unpushed work is expected there, Claude never pushes unless the user asks; the "ahead of its upstream is a defect" sentence is scoped to auto mode. File stays within the 320-line density budget.
|
||||
CHECK: grep -q 'autopush false' CLAUDE.global.md && grep -qi 'manual' CLAUDE.global.md && [ "$(wc -l < CLAUDE.global.md)" -le 320 ] && echo DOCTRINE-OK
|
||||
EXPECT: DOCTRINE-OK
|
||||
EVIDENCE: MET exit=0 marker-found :: DOCTRINE-OK
|
||||
5. shellcheck clean on the two touched scripts.
|
||||
CHECK: shellcheck lib/gitflow.sh hooks/unpushed-guard.sh && echo SHELLCHECK-OK
|
||||
EXPECT: SHELLCHECK-OK
|
||||
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK-OK
|
||||
6. Full hermetic suite green.
|
||||
CHECK: make test >/dev/null 2>&1 && echo SUITE-GREEN
|
||||
EXPECT: SUITE-GREEN
|
||||
EVIDENCE: NOT-MET exit=2 (nonzero) ::
|
||||
7. No new git-config key, no new env var, no change to `GITFLOW_NO_PUSH` semantics, no edit to hook emitters (`_gitflow_emit_*`) or to `githooks/`/`.githooks/`.
|
||||
8. shellcheck stays clean on `lib/gitflow-test.sh` and `lib/tests/unpushed-guard.test.sh` too (Health Stack `shellcheck lib/*.sh`).
|
||||
CHECK: shellcheck lib/gitflow-test.sh lib/tests/unpushed-guard.test.sh && echo SHELLCHECK-TESTS-OK
|
||||
EXPECT: SHELLCHECK-TESTS-OK
|
||||
EVIDENCE: MET exit=0 marker-found :: SHELLCHECK-TESTS-OK
|
||||
|
||||
## FILE SCOPE
|
||||
lib/gitflow.sh · hooks/unpushed-guard.sh · CLAUDE.global.md · lib/gitflow-test.sh · lib/tests/unpushed-guard.test.sh
|
||||
@@ -0,0 +1,48 @@
|
||||
# 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.
|
||||
@@ -6,6 +6,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/) and this project
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Manual-push mode**: `git config gitflow.autopush false` (human-set) now stops every push the gitflow lib makes, not only the post-commit / post-merge hooks. `gitflow start` and `finish` branch, commit and merge locally and push nothing; `gitflow delete` leaves the `origin/` copy in place and prints `git push origin --delete <br>` for the user to run. `hooks/unpushed-guard.sh` stays silent at turn end in this mode and opens each session with one `ℹ manual push mode:` line counting the commits no remote holds across every local branch; an unparseable `gitflow.autopush` value is named and treated as auto. Skills that push on their own do not honour the mode yet. Tests: `lib/gitflow-test.sh` T18m block, `lib/tests/unpushed-guard.test.sh` T10-T16.
|
||||
|
||||
### Changed
|
||||
- `gitflow start` and `finish` warn on stderr when a base is behind origin and cannot fast-forward, instead of a silent `git pull --ff-only || true` (T18l, T18n).
|
||||
|
||||
### Fixed
|
||||
- `gitflow delete` (and `finish`) land on the base that contains the branch and drop the branch's upstream before `git branch -d`, so a branch whose upstream lags (manual-push mode) is deleted instead of refused by git (T18k).
|
||||
|
||||
## [2.0.0] — 2026-10-06
|
||||
|
||||
Upgrading from 1.x: see [MIGRATION.md](./MIGRATION.md#upgrading-an-existing-machine-to-200).
|
||||
|
||||
+8
-4
@@ -183,9 +183,12 @@ auto-pushed upstream). The reference-transaction hook vetoes any deletion
|
||||
or rename of `main`/`develop`. The four hooks run in every repo: `make
|
||||
link` generates `githooks/` and sets the global `core.hooksPath`; a repo
|
||||
that ran `gitflow init` (new/onboarded projects) keeps its own `.githooks/`,
|
||||
refreshed at session start. 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.
|
||||
refreshed at session start. 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.
|
||||
|
||||
## Security — non-negotiable defaults
|
||||
Apply at every step: design, scaffolding, implementation, review.
|
||||
@@ -227,7 +230,8 @@ days of work never pushed.
|
||||
- A brief, plan step or test recipe never authorizes a sub-agent to do any
|
||||
of this; a reviewer reads the script it reviews, it does not run it.
|
||||
- Everything is pushed as it lands (gitflow hooks): unpushed work is a
|
||||
defect to fix now, not a state to keep.
|
||||
defect to fix now, not a state to keep. Manual-push mode (above) is the
|
||||
one exception.
|
||||
|
||||
# Communication mode: radical honesty
|
||||
- TRUTH OVER COMFORT: point out flaws immediately, no sugarcoating, no "not
|
||||
|
||||
+36
-1
@@ -9,6 +9,10 @@
|
||||
# SessionStart also reports uncommitted changes (a dead session leaves some
|
||||
# behind); Stop reports unpushed commits only, since a dirty tree mid-work is
|
||||
# the normal state at a turn end.
|
||||
#
|
||||
# Manual-push mode (git config gitflow.autopush false, human-set): unpushed
|
||||
# work is expected, so Stop stays silent; SessionStart gives one info line
|
||||
# counting every local branch, with the branches to push by hand.
|
||||
set -u
|
||||
|
||||
payload=$(cat 2>/dev/null)
|
||||
@@ -19,9 +23,37 @@ cd "$cwd" 2>/dev/null || exit 0
|
||||
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || exit 0
|
||||
br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0
|
||||
|
||||
raw=$(git config gitflow.autopush 2>/dev/null)
|
||||
manual=0; invalid=0
|
||||
[ "$(git config --bool --default true gitflow.autopush 2>/dev/null)" = false ] && manual=1
|
||||
[ -n "$raw" ] && ! git config --bool gitflow.autopush >/dev/null 2>&1 && invalid=1
|
||||
[ "$manual" = 1 ] && [ "$event" != SessionStart ] && exit 0 # BDR-087: info at start only
|
||||
|
||||
# Local branches holding commits no remote has, one per line.
|
||||
ahead_branches() {
|
||||
local b
|
||||
while IFS= read -r b; do
|
||||
[ "$(git rev-list --count "$b" --not --remotes 2>/dev/null)" -gt 0 ] && echo "$b"
|
||||
done < <(git for-each-ref --format='%(refname:short)' refs/heads)
|
||||
}
|
||||
|
||||
# Manual mode: commits on every local branch that no remote holds.
|
||||
manual_clause() {
|
||||
local n list first
|
||||
n=$(git rev-list --count --branches --not --remotes 2>/dev/null || echo 0)
|
||||
[ "$n" -gt 0 ] || return 0
|
||||
if ! git remote get-url origin >/dev/null 2>&1; then
|
||||
echo "no 'origin' remote, $n commit(s) on this disk only"
|
||||
return
|
||||
fi
|
||||
list=$(ahead_branches); first=$(printf '%s\n' "$list" | head -n 1)
|
||||
echo "$n commit(s) not on origin ($(printf '%s' "$list" | paste -sd, - | sed 's/,/, /g')), push by hand: git push -u origin $first"
|
||||
}
|
||||
|
||||
# Commits that no remote holds, as one clause; empty when everything is pushed.
|
||||
unpushed_clause() {
|
||||
local up n
|
||||
[ "$manual" = 1 ] && { manual_clause; return; }
|
||||
if ! git remote get-url origin >/dev/null 2>&1; then
|
||||
echo "no 'origin' remote, every commit lives on this disk only"
|
||||
return
|
||||
@@ -41,9 +73,12 @@ if [ "$event" = "SessionStart" ]; then
|
||||
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
||||
[ "$dirty" -gt 0 ] && msg="${msg:+$msg; }$dirty uncommitted change(s) in $cwd"
|
||||
fi
|
||||
if [ "$invalid" = 1 ] && [ "$event" = "SessionStart" ]; then
|
||||
msg="${msg:+$msg; }gitflow.autopush='$raw' is not a boolean, treated as auto (pushes run)"
|
||||
fi
|
||||
[ -n "$msg" ] || exit 0
|
||||
|
||||
msg="⚠ unpushed work: $msg"
|
||||
if [ "$manual" = 1 ]; then msg="ℹ manual push mode: $msg"; else msg="⚠ unpushed work: $msg"; fi
|
||||
if [ "$event" = "SessionStart" ]; then
|
||||
jq -cn --arg m "$msg" \
|
||||
'{systemMessage: $m, hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $m}}'
|
||||
|
||||
@@ -354,6 +354,41 @@ gitflow_start feature nr >/dev/null 2>&1; echo w>w; git add w
|
||||
nr_out="$(git commit -q -m w 2>&1)"; nr_rc=$?
|
||||
chk "T18g no origin → silent, commit ok" "[ $nr_rc -eq 0 ] && ! printf '%s' \"\$nr_out\" | grep -q FAILED"
|
||||
|
||||
echo "T18m — manual-push mode: gitflow.autopush=false (human-set) → nothing pushed, finish still deletes"
|
||||
newrepo manual; echo a>a; hookon; gitflow_init >/dev/null 2>&1
|
||||
bare="$WORK/manual.git"; git init -q --bare "$bare"; git remote add origin "$bare"
|
||||
git push -q -u origin main develop 2>/dev/null
|
||||
chk "T18m0 develop tracks origin/develop" "git rev-parse -q --verify 'develop@{u}' >/dev/null"
|
||||
git config gitflow.autopush false
|
||||
gitflow_start feature manual >/dev/null 2>&1
|
||||
chk "T18i start → branch local, no copy on origin" 'git rev-parse --verify -q refs/heads/feature/manual >/dev/null && ! git ls-remote --exit-code --heads origin feature/manual >/dev/null 2>&1'
|
||||
echo m>m.txt; git add m.txt; git commit -q -m m
|
||||
dev_remote_before=$(git -C "$bare" rev-parse develop)
|
||||
gitflow_finish >/dev/null 2>&1; fin_rc=$?
|
||||
chk "T18j finish → merged locally, origin develop unchanged, branch deleted" "[ $fin_rc -eq 0 ] && grep -q 'Merge feature/manual into develop' < <(git log develop --format=%s) && [ \"\$(git -C \"$bare\" rev-parse develop)\" = \"$dev_remote_before\" ] && ! git rev-parse --verify -q refs/heads/feature/manual >/dev/null"
|
||||
git config gitflow.autopush true; gitflow_start feature lag >/dev/null 2>&1
|
||||
git config gitflow.autopush false
|
||||
echo l>l.txt; git add l.txt; git commit -q -m l
|
||||
gitflow_finish >"$WORK/lag.out" 2>&1; lag_rc=$?
|
||||
chk "T18k lagging upstream → finish deletes, remote copy left in place" "[ $lag_rc -eq 0 ] && ! git rev-parse --verify -q refs/heads/feature/lag >/dev/null && [ \"\$(git -C \"$bare\" rev-parse develop)\" = \"$dev_remote_before\" ] && grep -q 'left in place' \"$WORK/lag.out\" && git ls-remote --exit-code --heads origin feature/lag >/dev/null 2>&1"
|
||||
git config gitflow.autopush true; gitflow_start feature np >/dev/null 2>&1
|
||||
git config gitflow.autopush false
|
||||
echo n>n.txt; git add n.txt; git commit -q -m n
|
||||
GITFLOW_NO_PUSH=1 gitflow_finish >"$WORK/np.out" 2>&1; np_rc=$?
|
||||
chk "T18o NO_PUSH → silent on the remote copy" "[ $np_rc -eq 0 ] && ! git rev-parse --verify -q refs/heads/feature/np >/dev/null && ! grep -q 'left in place' \"$WORK/np.out\" && git ls-remote --exit-code --heads origin feature/np >/dev/null 2>&1"
|
||||
git remote set-url origin /nonexistent/x.git
|
||||
gitflow_start feature off2 >"$WORK/off2.out" 2>&1
|
||||
chk "T18n offline, nothing recorded → silent, branch created" "! grep -q behind \"$WORK/off2.out\" && git rev-parse --verify -q refs/heads/feature/off2 >/dev/null"
|
||||
git remote set-url origin "$bare"; git checkout -q develop
|
||||
other="$WORK/manual-other"; git clone -q "$bare" "$other" 2>/dev/null
|
||||
( cd "$other" && git config user.email t@t && git config user.name t \
|
||||
&& git config core.hooksPath /dev/null && git checkout -q develop \
|
||||
&& echo o>o.txt && git add o.txt && git commit -q -m o \
|
||||
&& git push -q origin develop ) >/dev/null 2>&1
|
||||
div_err="$WORK/div.err"
|
||||
div_out=$(gitflow_start feature div 2>"$div_err")
|
||||
chk "T18l diverged base → warns on stderr, stdout stays the branch name" "[ \"$div_out\" = feature/div ] && grep -q 'behind origin/develop' \"$div_err\" && git rev-parse --verify -q refs/heads/feature/div >/dev/null"
|
||||
|
||||
echo "T19 — installed hooks == emitted hooks in the config repo (LRN-114 drift gate)"
|
||||
if [ -d "$HERE/../.githooks" ]; then
|
||||
chk "T19a pre-commit installed == emitted" 'diff -q <(_gitflow_emit_pre_commit) "$HERE/../.githooks/pre-commit" >/dev/null'
|
||||
|
||||
+55
-6
@@ -70,15 +70,23 @@ gitflow_release_open() {
|
||||
|
||||
# ── start ────────────────────────────────────────────────────────────────────
|
||||
|
||||
# rc 0 when pushing is off: GITFLOW_NO_PUSH=1 (throwaway test repos) or
|
||||
# gitflow.autopush=false (manual-push mode, human-set: work machine, foreign
|
||||
# clone). The single reader of both flags for the lib's own push sites.
|
||||
_gitflow_push_off() {
|
||||
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
|
||||
[ "$(git config --bool --default true gitflow.autopush)" = false ]
|
||||
}
|
||||
|
||||
# gitflow_start <type> <name> → checkout -b <type>/<name> from the correct base.
|
||||
# _gitflow_push_branch <br> → push + set upstream on origin (BDR-095: a remote
|
||||
# only backs up what it holds, so a branch is pushed the moment it exists).
|
||||
# Best effort BY CONTRACT: no origin, offline, or refused → loud warning, rc 0.
|
||||
# A failed push must never block the work, only make the gap visible.
|
||||
# GITFLOW_NO_PUSH=1 opts out (throwaway test repos).
|
||||
# Opt-outs: see _gitflow_push_off.
|
||||
_gitflow_push_branch() {
|
||||
local br="$1"
|
||||
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
|
||||
_gitflow_push_off && return 0
|
||||
git remote get-url origin >/dev/null 2>&1 || return 0
|
||||
if _gitflow_timeout git push -q -u --follow-tags origin "$br" >/dev/null 2>&1; then
|
||||
return 0
|
||||
@@ -96,6 +104,20 @@ _gitflow_timeout() {
|
||||
fi
|
||||
}
|
||||
|
||||
# _gitflow_sync_base → fast-forward the checked-out base from its upstream.
|
||||
# Never blocks. A base that cannot fast-forward while the remote is ahead (a
|
||||
# recorded divergence) is warned about: auto-push used to be the only thing
|
||||
# that surfaced it. No upstream, or offline with nothing recorded → silent.
|
||||
_gitflow_sync_base() {
|
||||
local behind
|
||||
_gitflow_timeout git pull --ff-only -q >/dev/null 2>&1 && return 0
|
||||
git rev-parse -q --verify '@{u}' >/dev/null 2>&1 || return 0
|
||||
behind=$(git rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
|
||||
[ "$behind" -gt 0 ] || return 0
|
||||
echo "gitflow: $(git symbolic-ref --short -q HEAD) is behind origin/$(git symbolic-ref --short -q HEAD) by $behind and cannot fast-forward — reconcile by hand (git pull, then push)" >&2
|
||||
return 0
|
||||
}
|
||||
|
||||
gitflow_start() {
|
||||
local type="${1:-}" name="${2:-}" base
|
||||
base="$(gitflow_base_for "$type")" || return 2
|
||||
@@ -103,7 +125,7 @@ gitflow_start() {
|
||||
git rev-parse --verify -q "$base" >/dev/null \
|
||||
|| { echo "gitflow_start: base '$base' missing — run 'gitflow init' first" >&2; return 3; }
|
||||
git checkout -q "$base" || return 1
|
||||
git pull --ff-only -q 2>/dev/null || true # best-effort sync; offline / no-upstream ok
|
||||
_gitflow_sync_base # best-effort sync; warns on divergence, never blocks
|
||||
git checkout -q -b "$type/$name" || return 1
|
||||
_gitflow_push_branch "$type/$name"
|
||||
echo "$type/$name"
|
||||
@@ -114,7 +136,7 @@ gitflow_start() {
|
||||
_gitflow_merge_into() { # _gitflow_merge_into <target> <source>
|
||||
local target="$1" source="$2"
|
||||
git checkout -q "$target" || return 1
|
||||
git pull --ff-only -q 2>/dev/null || true
|
||||
_gitflow_sync_base
|
||||
git merge --no-ff -q -m "Merge $source into $target" "$source" \
|
||||
|| { echo "gitflow: conflict merging $source → $target — resolve, commit, re-run finish" >&2; return 4; }
|
||||
_gitflow_push_branch "$target" # git merge fires post-merge, not post-commit; push here too
|
||||
@@ -143,6 +165,15 @@ gitflow_merged_into_base() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# _gitflow_note_remote_left <br> → manual mode never deletes origin/<br>; say
|
||||
# so when a remote-tracking ref shows a copy exists (no network call).
|
||||
_gitflow_note_remote_left() {
|
||||
local br="$1"
|
||||
gitflow_protected_base "$br" && return 0
|
||||
git rev-parse -q --verify "refs/remotes/origin/$br" >/dev/null || return 0
|
||||
echo "gitflow: origin/$br left in place (manual push mode) — by hand: git push origin --delete $br" >&2
|
||||
}
|
||||
|
||||
# _gitflow_delete_remote <br> → remove origin/<br> once the LOCAL copy is gone.
|
||||
# Same contract as the pushes (BDR-095): best effort, warn never fail; skipped
|
||||
# under GITFLOW_NO_PUSH=1, gitflow.autopush=false or no origin. The REMOTE tip
|
||||
@@ -153,8 +184,11 @@ gitflow_merged_into_base() {
|
||||
_gitflow_delete_remote() {
|
||||
local br="$1" out rc tip
|
||||
[ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0
|
||||
[ "$(git config --bool --default true gitflow.autopush)" = false ] && return 0
|
||||
git remote get-url origin >/dev/null 2>&1 || return 0
|
||||
if _gitflow_push_off; then
|
||||
_gitflow_note_remote_left "$br"
|
||||
return 0
|
||||
fi
|
||||
gitflow_protected_base "$br" && return 0
|
||||
out="$(_gitflow_timeout git ls-remote --exit-code --heads origin "refs/heads/$br" 2>/dev/null)"; rc=$?
|
||||
[ "$rc" -eq 2 ] && return 0 # no remote copy — nothing to remove
|
||||
@@ -175,6 +209,17 @@ _gitflow_delete_remote() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# _gitflow_checkout_containing_base <br> → leave <br>, landing on the base that
|
||||
# contains it (develop first, main for a branch merged into main only).
|
||||
_gitflow_checkout_containing_base() {
|
||||
local br="$1"
|
||||
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
|
||||
}
|
||||
|
||||
# gitflow_delete <branch> → the one sanctioned way to delete a branch, local
|
||||
# copy then origin copy. finish calls it after its merges; the CLI exposes it
|
||||
# for a branch merged elsewhere (a Gitea PR, a hand merge). Refuses, branch
|
||||
@@ -192,7 +237,11 @@ gitflow_delete() {
|
||||
echo "gitflow: REFUSED — '$br' is not merged into $GITFLOW_DEVELOP or $GITFLOW_MAIN — branch kept" >&2
|
||||
return 5
|
||||
fi
|
||||
git checkout -q "$GITFLOW_DEVELOP" 2>/dev/null || git checkout -q "$GITFLOW_MAIN" 2>/dev/null
|
||||
_gitflow_checkout_containing_base "$br"
|
||||
# LRN-161: `-d` judges against the upstream when one is set, against HEAD
|
||||
# otherwise. The ancestor check above is the real gate, so HEAD must be the
|
||||
# base that contains <br> and a lagging upstream (manual mode) must go.
|
||||
git branch -q --unset-upstream "$br" 2>/dev/null || true
|
||||
git branch -q -d "$br" || { echo "gitflow: git refused to delete '$br' — branch kept" >&2; return 5; }
|
||||
_gitflow_delete_remote "$br"
|
||||
}
|
||||
|
||||
@@ -38,4 +38,42 @@ check T8-dirty-start-reported "$(has "$(fire SessionStart "$PWD")" "uncommitted"
|
||||
out=$(jq -n --arg d "$PWD" '{hook_event_name:"SessionStart", cwd:$d}' | bash "$H" 2>/dev/null)
|
||||
check T9-start-adds-context "$(printf '%s' "$out" | jq -r '.hookSpecificOutput.hookEventName')" SessionStart
|
||||
|
||||
# ── manual-push mode (gitflow.autopush=false) ──
|
||||
git checkout -q -- a
|
||||
git config gitflow.autopush false
|
||||
check T10-manual-clean-start "$(fire SessionStart "$PWD")" silent
|
||||
check T10-manual-clean-stop "$(fire Stop "$PWD")" silent
|
||||
echo m>m; git add m; git commit -q -m m
|
||||
git branch side HEAD; git checkout -q side; echo s>s; git add s; git commit -q -m s
|
||||
git checkout -q -
|
||||
check T11-manual-stop-silent "$(fire Stop "$PWD")" silent
|
||||
out=$(fire SessionStart "$PWD")
|
||||
check T11-manual-info "$(has "$out" "manual push mode")" yes
|
||||
check T11-manual-count "$(has "$out" "2 commit(s)")" yes
|
||||
check T11-manual-lists-branch "$(has "$out" "side")" yes
|
||||
check T11-manual-no-warning "$(has "$out" "unpushed work")" no
|
||||
git checkout -q -b fresh
|
||||
check T12-fresh-branch-repo-wide "$(has "$(fire SessionStart "$PWD")" "2 commit(s)")" yes
|
||||
git checkout -q -
|
||||
git push -q origin HEAD side 2>/dev/null; echo d>>a
|
||||
out=$(fire SessionStart "$PWD")
|
||||
check T13-dirty-info "$(has "$out" "manual push mode")" yes
|
||||
check T13-dirty-uncommitted "$(has "$out" "uncommitted")" yes
|
||||
check T13-dirty-no-commit-clause "$(has "$out" "commit(s) not on origin")" no
|
||||
check T13-dirty-stop-silent "$(fire Stop "$PWD")" silent
|
||||
git checkout -q -- a
|
||||
git config gitflow.autopush flase
|
||||
echo i>i; git add i; git commit -q -m i
|
||||
out=$(fire SessionStart "$PWD")
|
||||
check T14-invalid-named "$(has "$out" "not a boolean")" yes
|
||||
check T14-invalid-treated-auto "$(has "$out" "unpushed work")" yes
|
||||
check T14-invalid-stop-auto "$(has "$(fire Stop "$PWD")" "1 commit(s)")" yes
|
||||
git config --unset gitflow.autopush
|
||||
check T15-unset-auto-intact "$(has "$(fire Stop "$PWD")" "1 commit(s)")" yes
|
||||
git config gitflow.autopush false; git remote remove origin
|
||||
out=$(fire SessionStart "$PWD")
|
||||
check T16-no-origin-manual "$(has "$out" "manual push mode")" yes
|
||||
check T16-no-origin-clause "$(has "$out" "no 'origin' remote")" yes
|
||||
check T16-no-origin-stop-silent "$(fire Stop "$PWD")" silent
|
||||
|
||||
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]
|
||||
|
||||
@@ -54,10 +54,12 @@ bash ~/.claude/lib/gitflow.sh protected-base [br] # rc 0 on main/develop — the
|
||||
`main`/`develop` (rc 6) and any branch not merged into develop or main (rc 5),
|
||||
and keeps the branch. The `origin/` copy is removed right after, once ITS
|
||||
tip passes the same check; a remote tip holding commits the bases lack is
|
||||
kept, loudly (T24). Hand `git branch -d` is denied — with an auto-pushed
|
||||
upstream it checks the wrong thing (T22a). A `reference-transaction` hook
|
||||
vetoes any deletion or rename of `main`/`develop` at the ref layer, in every
|
||||
repo.
|
||||
kept, loudly (T24). In manual-push mode (`git config gitflow.autopush
|
||||
false`, human-set) nothing is pushed: `start` and `finish` stay local, and
|
||||
the `origin/` copy is left in place (T18i-T18k). Hand `git branch -d` is
|
||||
denied — with an auto-pushed upstream it checks the wrong thing (T22a). A
|
||||
`reference-transaction` hook vetoes any deletion or rename of
|
||||
`main`/`develop` at the ref layer, in every repo.
|
||||
|
||||
## The finish gate — merge ONLY on an explicit human signal
|
||||
|
||||
@@ -107,6 +109,8 @@ stays human-gated.
|
||||
| `delete`/`finish` rc=5 — branch not merged into develop or main | The branch still holds unmerged work: KEEP it, report it, never fall back to `git branch -d`/`-D`. Merge first (human gate), then re-run |
|
||||
| `delete` rc=6 — protected base | `main`/`develop` are never deleted. Stop; the request itself is the defect to report |
|
||||
| `delete`/`finish` warning "remote copy KEPT" or "NOT removed" | Non-fatal BY CONTRACT (remote cleanup is best-effort). KEPT = origin/<br> has a tip the bases lack: fetch, look, merge or leave it — never `git push --delete` by hand. NOT removed = origin unreachable or refused: report the printed command to the user |
|
||||
| `delete`/`finish` warning "origin/<br> left in place (manual push mode)" | Expected in manual-push mode, not a failure. Pass the printed `git push origin --delete <br>` to the user; never run it (manual mode: no push unless the user asks, and `push --delete` is denied by settings) |
|
||||
| `start`/`finish` warning "<base> is behind origin/<base> by N and cannot fast-forward" | Non-fatal BY CONTRACT: the branch is still created and the merge still runs on the local base. The base has diverged from origin: report it to the user, who reconciles (`git pull`, then push). Never rebase or force-push a base |
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
|
||||
@@ -167,12 +167,17 @@ fourth hook, `reference-transaction`, vetoes any deletion or rename of
|
||||
reach every repo two ways: `make link` generates `githooks/` from the lib
|
||||
and sets git's global `core.hooksPath` to `~/.claude/githooks` (a repo's own
|
||||
local `core.hooksPath` wins, by git's rules), and `hooks/session-start.sh`
|
||||
refreshes a repo's `.githooks/` when it lags the lib. Per-repo opt-outs for
|
||||
a foreign clone: `git config gitflow.protect false` (branch model) and
|
||||
`git config gitflow.autopush false` (push); `GITFLOW_NO_PUSH=1` for one
|
||||
command in a throwaway repo. `make doctor` checks the global setting and
|
||||
the generated dir. `hooks/unpushed-guard.sh` reports a branch ahead of its
|
||||
upstream at session start and at each turn end.
|
||||
refreshes a repo's `.githooks/` when it lags the lib. Per-repo opt-outs, set
|
||||
by a human: `git config gitflow.protect false` (branch model, foreign clone)
|
||||
and `git config gitflow.autopush false` (manual-push mode: the hooks,
|
||||
`start` and `finish` push nothing, and `delete` leaves the `origin/` copy in
|
||||
place, printing the command to remove it by hand); `GITFLOW_NO_PUSH=1` for
|
||||
one command in a throwaway repo. `start` and `finish` warn when a base is
|
||||
behind origin and cannot fast-forward. `make doctor` checks the global
|
||||
setting and the generated dir. `hooks/unpushed-guard.sh` reports a branch
|
||||
ahead of its upstream at session start and at each turn end; in manual-push
|
||||
mode it stays silent at turn end and gives one `ℹ manual push mode:` line at
|
||||
session start, counting unpushed commits across every local branch.
|
||||
|
||||
## managed-settings.json (enterprise)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user