diff --git a/.githooks/post-commit b/.githooks/post-commit index 417fecf..08d9954 100755 --- a/.githooks/post-commit +++ b/.githooks/post-commit @@ -5,8 +5,15 @@ hook=post-commit # holds. Never fails the commit: no origin / offline / refused → warning only. # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 -# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false -[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0 +# Manual-push mode (human-set): git config gitflow.autopush false. Fail closed: +# an unparseable value or a config read failure also means "no push", named. +# Mirrors gitflow_push_mode (lib/gitflow.sh); arms pinned by T18b/T18h/T18q2/T18q4. +v=$(git config --bool gitflow.autopush 2>/dev/null); rc=$? +case "$rc:$v" in + 0:true|1:*) ;; + 0:false) exit 0 ;; + *) echo "gitflow $hook: gitflow.autopush unreadable (git rc $rc) — NOT pushed, treated as manual push mode; fix the value by hand" >&2; exit 0 ;; +esac git remote get-url origin >/dev/null 2>&1 || exit 0 br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi diff --git a/.githooks/post-merge b/.githooks/post-merge index 69a9f65..853eda0 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -5,8 +5,15 @@ hook=post-merge # holds. Never fails the commit: no origin / offline / refused → warning only. # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 -# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false -[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0 +# Manual-push mode (human-set): git config gitflow.autopush false. Fail closed: +# an unparseable value or a config read failure also means "no push", named. +# Mirrors gitflow_push_mode (lib/gitflow.sh); arms pinned by T18b/T18h/T18q2/T18q4. +v=$(git config --bool gitflow.autopush 2>/dev/null); rc=$? +case "$rc:$v" in + 0:true|1:*) ;; + 0:false) exit 0 ;; + *) echo "gitflow $hook: gitflow.autopush unreadable (git rc $rc) — NOT pushed, treated as manual push mode; fix the value by hand" >&2; exit 0 ;; +esac git remote get-url origin >/dev/null 2>&1 || exit 0 br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi diff --git a/githooks/post-commit b/githooks/post-commit index 417fecf..08d9954 100755 --- a/githooks/post-commit +++ b/githooks/post-commit @@ -5,8 +5,15 @@ hook=post-commit # holds. Never fails the commit: no origin / offline / refused → warning only. # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 -# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false -[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0 +# Manual-push mode (human-set): git config gitflow.autopush false. Fail closed: +# an unparseable value or a config read failure also means "no push", named. +# Mirrors gitflow_push_mode (lib/gitflow.sh); arms pinned by T18b/T18h/T18q2/T18q4. +v=$(git config --bool gitflow.autopush 2>/dev/null); rc=$? +case "$rc:$v" in + 0:true|1:*) ;; + 0:false) exit 0 ;; + *) echo "gitflow $hook: gitflow.autopush unreadable (git rc $rc) — NOT pushed, treated as manual push mode; fix the value by hand" >&2; exit 0 ;; +esac git remote get-url origin >/dev/null 2>&1 || exit 0 br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi diff --git a/githooks/post-merge b/githooks/post-merge index 69a9f65..853eda0 100755 --- a/githooks/post-merge +++ b/githooks/post-merge @@ -5,8 +5,15 @@ hook=post-merge # holds. Never fails the commit: no origin / offline / refused → warning only. # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 -# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false -[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0 +# Manual-push mode (human-set): git config gitflow.autopush false. Fail closed: +# an unparseable value or a config read failure also means "no push", named. +# Mirrors gitflow_push_mode (lib/gitflow.sh); arms pinned by T18b/T18h/T18q2/T18q4. +v=$(git config --bool gitflow.autopush 2>/dev/null); rc=$? +case "$rc:$v" in + 0:true|1:*) ;; + 0:false) exit 0 ;; + *) echo "gitflow $hook: gitflow.autopush unreadable (git rc $rc) — NOT pushed, treated as manual push mode; fix the value by hand" >&2; exit 0 ;; +esac git remote get-url origin >/dev/null 2>&1 || exit 0 br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi diff --git a/hooks/unpushed-guard.sh b/hooks/unpushed-guard.sh index 1c464c2..5a83e5b 100755 --- a/hooks/unpushed-guard.sh +++ b/hooks/unpushed-guard.sh @@ -12,9 +12,13 @@ # # 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. +# counting every local branch, with the branches to push by hand. An +# unparseable value is treated as manual too (fail closed, BDR-114); the mode +# comes from the lib verb, the one reader the hooks share. set -u +# Resolved before any cd: the hook may be invoked by a relative path. +_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" 2>/dev/null && pwd)/gitflow.sh" payload=$(cat 2>/dev/null) field() { printf '%s' "$payload" | jq -r "$1 // empty" 2>/dev/null; } event=$(field '.hook_event_name') @@ -23,10 +27,10 @@ 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 +# The verb writes its stderr line BEFORE its stdout word: the last line is the mode. +out=$(bash "$_lib" push-mode 2>&1); mode=${out##*$'\n'} +mode_err=${out%"$mode"}; mode_err=${mode_err%$'\n'} +manual=0; [ "$mode" = auto ] || manual=1 # fail closed: anything but auto [ "$manual" = 1 ] && [ "$event" != SessionStart ] && exit 0 # BDR-087: info at start only # Local branches holding commits no remote has, one per line. @@ -73,8 +77,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)" +if [ "$event" = "SessionStart" ]; then + case "$mode" in + invalid) msg="${msg:+$msg; }${mode_err#gitflow.sh push-mode: } — treated as manual push mode (nothing pushes); fix the value by hand" ;; + manual|auto) ;; + *) msg="${msg:+$msg; }push mode unreadable (lib verb printed '${mode:-nothing}') — treated as manual push mode" ;; + esac fi [ -n "$msg" ] || exit 0 diff --git a/lib/gitflow-test.sh b/lib/gitflow-test.sh index c6bdd67..fa2c3fd 100644 --- a/lib/gitflow-test.sh +++ b/lib/gitflow-test.sh @@ -406,6 +406,30 @@ 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 "T18q — fail closed: unparseable gitflow.autopush → nothing pushes, named (BDR-114)" +newrepo badval; echo a>a; hookon; gitflow_init >/dev/null 2>&1 +bare="$WORK/badval.git"; git init -q --bare "$bare"; git remote add origin "$bare" +git push -q -u origin main develop 2>/dev/null +git config gitflow.autopush flase +gitflow_start feature bad >/dev/null 2>"$WORK/q1.err" +chk "T18q1 start → branch local, not on origin, value named" "git rev-parse --verify -q refs/heads/feature/bad >/dev/null && ! git ls-remote --exit-code --heads origin feature/bad >/dev/null 2>&1 && grep -q 'not a boolean' \"$WORK/q1.err\"" +echo b>b.txt; git add b.txt; git commit -q -m b 2>"$WORK/q2.err" +chk "T18q2 commit → not pushed, hook says NOT pushed" "! git ls-remote --exit-code --heads origin feature/bad >/dev/null 2>&1 && grep -q 'NOT pushed' \"$WORK/q2.err\"" +dev_before=$(git -C "$bare" rev-parse develop) +gitflow_finish >/dev/null 2>&1; q_rc=$? +chk "T18q3 finish → merged locally, origin develop unchanged" "[ $q_rc -eq 0 ] && [ \"\$(git -C \"$bare\" rev-parse develop)\" = \"$dev_before\" ] && ! git rev-parse --verify -q refs/heads/feature/bad >/dev/null" +git config gitflow.autopush true +gitflow_start feature good >/dev/null 2>&1 +echo g>g.txt; git add g.txt; git commit -q -m g 2>/dev/null +chk "T18q4 true → post-commit pushed (tips equal)" '[ "$(git rev-parse HEAD)" = "$(git -C "$bare" rev-parse feature/good)" ]' +_gitflow_emit_push_hook post-commit > "$WORK/pc.sh" +chk "T18q5a emitted hook carries the rc:value case" "[ -s \"$WORK/pc.sh\" ] && grep -qF 'case \"\$rc:\$v\"' \"$WORK/pc.sh\"" +if command -v shellcheck >/dev/null 2>&1; then + chk "T18q5 emitted hook is POSIX-clean" "shellcheck -s sh \"$WORK/pc.sh\"" +else + ok "T18q5 skipped (no shellcheck)" +fi + 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 db008b6..9cabaa8 100644 --- a/lib/gitflow.sh +++ b/lib/gitflow.sh @@ -97,12 +97,14 @@ gitflow_push_mode() { # ── 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. +# rc 0 when pushing is off: GITFLOW_NO_PUSH=1 (throwaway test repos), or +# gitflow.autopush not readable as `true`/unset — manual-push mode (false, +# human-set) AND fail closed on an unparseable value or a config read +# failure (BDR-114). The verb's stderr passes through: it names an invalid +# value and is silent for auto/manual. Single reader for the lib's push sites. _gitflow_push_off() { [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && return 0 - [ "$(git config --bool --default true gitflow.autopush)" = false ] + [ "$(gitflow_push_mode)" != auto ] } # gitflow_start → checkout -b / from the correct base. @@ -192,8 +194,9 @@ 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
→ push off (manual mode or invalid value) 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 @@ -203,7 +206,7 @@ _gitflow_note_remote_left() { # _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 +# when push is off (see _gitflow_push_off) or no origin. The REMOTE tip # is re-checked against develop/main before the delete: a commit pushed from # elsewhere that never reached a base (or that this clone has never fetched) # keeps the remote branch alive, loudly. Never a base, by construction and by @@ -507,8 +510,15 @@ cat <<'HOOK' # holds. Never fails the commit: no origin / offline / refused → warning only. # Opt out for one command with GITFLOW_NO_PUSH=1 (throwaway repos, tests). [ "${GITFLOW_NO_PUSH:-0}" = 1 ] && exit 0 -# Per-repo opt-out (no push rights on a foreign clone): git config gitflow.autopush false -[ "$(git config --bool --default true gitflow.autopush)" = false ] && exit 0 +# Manual-push mode (human-set): git config gitflow.autopush false. Fail closed: +# an unparseable value or a config read failure also means "no push", named. +# Mirrors gitflow_push_mode (lib/gitflow.sh); arms pinned by T18b/T18h/T18q2/T18q4. +v=$(git config --bool gitflow.autopush 2>/dev/null); rc=$? +case "$rc:$v" in + 0:true|1:*) ;; + 0:false) exit 0 ;; + *) echo "gitflow $hook: gitflow.autopush unreadable (git rc $rc) — NOT pushed, treated as manual push mode; fix the value by hand" >&2; exit 0 ;; +esac git remote get-url origin >/dev/null 2>&1 || exit 0 br=$(git symbolic-ref --short -q HEAD 2>/dev/null) || exit 0 # detached HEAD — nothing to track if command -v timeout >/dev/null 2>&1; then t="timeout ${GITFLOW_PUSH_TIMEOUT:-30}"; else t=""; fi diff --git a/lib/tests/unpushed-guard.test.sh b/lib/tests/unpushed-guard.test.sh index 6b4c9c0..d5a60d5 100755 --- a/lib/tests/unpushed-guard.test.sh +++ b/lib/tests/unpushed-guard.test.sh @@ -66,8 +66,10 @@ 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 +check T14-invalid-prefix "$(has "$out" "ℹ manual push mode:")" yes +check T14-invalid-treated "$(has "$out" "treated as manual")" yes +check T14-invalid-no-warn "$(has "$out" "unpushed work")" no +check T14-invalid-stop-silent "$(fire Stop "$PWD")" silent 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