feat(gitflow): manual-push mode honoured by the lib, quiet unpushed-guard

`gitflow.autopush false` (human-set git config) now means "nothing is
pushed" end to end, not only in the post-commit/post-merge hooks:

- lib/gitflow.sh: `_gitflow_push_off` is the single reader of
  GITFLOW_NO_PUSH / gitflow.autopush for the lib's push sites; `start`
  and `finish` stop pushing in manual mode. `gitflow_delete` checks out
  the base that contains the branch and drops a lagging upstream before
  `git branch -d` (LRN-161: `-d` judges against the upstream when set).
  Skipped remote deletes say `left in place`; `_gitflow_sync_base`
  replaces the silent `pull --ff-only || true` and warns when a base is
  behind origin and cannot fast-forward.
- hooks/unpushed-guard.sh: manual mode is silent at Stop and gives one
  `ℹ manual push mode:` line at SessionStart counting every local
  branch; an unparseable value is named and treated as auto.
- CLAUDE.global.md: manual-push mode doctrine, "ahead = defect" scoped
  to auto mode.
- Tests: gitflow-test T18m block (T18m0, T18i-T18o, 7 cases),
  unpushed-guard T10-T16.

Follow-ups (TODO.md): run B push-guard hook + settings deny widening +
banner; run C skills that push on their own (/close STEP 5C, …).
Do not enable manual mode on the work machine before B and C land.
This commit is contained in:
bchanot
2026-10-06 17:49:20 +02:00
parent fa67664bac
commit 2fc88304ac
5 changed files with 172 additions and 11 deletions
+8 -4
View File
@@ -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
View File
@@ -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}}'
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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 ]