forked from bchanot/claude
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:
@@ -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 ]
|
||||
|
||||
Reference in New Issue
Block a user