diff --git a/CLAUDE.global.md b/CLAUDE.global.md index 8a00c4e..59969d0 100644 --- a/CLAUDE.global.md +++ b/CLAUDE.global.md @@ -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 diff --git a/hooks/unpushed-guard.sh b/hooks/unpushed-guard.sh index 2689a91..1c464c2 100755 --- a/hooks/unpushed-guard.sh +++ b/hooks/unpushed-guard.sh @@ -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}}' diff --git a/lib/gitflow-test.sh b/lib/gitflow-test.sh index bdefd10..3579b83 100644 --- a/lib/gitflow-test.sh +++ b/lib/gitflow-test.sh @@ -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' diff --git a/lib/gitflow.sh b/lib/gitflow.sh index 1b34a6b..1ef2cd1 100644 --- a/lib/gitflow.sh +++ b/lib/gitflow.sh @@ -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 → checkout -b / from the correct base. # _gitflow_push_branch
→ 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 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
→ manual mode never deletes origin/
; 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
→ remove origin/
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
→ leave
, 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 → 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
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" } diff --git a/lib/tests/unpushed-guard.test.sh b/lib/tests/unpushed-guard.test.sh index ad3630c..6b4c9c0 100755 --- a/lib/tests/unpushed-guard.test.sh +++ b/lib/tests/unpushed-guard.test.sh @@ -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 ]