Skip to content

fix(bin): prune merged task branches across all project modes - #2768

Open
trevorallred wants to merge 10 commits into
kunchenguid:mainfrom
trevorallred:fm/firstmate-merged-branch-hardening
Open

fix(bin): prune merged task branches across all project modes#2768
trevorallred wants to merge 10 commits into
kunchenguid:mainfrom
trevorallred:fm/firstmate-merged-branch-hardening

Conversation

@trevorallred

Copy link
Copy Markdown

Intent

fm-teardown.sh (and the merge scripts it works with - fm-merge-local.sh for local-only tasks, and the no-mistakes/direct-PR paths) did not delete a task's own fm/ branch after its work was confirmed merged. Concrete trigger: five rapid-fire local-only ship tasks against one project each left their fm/ branch behind even though every one had already been safely fast-forward-merged by fm-merge-local.sh; firstmate cleaned those up by hand with git branch -d (a safe delete that only succeeds when git itself proves the branch is already fully merged - that same safety property must be encoded here, never a forced delete). An earlier one-off task (fleet-merged-branch-cleanup) had already found the same class of stale branches across multiple projects and delivery modes, confirming this is a general lifecycle gap, not local-only-specific.

Required:

  1. Inline cleanup as the primary mechanism (not just a periodic sweep): once a task's work is confirmed landed, delete its local fm/ branch as part of teardown or immediately after the local-only fast-forward merge - whichever fits the existing script boundaries better. The "is this branch safely deletable" check must live in exactly one place, not be duplicated per delivery mode. Local-only: after fm-merge-local.sh's fast-forward succeeds, the source branch is provably fully merged - safe to delete immediately (git branch -d's own guarantee). no-mistakes/direct-PR: only after the branch's tip is provably contained in the current default branch, mirroring the check fleet-merged-branch-cleanup's prior one-off audit used. Never force-delete an unmerged or diverged branch - if the safety check can't prove the branch is merged, leave it alone and move on, not an error worth failing teardown over.
  2. A bounded periodic sweep as a backstop for anything that slips past inline cleanup (e.g. a PR merged outside firstmate's own flow, so no teardown ever ran for it), reusing the same safe-deletion check from item 1 rather than a second implementation.
  3. Also cover the "completed/externally merged task is reconciled" half of the original scope: when firstmate discovers that a task's PR was merged outside the normal fm-pr-merge.sh/fm-merge-local.sh flow, that gets reconciled (teardown becomes safe to run, the branch becomes eligible for the same cleanup) - verified today's existing PR-merge-then-teardown flow (branch-name PR discovery fallback) already covers this, so this item just needed a regression test proving it, not new mechanism.

Regression coverage required: tests colocated in tests/ (.test.sh naming, extending an existing runner rather than inventing one) proving a genuinely merged branch gets deleted, an unmerged/diverged branch is left alone, and the periodic sweep doesn't touch protected/default branches or branches with an active worktree still checked out. Tests must exercise real behavior through the actual scripts, never assert on script source text.

Decisions made while implementing:

  • Investigation showed fm-teardown.sh already dropped a task's own branch inline once landedness (or an explicit --force discard) was established, so that existing behavior for the no-mistakes/direct-PR path was preserved unchanged rather than reworked, since swapping it to a lighter ancestor/gone-tracking-only check would have been a real regression for the common squash-merge case (squash commits are never ancestors, and gone-tracking may not be fresh at that exact moment). The genuine, concrete gap was that fm-fleet-sync.sh's existing periodic prune (prune_gone_branches, gone-tracking only) requires a remote and skips local-only-mode and origin-less projects entirely - exactly the project shape in the concrete trigger - so it never ran at all for those branches.
  • Added bin/fm-branch-merge-lib.sh as the one shared owner of the "is this branch provably safe to delete" proof (not checked out in any worktree, and either an ancestor of the merged-into ref or an upstream tracking "[gone]"), used by a new fm-fleet-sync.sh sweep (prune_merged_fm_branches) scoped to firstmate's own fm/* branch naming, run unconditionally before any mode/remote gate so it also covers local-only and no-origin projects.
  • fm-teardown.sh's own two duplicated inline branch-drop code blocks (orca and generic worktree paths) were deduplicated into one local helper function with no behavior change, since that duplication already existed before this task and the new shared library was not substituted in (see above).
  • Verified today's existing externally-merged-PR reconciliation (fm-teardown.sh's branch-name PR discovery fallback when no pr= was ever recorded) already works via a pre-existing passing test; extended it and the existing local-only-merged-to-local-main test with assertions that the task branch is actually deleted afterward, rather than writing a new mechanism.

What Changed

  • Added a shared safe-deletion proof and fleet-sync backstop that prunes merged fm/<task-id> branches, including local-only and no-origin projects, while preserving active worktrees and unmerged branches.
  • Consolidated teardown’s task-branch cleanup helper and documented the expanded branch-pruning behavior and configuration.

Risk Assessment

🚨 High: The changed teardown helper still force-deletes a task branch despite the authoritative intent explicitly requiring non-forced deletion only when safety is provable.

Testing

Exercised the affected shell interfaces end-to-end with real temporary Git repositories: merged fm/* branches were pruned in local-only and no-origin projects; unmerged, gone-but-unmerged, active-worktree, and default branches remained intact; teardown removed branches after both local and externally merged PR flows while refusing unpushed work. Reviewer-visible CLI transcripts were saved as evidence.

Evidence: Fleet-sync branch lifecycle transcript

Source: Fleet-sync branch lifecycle transcript

ok - detached clean ancestor is re-attached and fast-forwarded (recovered)
ok - detached HEAD with unique commits is reported STUCK and left untouched
ok - detached clean ancestor with diverged local default is reported STUCK and left untouched
ok - dirty working tree is reported STUCK and left untouched
ok - non-default named branch is reported STUCK and left untouched
ok - diverged default branch is reported STUCK and left untouched
ok - on-default clean behind clone still fast-forwards
ok - already-current clone is reported unchanged
ok - no-origin clone is skipped (benign), not flagged STUCK
ok - local-only clone is skipped (benign), not flagged STUCK
ok - the local-only backstop sweep prunes an fm/* branch already fast-forward-merged into local main
ok - the backstop sweep runs with no origin remote at all (pure local-only clone, not just local-only mode)
ok - the backstop sweep leaves an unmerged/diverged fm/* branch untouched
ok - the backstop sweep leaves an unmerged fm/* branch with a [gone] upstream untouched
ok - the backstop sweep leaves an fm/* branch with an active worktree untouched
ok - the sweep and its shared proof both refuse to ever target the default/protected branch
ok - single-project form accepts a bare project name
ok - single-project bare name resolution is not cwd-sensitive
ok - single-project form accepts a projects/<name> relative name
ok - single-project projects/<name> resolution is not cwd-sensitive
ok - single-project form leaves a genuinely bad name unresolved
ok - whole-fleet form processes every clone under projects/
ok - bootstrap relays recovered: and STUCK: fleet-sync outcomes
ok - orphaned provably-stale packed-refs.lock is cleared and the clone syncs
ok - a live packed-refs.lock is never removed and the sync fails loudly
ok - a live process holding the clone worktree dir blocks lock removal (clone-dir liveness)
ok - a transient packed-refs.lock that self-clears is retried without a force-remove
ok - a non-packed-refs.lock fetch failure keeps today's behavior (no retry)
Evidence: Teardown merged/local and externally merged PR transcript

Source: Teardown merged/local and externally merged PR transcript

ok - local-only worktree with work merged into local main is torn down (no regression), and its task branch is dropped inline
ok - teardown discovers a merged PR by branch name, tears down when no pr= was ever recorded, and drops its task branch
ok - local-only worktree with truly unpushed work is refused (safety preserved)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 error
  • 🚨 bin/fm-branch-merge-lib.sh:49 - [gone] only proves that the upstream ref was deleted; it does not prove the branch was merged. A reachable path is an unmerged fm/&lt;id&gt; branch whose remote branch is manually deleted, followed by fetch --prune: this returns [gone] at line 49 and line 67 force-deletes its unique local commits. That contradicts the required invariant that unmerged/diverged branches are never force-deleted. Require an actual landedness proof before deletion, or leave [gone] branches for the existing teardown/PR reconciliation path.

🔧 Fix: Prevent unsafe gone-upstream branch deletion
1 error still open:

  • 🚨 bin/fm-teardown.sh:2421 - The diff retains a forced git branch -D in the new shared teardown helper. This contradicts the required criterion, “Never force-delete an unmerged or diverged branch” and the stated requirement to retain Git’s branch -d safety guarantee. The preceding landedness check can permit squash/content-equivalent paths whose tip is not an ancestor; confirm whether preserving that existing behavior is an intended exception, or change this helper to use the shared safe-delete proof and plain -d.
✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-fleet-sync.test.sh
  • Focused teardown cases executed from tests/fm-teardown.test.sh: test_local_only_merged_to_local_main_allows, test_no_pr_recorded_discovers_merged_pr_by_branch_allows, and test_local_only_truly_unpushed_refuses
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed (3) ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix: Verify repository lint passes cleanly
1 warning still open:

  • ⚠️ linter found issues (exit code 1)

🔧 Fix: Verify pinned lint passes cleanly
1 warning still open:

  • ⚠️ linter found issues (exit code 1)

🔧 Fix: Verify repository lint passes cleanly
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

fm-teardown.sh already dropped a task's own fm/<task-id> branch inline once
its work was confirmed landed, but a local-only-mode (or origin-less) project
had no backstop for a branch that survived past that point - fm-fleet-sync.sh's
existing periodic prune only recognized a squash-merged PR's now-gone remote
branch, and skipped the whole remote-backed sync (including that prune) for
exactly the projects with no remote to notice "gone" tracking on. That gap
matched a concrete trigger: five rapid local-only ship tasks against a
local-only project each left their fm/<task-id> branch behind even though
bin/fm-merge-local.sh had already fast-forward-merged every one of them, and
firstmate cleaned them up by hand with `git branch -d`.

Add bin/fm-branch-merge-lib.sh, the one owner of "is this branch provably safe
to delete": not checked out in any worktree, and either an ancestor of the
merged-into ref (a clean fast-forward or non-squash merge - git's own safe
`branch -d` can verify this itself) or its upstream tracking reads "[gone]"
(a squash-merged PR's remote branch was deleted). Never force-deletes past
that proof.

fm-fleet-sync.sh gains prune_merged_fm_branches, a git-only sweep of a
project's own fm/* branches using that shared proof, run unconditionally
before any mode/remote gate - so it also covers local-only and no-origin
projects, which the existing remote-backed prune_gone_branches never reaches.
fm-teardown.sh's own inline branch-drop is refactored (no behavior change)
into one local helper instead of two duplicated copies.

Regression coverage: fm-fleet-sync.test.sh gains cases proving the sweep
prunes a genuinely fast-forward-merged fm/* branch (including with no origin
remote at all), leaves an unmerged/diverged branch and one still checked out
in an active worktree untouched, and never targets the project's own default
branch. fm-teardown.test.sh's existing local-only-merged and
no-pr-recorded/externally-merged-PR cases now also assert the task branch is
actually dropped, closing the concrete trigger and confirming today's
externally-merged-PR reconciliation already covers that case end to end.
@greptile-apps

greptile-apps Bot commented Aug 22, 2026

Copy link
Copy Markdown

Confidence Score: 4/5

The PR should not merge until the merged-task pruning sweep requires explicit captain authorization rather than running by default.

Fleet sync still invokes a branch-deleting sweep when the pruning environment setting is unset, so qualifying local task refs are removed without the explicit opt-in required for newly expanded destructive behavior.

Files Needing Attention: bin/fm-fleet-sync.sh, docs/configuration.md

Reviews (8): Last reviewed commit: "fix(bin): make the merged-branch backsto..." | Re-trigger Greptile

Comment thread bin/fm-branch-merge-lib.sh Outdated
Comment thread bin/fm-fleet-sync.sh
Comment thread bin/fm-branch-merge-lib.sh Outdated
Comment thread bin/fm-branch-merge-lib.sh Outdated
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

VISION: mixed. The new fm/* backstop sweep is correctly opt-in (FM_FLEET_PRUNE_MERGED defaults to 0), which matches "anything destructive requires the captain's explicit word." The teardown helper is a no-behavior-change extract of the existing landedness-then-branch -D path, not a new default. Class: opt-in.

What is not merge-ready:

  1. Greptile P1, real: fm_branch_delete_if_safely_merged uses update-ref -d after a worktree occupancy scan. A same-tip checkout that appears in that window deletes an active branch; update-ref -d does not refuse a checked-out ref the way git branch -d does.
  2. Docs drift: docs/architecture.md describes the merged-fm/* prune as something fleet sync just does, without the enable flag. That reads as default-on even though the code is not.
  3. Greptile's "default-on destructive sweep" P1 is wrong about the code (the function returns immediately unless FM_FLEET_PRUNE_MERGED=1) and is tracking the docs lie in (2).

Security: none. Residual: once enabled, this is ref deletion; keep it captain-gated.

Overlap / do not land together: bin/fm-teardown.sh and tests/fm-teardown.test.sh overlap the hold pair #2637 / #2692. Do not land with that pair.

CI: first-time fork workflows approved after diff review. Greptile is red. no-mistakes is pending and blocking. Ahead 6, behind 0, mergeable.

Waiting on the author for the worktree race and the architecture.md default-on wording — not waiting on the captain.

Merge-eligible: NO. Captain-flag NOW: NO.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Corrective: after confirmed merge, delete the task's own fm/<id> branch with a safe delete (never force). Aligns (cleanup durability; unlanded work stays).

Not merge-eligible while CI is unfinished. File overlap with held teardown pair #2637/#2692 on fm-teardown.sh / teardown tests — do not land together.

Waiting on CI — not waiting on the captain.

Comment thread bin/fm-fleet-sync.sh
The prior CI fix round gated prune_merged_fm_branches behind a new
FM_FLEET_PRUNE_MERGED opt-in (default off), responding to an automated
review concern that a destructive fleet-sync mutation should not default
on. That concern does not hold here: fm-fleet-sync.sh is AGENTS.md's own
named exception to "never write to a project" (fleet sync, secondmate
sync, and a few other guarded paths are explicitly carved out), and
prune_gone_branches in this exact file already deletes local branches
from a project clone by default, gated only by the pre-existing
FM_FLEET_PRUNE variable. prune_merged_fm_branches extends that same,
already-authorized mechanism to close a coverage gap (local-only and
no-origin projects), not new destructive authority - so it belongs under
the same default-on gate, matching the concrete trigger this whole change
exists to fix (a task branch left behind with nothing to notice or clean
it up automatically).

Reverts the gate to FM_FLEET_PRUNE (default on, matching
prune_gone_branches), removes every FM_FLEET_PRUNE_MERGED reference from
comments and docs, and drops the now-inapplicable
test_merged_task_branch_requires_explicit_prune_authority test along with
its run_sync_with_merged_prune helper, restoring the other prune-positive
tests to plain run_sync. The atomic branch-d-from-a-detached-worktree
delete mechanism, the expected-tip and worktree-race regression tests, and
the [gone]-without-merge test from the intervening CI fix rounds are kept
unchanged - only the gating variable and the prose/tests describing it
move back to default-on.
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

VISION: aligns on cleanup durability and the refusal to discard unlanded work. Shared fm_branch_is_safely_merged proof deletes an fm/<task-id> ref only when its tip is an ancestor of local default and no worktree has it checked out; never force-delete. Teardown helper is an extract of the existing landedness-then-branch -D path, not a new default.

Class: corrective. Backstop sweep prune_merged_fm_branches closes the local-only / no-origin gap under the existing FM_FLEET_PRUNE gate (default 1, same as prune_gone_branches). Not a new destructive authority.

Security: none. Git-only ancestor proof, scoped to refs/heads/fm/*, refuses the default branch, refuses checked-out branches. No credentials, no workflow file.

Overlap / HOLD: touches bin/fm-teardown.sh and teardown tests. Do not land with hold-pair #2637 / #2692, and do not land with #2760 / #2770 (spawn/teardown lifecycle). Also overlaps #2586 (teardown rewrite currently reverts GitLab merge from #2779) and #2792 (herdr.sh isolation vs hold-pair #2637) on teardown files. Spawn-freshen #2622 / #2693 / #2154 still open — not this PR's files, named only as fleet context.

CI: HEAD a5a23968c688be977ed38ff56eefc02db0fa561f. ahead 10 / behind 4 (diverged from main). CI run 32583347298 in_progress (approved this pass). Body-compliance 32583347319 queued. Structured attestation head_sha=0c82024da2504c7c4c08162f93c62db72fe628ee does NOT match THIS HEAD — no-mistakes is blocking. Greptile FAILURE — not a gate.

Workflows approved: yes (CI 32583347298, NM 32583347319). Land-eligible: NO even if CI later greens, because of the pair hold. CI/NM not green this pass, so waiting on the author, not the captain. If later fully green except the pair, that is a captain-flag for the pair decision — not now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants