Skip to content
75 changes: 75 additions & 0 deletions bin/fm-branch-merge-lib.sh
Original file line number Diff line number Diff line change
@@ -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/<task-id> 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 <repo>: newline list of branch shortnames
# currently checked out in any worktree of <repo> (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 <repo> <branch> <merged_into_ref> [expected_tip]:
# the proof itself (see header). When <expected_tip> 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
# <branch> 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 <repo> <branch> <merged_into_ref>: delete
# <branch> in <repo> 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 <merged_into> 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"
}
47 changes: 46 additions & 1 deletion bin/fm-fleet-sync.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<task-id> 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
Expand All @@ -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"
Expand Down Expand Up @@ -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/<task-id> 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
Comment thread
trevorallred marked this conversation as resolved.
echo "$label: pruned $branch (merged into $default)"
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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
Expand Down
28 changes: 16 additions & 12 deletions bin/fm-teardown.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2407,32 +2407,36 @@ if [ "$BACKEND" = herdr ]; then
TEARDOWN_HERDR_PANE=$FM_BACKEND_HERDR_PANE
fi

# Detach and drop the task's own branch in <worktree> 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
require_orca_worktree_path_match_if_present "$ORCA_WORKTREE_ID" "$WT" || exit 1
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"
fi
[ -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"
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<default>`, 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/<task-id>` 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

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<task-id> 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
Expand Down
1 change: 1 addition & 0 deletions docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
Loading
Loading