diff --git a/bin/fm-branch-merge-lib.sh b/bin/fm-branch-merge-lib.sh new file mode 100644 index 00000000000..5d6c88250f4 --- /dev/null +++ b/bin/fm-branch-merge-lib.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Shared "is this branch provably safe to delete?" decision procedure. +# +# ONE owner for the merged-branch proof that fm-fleet-sync.sh's periodic +# fm/ sweep relies on (fm-teardown.sh's own inline branch-drop stays +# on its existing, stronger, GitHub-aware landedness proof - see that +# script's header - and is not re-derived here). A branch is provably safe to +# delete iff ALL of the following hold: +# 1. it exists as a local branch in the repo; +# 2. it is not currently checked out in ANY worktree of that repo, linked or +# main - deleting a checked-out branch would fail anyway, but checking +# first keeps this a pure decision procedure with no destructive side +# effect on the caller's own probing; +# 3. its tip is an ancestor of the given "merged into" ref (a clean +# fast-forward, or any non-squash merge, already contains it - git's own +# `branch -d` can verify this itself). +# Upstream "[gone]" is deliberately excluded: deletion of a remote ref does +# not prove that its work landed. The separately-owned prune_gone_branches +# flow covers squash-merged PR cleanup, while fm-teardown.sh uses its existing +# GitHub-aware landedness check for inline cleanup. +# ANY uncertainty - the branch does not exist, a worktree still has it out, or +# neither proof holds - returns non-zero (NOT safe): fail safe, never force a +# delete this cannot prove. The caller decides what "not safe" means (leave it +# alone and move on, never an error worth failing over). + +# fm_branch_worktree_branches : newline list of branch shortnames +# currently checked out in any worktree of (the main checkout included). +fm_branch_worktree_branches() { + local repo=$1 + git -C "$repo" worktree list --porcelain 2>/dev/null | sed -n 's#^branch refs/heads/##p' +} + +# fm_branch_is_safely_merged [expected_tip]: +# the proof itself (see header). When is supplied, it additionally +# proves that is still the branch's current tip, binding a caller's later +# expected-old-value deletion to precisely the tip this proof examined. Never +# inspects or changes anything but refs. Refuses a +# that IS the "merged into" ref itself as a defensive guard against a +# caller accidentally naming the protected/default branch - every branch is +# trivially its own ancestor, so without this guard that self-comparison would +# otherwise look "safely merged". +fm_branch_is_safely_merged() { + local repo=$1 branch=$2 merged_into=$3 expected_tip=${4:-} tip + [ -n "$branch" ] || return 1 + [ "refs/heads/$branch" != "$merged_into" ] || return 1 + tip=$(git -C "$repo" rev-parse --verify --quiet "refs/heads/$branch") || return 1 + [ -z "$expected_tip" ] || [ "$tip" = "$expected_tip" ] || return 1 + fm_branch_worktree_branches "$repo" | grep -Fxq -- "$branch" && return 1 + if [ -n "$merged_into" ] \ + && git -C "$repo" merge-base --is-ancestor "$tip" "$merged_into" 2>/dev/null; then + return 0 + fi + return 1 +} + +# fm_branch_delete_if_safely_merged : delete +# in only when fm_branch_is_safely_merged proves it, printing +# nothing itself (callers report their own outcome). `git branch -d` always +# judges mergedness against its checkout's HEAD, rather than an explicit ref. +# Use a short-lived detached worktree at so its final safe delete +# repeats the SAME proof as the caller, while still letting Git refuse deletion +# if another worktree checked out the branch after our proof. Returns non-zero, +# unchanged, for anything the proof does not cover. +fm_branch_delete_if_safely_merged() { + local repo=$1 branch=$2 merged_into=$3 git_dir prune_worktree delete_status + fm_branch_is_safely_merged "$repo" "$branch" "$merged_into" || return 1 + git_dir=$(git -C "$repo" rev-parse --absolute-git-dir 2>/dev/null) || return 1 + prune_worktree=$(mktemp -d "$git_dir/fm-branch-prune.XXXXXX") || return 1 + rmdir "$prune_worktree" || return 1 + git -C "$repo" worktree add --detach -q "$prune_worktree" "$merged_into" || return 1 + git -C "$prune_worktree" branch -d -- "$branch" >/dev/null 2>&1 + delete_status=$? + git -C "$repo" worktree remove "$prune_worktree" >/dev/null 2>&1 || true + return "$delete_status" +} diff --git a/bin/fm-fleet-sync.sh b/bin/fm-fleet-sync.sh index d5c951e1a74..464de91b41c 100755 --- a/bin/fm-fleet-sync.sh +++ b/bin/fm-fleet-sync.sh @@ -12,7 +12,13 @@ # ... - needs attention" warning rather than a quiet drift. Nothing is ever forced, # stashed, or discarded. # Still skips (benignly) local-only/no-origin projects, missing remotes/branches, -# and fetch failures. +# and fetch failures for the remote-backed sync above - but every project, +# local-only or not, first gets an unconditional git-only sweep of its own +# fm/ branches (prune_merged_fm_branches, backed by the shared proof in +# fm-branch-merge-lib.sh): a backstop for a branch fm-teardown.sh's own inline +# cleanup did not or could not reach - e.g. a local-only fast-forward merge +# whose branch survived past teardown, or a PR merged outside firstmate's own +# flow. Never a factor in the STUCK/self-heal decisions above. # Pruning never deletes the checked-out branch or a branch that still has a # worktree, so it cannot discard unlanded work; set FM_FLEET_PRUNE=0 to disable it. # When the fetch fails on an orphaned .git/packed-refs.lock (left by a ref rewrite @@ -35,6 +41,8 @@ FM_HOME="${FM_HOME:-${FM_ROOT_OVERRIDE:-$FM_ROOT}}" PROJECTS="${FM_PROJECTS_OVERRIDE:-$FM_HOME/projects}" # shellcheck source=bin/fm-lock-lib.sh . "$SCRIPT_DIR/fm-lock-lib.sh" +# shellcheck source=bin/fm-branch-merge-lib.sh +. "$SCRIPT_DIR/fm-branch-merge-lib.sh" # Inert unless FM_TIMING_LOG names a file; only the deferred network stage sets it. # shellcheck source=bin/fm-timing-lib.sh . "$SCRIPT_DIR/fm-timing-lib.sh" @@ -247,6 +255,37 @@ prune_gone_branches() { --format='%(refname:short) %(upstream:track)' refs/heads 2>/dev/null) } +# Backstop for the task-branch cleanup fm-teardown.sh normally does inline: +# delete an fm/ branch whose tip is provably merged (fm-branch-merge-lib.sh's +# shared ancestor proof - a fast-forward or non-squash merge already contains +# its tip), scoped to firstmate's own fm/* naming so this never touches a branch +# firstmate did not create. A "[gone]" upstream is deliberately not enough to +# prove merge; the separately-owned prune_gone_branches flow covers +# squash-merged PR cleanup. This is the ONLY sweep +# that covers a local-only-mode project (prune_gone_branches above never runs +# for one, since local-only skips the whole remote-backed sync below, and a +# purely local fast-forward merge leaves no "[gone]" upstream to notice) and a +# task whose worktree/teardown ran before the branch could be dropped inline. +# Never the checked-out branch, and never a branch that still has a worktree +# (a live or not-yet-torn-down task) - fm_branch_is_safely_merged enforces +# both. Uses the LOCAL default branch as the merged-into ref, so it runs with +# no fetch and no origin dependency; a remote-backed project whose local +# default is still behind origin simply catches up on the next sweep once the +# fast-forward below advances it. Set FM_FLEET_PRUNE=0 to skip pruning entirely. +prune_merged_fm_branches() { + [ "${FM_FLEET_PRUNE:-1}" != "0" ] || return 0 + local default branch + default=$(default_branch) || return 0 + git -C "$PROJ" rev-parse --verify --quiet "refs/heads/$default^{commit}" >/dev/null || return 0 + while IFS= read -r branch; do + [ -n "$branch" ] || continue + [ "$branch" != "$default" ] || continue + if fm_branch_delete_if_safely_merged "$PROJ" "$branch" "refs/heads/$default"; then + echo "$label: pruned $branch (merged into $default)" + fi + done < <(git -C "$PROJ" for-each-ref --format='%(refname:short)' 'refs/heads/fm/*' 2>/dev/null) +} + # True when some worktree of $PROJ has $DEFAULT checked out (so we cannot attach # to it here). The current worktree is detached when this is consulted, so any # match is necessarily another worktree. @@ -304,6 +343,12 @@ sync_project() { echo "$label: skipped: not a git repo" return 0 fi + + # Runs before every mode/remote gate below - git-only, no fetch - so a + # local-only or no-remote project still gets its merged fm/* branches swept, + # which nothing else in this script otherwise does for those projects. + prune_merged_fm_branches || true + mode_line=$("$FM_ROOT/bin/fm-project-mode.sh" "$label" 2>/dev/null || echo "no-mistakes off") mode=${mode_line%% *} if [ "$mode" = "local-only" ]; then diff --git a/bin/fm-teardown.sh b/bin/fm-teardown.sh index c39c3f7c6a8..18e6a4a0e73 100755 --- a/bin/fm-teardown.sh +++ b/bin/fm-teardown.sh @@ -2407,6 +2407,20 @@ if [ "$BACKEND" = herdr ]; then TEARDOWN_HERDR_PANE=$FM_BACKEND_HERDR_PANE fi +# Detach and drop the task's own branch in if it is currently on +# one (a detached HEAD, or a checkout failure, leaves it untouched). Reaching +# this point already means the earlier landed/discard-work safety checks +# passed (or --force skipped them per the captain's explicit discard), so +# this is the one owner both call sites below use to actually drop the ref - +# never a second, independently-derived copy of the same two lines. +teardown_drop_task_branch() { + local wt=$1 branch + branch=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) + [ "$branch" != HEAD ] || return 0 + git -C "$wt" checkout --detach -q 2>/dev/null || return 0 + git -C "$wt" branch -D "$branch" >/dev/null 2>&1 || true +} + # Best-effort: drop the local task branch so the shared repo does not accumulate refs. if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then if [ "$ORCA_PATH_MATCH_VERIFIED" != 1 ]; then @@ -2414,12 +2428,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then ORCA_PATH_MATCH_VERIFIED=1 fi if [ -d "$WT" ]; then - branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) - if [ "$branch" != "HEAD" ]; then - if git -C "$WT" checkout --detach -q 2>/dev/null; then - git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true - fi - fi + teardown_drop_task_branch "$WT" rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ "$WT/.opencode/plugins/fm-busy-state.js" \ "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" @@ -2427,12 +2436,7 @@ if [ "$BACKEND" = orca ] && [ "$KIND" != secondmate ]; then [ -z "$T_ORCA" ] || fm_backend_kill "$BACKEND" "$T" "$(meta_value "$META" zellij_tab_id)" "fm-$ID" 2>/dev/null || true fm_backend_remove_worktree "$BACKEND" "$ORCA_WORKTREE_ID" elif [ -d "$WT" ] && [ "$KIND" != secondmate ]; then - branch=$(git -C "$WT" rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD) - if [ "$branch" != "HEAD" ]; then - if git -C "$WT" checkout --detach -q 2>/dev/null; then - git -C "$WT" branch -D "$branch" >/dev/null 2>&1 || true - fi - fi + teardown_drop_task_branch "$WT" # Remove our hook file so a reused pool worktree cannot fire signals for a dead task. rm -f "$WT/.claude/settings.local.json" "$WT/.opencode/plugins/fm-turn-end.js" \ "$WT/.fm-grok-turnend" "$WT/.fm-kimi-turnend" diff --git a/docs/architecture.md b/docs/architecture.md index f5857d56010..c091eccde67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -325,8 +325,9 @@ Wake-time refreshes can target a single clone by project name, so the primary ho Clean default-branch clones fast-forward to `origin/`, and a clean detached HEAD that holds no unique commits is re-attached to the default branch before the same fast-forward path runs. Dirty clones, non-default branches, detached HEADs with unique commits, diverged defaults, and default branches checked out in another worktree are reported as `STUCK:` with their behind count and left untouched. Fetches blocked by an orphaned `.git/packed-refs.lock` use bounded retries and remove the lock only when the shared staleness proof can prove it abandoned; [configuration.md](configuration.md#toolchain) owns the recovery details and tuning knobs. -Local-only projects, clones without an origin remote, and fetch failures remain benign skips. -The refresh also prunes local branches whose remote is gone and that no worktree still needs. +Local-only projects, clones without an origin remote, and fetch failures remain benign skips for remote refreshes. +Before those mode and remote gates, fleet sync also safely prunes firstmate-owned `fm/` branches whose tips are already contained in the local default branch and are not checked out in any worktree, so the backstop covers local-only and no-origin projects without discarding unmerged work. +For remote-backed projects, it additionally prunes local branches whose upstream is gone and that no worktree still needs. ## Self-updates stay safe diff --git a/docs/configuration.md b/docs/configuration.md index d9444dd5a19..6ac247ab8bf 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -660,7 +660,7 @@ FM_WORKTREE_WRITE_MAXDEPTH=6 # depth that same probe walks below the recor FM_WORKTREE_WRITE_TIMEOUT=10 # wall-clock seconds that one walk may take, so a worktree on a hung mount cannot stall the watcher poll that started it; hitting the bound reads as no write evidence, which leaves the escalation schedule exactly as it was; a value that is not a positive integer falls back to the default FM_WATCH_TRIAGE_LOG_MAX_BYTES=262144 # size cap for the watcher's absorbed-wake debug log FM_FLEET_SYNC_BOOTSTRAP_TIMEOUT= # optional seconds allowed for bootstrap's best-effort clone refresh; unset/blank defaults to max(20, 5 + 3 * origin-backed-project-count) -FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone +FM_FLEET_PRUNE=1 # set to 0 to skip pruning local branches whose upstream is gone, and the fm/ backstop sweep (a branch's tip must be an ancestor of the local default branch) that also runs for local-only-mode and no-origin projects FM_STALE_WORKTREE_LOCK_AGE_SECS=30 # min mtime age before fm-teardown.sh treats a leftover worktree git index.lock as provably stale FM_TREEHOUSE_RETURN_LOCK_RETRIES=3 # retries after a treehouse return fails on the transient git index.lock signature FM_TREEHOUSE_RETURN_LOCK_RETRY_WAIT_SECS=1 # seconds fm-teardown.sh waits before each retry after that signature diff --git a/docs/scripts.md b/docs/scripts.md index 9f219592d79..e67813304a9 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -14,6 +14,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-bootstrap.sh` | Detect toolchain and fleet problems, run the locked session-start sweeps, and install approved tools | | `fm-startup-network.sh` | Run session start's network checks off its blocking path in a bounded detached worker, and publish the result inline or as a wake | | `fm-fleet-sync.sh` | Refresh project clones with safe fast-forwards, self-heals, `STUCK:` reports, branch pruning, and bounded recovery from an orphaned `.git/packed-refs.lock` | +| `fm-branch-merge-lib.sh` | Shared proof and safe deletion helper for merged firstmate task branches | | `fm-fleet-snapshot.sh` | Print the read-only structured fleet snapshot JSON (schema `fm-fleet-snapshot.v1`) | | `fm-fleet-view.sh` | Render the fleet snapshot as a human Markdown view | | `fm-bearings-snapshot.sh` | Project the fleet snapshot to the compact TOON bearings view; local-only unless `--include-prs` | diff --git a/tests/fm-fleet-sync.test.sh b/tests/fm-fleet-sync.test.sh index b1fcd0a38e2..76750928291 100755 --- a/tests/fm-fleet-sync.test.sh +++ b/tests/fm-fleet-sync.test.sh @@ -12,6 +12,14 @@ # The pre-existing fast-forward / already-current / local-only / no-origin paths # must be unchanged, and bootstrap must relay the new outcomes as FLEET_SYNC lines. # +# It also pins the fm/ backstop sweep (prune_merged_fm_branches, +# backed by bin/fm-branch-merge-lib.sh's shared proof): a branch already +# fast-forward-merged into local main is pruned even for a local-only-mode +# project or a clone with no origin remote at all (prune_gone_branches never +# reaches either, since both skip the whole remote-backed sync); an unmerged +# branch, one still checked out in a worktree, and the project's own default +# branch are all left untouched. +# # It also pins the orphaned .git/packed-refs.lock recovery in the fetch step # (fetch_with_packed_refs_lock_guard, backed by bin/fm-lock-lib.sh's shared # staleness proof): a provably-stale lock is retried then removed and the clone @@ -370,6 +378,262 @@ test_local_only_skipped() { pass "local-only clone is skipped (benign), not flagged STUCK" } +# --- prune_merged_fm_branches fixtures -------------------------------------- +# +# fm-merge-local.sh's own effect, reproduced with plain git rather than by +# invoking that script: an fm/ branch with one real commit, fast-forward +# merged into local main. This is exactly the state fm-fleet-sync.sh's +# fm/ sweep (prune_merged_fm_branches, backed by +# bin/fm-branch-merge-lib.sh) must recognize as safely deletable. +ff_merge_task_branch() { + local clone=$1 branch=$2 file=$3 content=$4 + git -C "$clone" branch -q "$branch" + git -C "$clone" checkout -q "$branch" + printf '%s\n' "$content" > "$clone/$file" + git -C "$clone" add -- "$file" + git -C "$clone" -c user.email=t@t -c user.name=t commit -q -m "task work" + git -C "$clone" checkout -q main + git -C "$clone" merge -q --ff-only "$branch" +} + +branch_exists() { + git -C "$1" show-ref --verify --quiet "refs/heads/$2" +} + +test_local_only_prunes_merged_task_branch() { + local home clone out + home=$(new_home) + clone=$(build_pair "$home" sigma) + ff_merge_task_branch "$clone" fm/task-a feature.txt hello + mkdir -p "$home/data" + printf -- '- sigma [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "sigma: pruned fm/task-a" \ + "local-only project still gets its merged fm/* branch swept" + branch_exists "$clone" fm/task-a \ + && fail "local-only-prune: fm/task-a should have been deleted" + pass "the local-only backstop sweep prunes an fm/* branch already fast-forward-merged into local main" +} + +test_no_origin_prunes_merged_task_branch() { + local home clone out + home=$(new_home) + clone="$home/projects/xi" + git init -q "$clone" + git -C "$clone" symbolic-ref HEAD refs/heads/main + commit_file "$clone" file.txt v0 C0 + ff_merge_task_branch "$clone" fm/task-b feature.txt hello + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "xi: pruned fm/task-b" \ + "a remote-less project still gets its merged fm/* branch swept" + branch_exists "$clone" fm/task-b \ + && fail "no-origin-prune: fm/task-b should have been deleted" + pass "the backstop sweep runs with no origin remote at all (pure local-only clone, not just local-only mode)" +} + +test_detached_project_prunes_merged_task_branch() { + local home clone out + home=$(new_home) + clone=$(build_pair "$home" chi) + ff_merge_task_branch "$clone" fm/task-detached feature.txt hello + git -C "$clone" checkout -q --detach + mkdir -p "$home/data" + printf -- '- chi [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "chi: pruned fm/task-detached" \ + "a detached project still deletes a branch proved merged into main" + branch_exists "$clone" fm/task-detached \ + && fail "detached-prune: fm/task-detached should have been deleted" + pass "the shared sweep deletes against its explicit default-branch proof even from detached HEAD" +} + +test_unrelated_checkout_prunes_branch_merged_into_default() { + local home clone out + home=$(new_home) + clone=$(build_pair "$home" unrelated_head) + # Make an unrelated branch before main advances, so it does not contain the + # task commit. Fleet sync still must use its explicit main proof, rather than + # letting `git branch -d` accidentally judge mergedness against this HEAD. + git -C "$clone" branch -q unrelated + ff_merge_task_branch "$clone" fm/task-unrelated feature.txt hello + git -C "$clone" checkout -q unrelated + commit_file "$clone" unrelated.txt value "unrelated work" + mkdir -p "$home/data" + printf -- '- unrelated_head [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + out=$(run_sync "$home" "$clone") + + assert_contains "$out" "unrelated_head: pruned fm/task-unrelated" \ + "the sweep must delete a branch proved merged into main even from unrelated HEAD" + branch_exists "$clone" fm/task-unrelated \ + && fail "unrelated-head-prune: fm/task-unrelated should have been deleted" + pass "the final safe deletion uses the explicit default-branch merge target, not the caller's unrelated checkout" +} + +test_unmerged_task_branch_is_left_alone() { + local home clone out + home=$(new_home) + clone=$(build_pair "$home" omicron) + git -C "$clone" branch -q fm/task-c + git -C "$clone" checkout -q fm/task-c + commit_file "$clone" feature.txt hello "unmerged work" + git -C "$clone" checkout -q main + mkdir -p "$home/data" + printf -- '- omicron [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + out=$(run_sync "$home" "$clone") + + assert_not_contains "$out" "pruned fm/task-c" "an unmerged fm/* branch must never be reported as pruned" + branch_exists "$clone" fm/task-c \ + || fail "unmerged-prune: fm/task-c was deleted despite never being merged" + pass "the backstop sweep leaves an unmerged/diverged fm/* branch untouched" +} + +test_branch_update_between_proof_and_delete_is_left_alone() { + local home clone fakegit merged_tip unmerged_tip real_git + home=$(new_home) + clone=$(build_pair "$home" race) + ff_merge_task_branch "$clone" fm/task-race feature.txt merged + merged_tip=$(git -C "$clone" rev-parse fm/task-race) + git -C "$clone" checkout -q fm/task-race + commit_file "$clone" later.txt unmerged "concurrent unmerged update" + unmerged_tip=$(git -C "$clone" rev-parse fm/task-race) + git -C "$clone" checkout -q main + git -C "$clone" update-ref refs/heads/fm/task-race "$merged_tip" + fakegit="$home/fake-git"; mkdir -p "$fakegit" + real_git=$(command -v git) + cat > "$fakegit/git" <<'EOF' +#!/bin/sh +if [ "$1" = "-C" ] && [ "$2" = "$RACE_REPO" ] \ + && [ "$3" = "merge-base" ] && [ "$4" = "--is-ancestor" ]; then + "$REAL_GIT" "$@"; status=$? + if [ "$status" -eq 0 ]; then + "$REAL_GIT" -C "$RACE_REPO" update-ref "refs/heads/$RACE_BRANCH" "$RACE_NEW_TIP" + fi + exit "$status" +fi +exec "$REAL_GIT" "$@" +EOF + chmod +x "$fakegit/git" + + PATH="$fakegit:$PATH" REAL_GIT="$real_git" RACE_REPO="$clone" \ + RACE_BRANCH=fm/task-race RACE_NEW_TIP="$unmerged_tip" \ + bash -c '. "$1"; fm_branch_delete_if_safely_merged "$2" fm/task-race refs/heads/main' \ + bash "$ROOT/bin/fm-branch-merge-lib.sh" "$clone" \ + && fail "concurrent-branch-update: deletion unexpectedly succeeded" + + branch_exists "$clone" fm/task-race \ + || fail "concurrent-branch-update: branch was deleted after its tip changed" + [ "$(git -C "$clone" rev-parse fm/task-race)" = "$unmerged_tip" ] \ + || fail "concurrent-branch-update: updated unmerged tip was not preserved" + pass "a branch update between merged proof and deletion is left intact" +} + +test_worktree_added_between_proof_and_delete_is_left_alone() { + local home clone fakegit real_git worktree + home=$(new_home) + clone=$(build_pair "$home" worktree_race) + ff_merge_task_branch "$clone" fm/task-worktree-race feature.txt merged + fakegit="$home/fake-git"; mkdir -p "$fakegit" + worktree="$home/active-task-worktree" + real_git=$(command -v git) + cat > "$fakegit/git" <<'EOF' +#!/bin/sh +if [ "$1" = "-C" ] && [ "$3" = "branch" ] \ + && [ "$4" = "-d" ] && [ "$6" = "$RACE_BRANCH" ]; then + "$REAL_GIT" -C "$RACE_REPO" worktree add -q "$RACE_WORKTREE" "$RACE_BRANCH" +fi +exec "$REAL_GIT" "$@" +EOF + chmod +x "$fakegit/git" + + PATH="$fakegit:$PATH" REAL_GIT="$real_git" RACE_REPO="$clone" \ + RACE_BRANCH=fm/task-worktree-race RACE_WORKTREE="$worktree" \ + bash -c '. "$1"; fm_branch_delete_if_safely_merged "$2" fm/task-worktree-race refs/heads/main' \ + bash "$ROOT/bin/fm-branch-merge-lib.sh" "$clone" \ + && fail "concurrent-worktree-checkout: deletion unexpectedly succeeded" + + branch_exists "$clone" fm/task-worktree-race \ + || fail "concurrent-worktree-checkout: branch was deleted after a worktree checked it out" + [ "$(git -C "$worktree" symbolic-ref --short HEAD)" = "fm/task-worktree-race" ] \ + || fail "concurrent-worktree-checkout: linked worktree did not retain the task branch" + pass "a worktree checkout between merged proof and deletion leaves the branch intact" +} + +test_gone_unmerged_task_branch_is_left_alone() { + local home clone remote out + home=$(new_home) + clone=$(build_pair "$home" upsilon) + remote="$home/remotes/upsilon.git" + git -C "$clone" checkout -q -b fm/task-gone + commit_file "$clone" feature.txt hello "unmerged work" + git -C "$clone" push -q -u origin fm/task-gone + git --git-dir="$remote" update-ref -d refs/heads/fm/task-gone + git -C "$clone" fetch -q --prune origin + mkdir -p "$home/data" + printf -- '- upsilon [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + out=$(run_sync "$home" "$clone") + + assert_not_contains "$out" "pruned fm/task-gone" "a [gone] upstream without a merge must never be reported as pruned" + branch_exists "$clone" fm/task-gone \ + || fail "gone-unmerged-prune: fm/task-gone was deleted despite never being merged" + [ "$(git -C "$clone" for-each-ref --format='%(upstream:track)' refs/heads/fm/task-gone)" = "[gone]" ] \ + || fail "gone-unmerged-prune: expected pruned tracking branch to report [gone]" + pass "the backstop sweep leaves an unmerged fm/* branch with a [gone] upstream untouched" +} + +test_checked_out_task_branch_is_left_alone() { + local home clone out wt + home=$(new_home) + clone=$(build_pair "$home" pi_project) + ff_merge_task_branch "$clone" fm/task-d feature.txt hello + # Add a worktree on the branch, exactly like a live/not-yet-torn-down task: + # still checked out even though its content already landed on main. + wt="$home/wt-task-d" + git -C "$clone" worktree add -q "$wt" fm/task-d + mkdir -p "$home/data" + printf -- '- pi_project [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + out=$(run_sync "$home" "$clone") + + assert_not_contains "$out" "pruned fm/task-d" "a branch still checked out in a worktree must never be reported as pruned" + branch_exists "$clone" fm/task-d \ + || fail "checked-out-prune: fm/task-d was deleted while still checked out in $wt" + pass "the backstop sweep leaves an fm/* branch with an active worktree untouched" +} + +test_prune_never_targets_the_default_branch() { + local home clone + home=$(new_home) + clone=$(build_pair "$home" rho) + mkdir -p "$home/data" + printf -- '- rho [local-only] - test project (added 2026-06-27)\n' > "$home/data/projects.md" + + run_sync "$home" "$clone" >/dev/null + + branch_exists "$clone" main || fail "default-branch-guard: main was removed by the sweep" + [ "$(git -C "$clone" symbolic-ref --quiet --short HEAD 2>/dev/null)" = main ] \ + || fail "default-branch-guard: the clone is no longer on main after the sweep" + + # Direct exercise of fm-branch-merge-lib.sh's own defensive guard (its public + # function interface, not its source text): a caller that ever named the + # default branch itself as the delete candidate must still be refused, since + # every branch is trivially its own ancestor. + # shellcheck source=bin/fm-branch-merge-lib.sh disable=SC1091 + . "$ROOT/bin/fm-branch-merge-lib.sh" + if fm_branch_is_safely_merged "$clone" main refs/heads/main; then + fail "default-branch-guard: fm_branch_is_safely_merged approved deleting the branch named as its own merge target" + fi + pass "the sweep and its shared proof both refuse to ever target the default/protected branch" +} + test_single_project_by_bare_name_resolves() { local home out home=$(new_home) @@ -613,6 +877,16 @@ test_on_default_clean_behind_fast_forwards test_already_current_unchanged test_no_origin_skipped test_local_only_skipped +test_local_only_prunes_merged_task_branch +test_no_origin_prunes_merged_task_branch +test_detached_project_prunes_merged_task_branch +test_unrelated_checkout_prunes_branch_merged_into_default +test_unmerged_task_branch_is_left_alone +test_branch_update_between_proof_and_delete_is_left_alone +test_worktree_added_between_proof_and_delete_is_left_alone +test_gone_unmerged_task_branch_is_left_alone +test_checked_out_task_branch_is_left_alone +test_prune_never_targets_the_default_branch test_single_project_by_bare_name_resolves test_single_project_by_bare_name_ignores_cwd_shadow test_single_project_by_projects_relative_name_resolves diff --git a/tests/fm-teardown.test.sh b/tests/fm-teardown.test.sh index a0815a967e8..af056fbdb54 100755 --- a/tests/fm-teardown.test.sh +++ b/tests/fm-teardown.test.sh @@ -210,6 +210,13 @@ write_meta() { "mode=$mode" } +# True when refs/heads/fm/task-x1 still exists in the shared project repo +# ($case_dir/wt is a linked worktree of $case_dir/project, so either path +# resolves the same ref). Args: case_dir +task_branch_exists() { + git -C "$1/project" show-ref --verify --quiet refs/heads/fm/task-x1 +} + # Commit something on the worktree's task branch. Args: case_dir [message] wt_commit() { local case_dir=$1 msg=${2:-wt work} @@ -650,7 +657,13 @@ test_local_only_merged_to_local_main_allows() { expect_code 0 "$rc" "merged-main: teardown should succeed when work is merged into local main" ! grep -q REFUSED "$case_dir/stderr" || fail "merged-main: teardown printed a REFUSED line" - pass "local-only worktree with work merged into local main is torn down (no regression)" + # This is the exact concrete-trigger shape: a local-only task whose branch + # was fast-forward merged into main (bin/fm-merge-local.sh's own effect). + # Its fm/task-x1 branch must not be left behind for firstmate to clean up by + # hand once teardown itself has confirmed the merge and torn the task down. + task_branch_exists "$case_dir" \ + && fail "merged-main: fm/task-x1 was left behind after a landed local-only teardown" + pass "local-only worktree with work merged into local main is torn down (no regression), and its task branch is dropped inline" } test_no_mistakes_origin_remote_allows() { @@ -764,7 +777,13 @@ test_no_pr_recorded_discovers_merged_pr_by_branch_allows() { expect_code 0 "$rc" "no-pr-branch-discovery: teardown should succeed by discovering the merged PR from the branch name" ! grep -q REFUSED "$case_dir/stderr" || fail "no-pr-branch-discovery: teardown printed a REFUSED line" - pass "teardown discovers a merged PR by branch name and tears down when no pr= was ever recorded" + # A PR merged outside firstmate's own fm-pr-check.sh/fm-pr-merge.sh flow (no + # pr= ever recorded) is fully reconciled by this same discovery path: once + # teardown proceeds, the task's own fm/task-x1 branch becomes eligible for + # - and gets - the same inline cleanup as any other landed task. + task_branch_exists "$case_dir" \ + && fail "no-pr-branch-discovery: fm/task-x1 was left behind after teardown reconciled the externally-merged PR" + pass "teardown discovers a merged PR by branch name, tears down when no pr= was ever recorded, and drops its task branch" } test_squash_merged_pr_allows_replayed_unpushed_patch() {