From 2978d92c7b14deaf61f46443054dadc6ed4a6d01 Mon Sep 17 00:00:00 2001 From: Mao Nakamoto <41178744+maonakamoto@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:13:05 +0200 Subject: [PATCH] fix(fleet): 24 of the 25 "stranded" locations were merged PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard shipped yesterday reported 25 stranded locations and 26 unpushed commits, oldest 25 days. `gh pr list --state all` says MERGED for 24 of them. Every single one. The mechanism is the fleet's own happy path. An agent branches in a worktree, pushes, opens a PR, it squash-merges, GitHub deletes the remote branch, and a later `fetch --prune` drops the tracking ref. What survives is a local branch whose `branch..merge` is still configured and no longer resolves. `rev-parse @{u}` fails on that, so the old code took the same path as a branch that was never pushed at all and fell through to origin/main — where the pre-squash commit is unreachable by construction, because main received different sha carrying the same content. The count could therefore never reach zero. It was not a stale number; it was a permanent one. Upstream GONE is the opposite fact from upstream ABSENT: it is proof the branch DID leave the machine, which is precisely what puts it out of scope for a guard whose stated job is finding work GitHub cannot see. So read the config with for-each-ref and check whether the tracking ref resolves, instead of asking one question that both states answer the same way. Three tests pin it, including the must-still-fire half — never-pushed work and work ahead of a live upstream both still count. Reverting the fix turns the first of them red. Fleet after: 6 locations, 1 unpushed commit. Co-Authored-By: Claude Opus 5 --- scripts/fleet/stranded-work.sh | 31 ++++++++++++-- scripts/fleet/test-stranded-work.sh | 64 +++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/scripts/fleet/stranded-work.sh b/scripts/fleet/stranded-work.sh index 7d812f6..4e73ea9 100755 --- a/scripts/fleet/stranded-work.sh +++ b/scripts/fleet/stranded-work.sh @@ -88,9 +88,34 @@ scan_repo() { # Unpushed: measured against the upstream when there is one, else against the # remote default branch. A branch that was never pushed is the worse case, not # an exempt one — that is where four of fleetcrown's commits were hiding. - local base unpushed_count=0 unpushed_age=-1 oldest_commit - if git -C "$dir" rev-parse --verify -q '@{u}' >/dev/null 2>&1; then - base='@{u}' + # + # But "no upstream" and "upstream gone" are opposite facts, and conflating + # them made this guard report the whole fleet as stranded. The fleet's normal + # end of life for a branch is: push, PR, squash-merge, GitHub deletes the + # remote branch, a later `fetch --prune` drops the tracking ref. The local + # branch is left with `branch..merge` still configured and nothing to + # resolve it to. Falling through to origin/main then counts the pre-squash + # commit as unpushed FOREVER — it is not reachable from main and never will + # be, because main got a different commit with the same content. + # + # Measured 2026-08-26: 24 of the 25 locations this reported as stranded were + # merged PRs. Every one. `gh pr list --state all` said MERGED for all 24, the + # oldest 25 days — i.e. the guard's loudest number was entirely false, and it + # would have stayed false for as long as those worktrees existed. + # + # A configured-but-unresolvable upstream is therefore proof the branch DID + # leave the machine, which is exactly what puts it out of scope: this guard + # looks for work GitHub cannot see. Whether that pushed branch was merged or + # abandoned is a question for `gh`, not for a filesystem scan. + local base unpushed_count=0 unpushed_age=-1 oldest_commit upstream + upstream="$(git -C "$dir" for-each-ref --format='%(upstream:short)' \ + "refs/heads/$branch" 2>/dev/null)" + if [ -n "$upstream" ]; then + if git -C "$dir" rev-parse --verify -q "refs/remotes/$upstream" >/dev/null 2>&1; then + base="$upstream" + else + base='' # pushed, then the remote branch was deleted — not stranded + fi elif git -C "$dir" rev-parse --verify -q origin/main >/dev/null 2>&1; then base='origin/main' elif git -C "$dir" rev-parse --verify -q origin/master >/dev/null 2>&1; then diff --git a/scripts/fleet/test-stranded-work.sh b/scripts/fleet/test-stranded-work.sh index f4c7a85..84a4dad 100755 --- a/scripts/fleet/test-stranded-work.sh +++ b/scripts/fleet/test-stranded-work.sh @@ -187,6 +187,70 @@ out="$(FLEET_ROOT="$wt_root" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$ && ok "the main checkout is not re-scanned as its own worktree" \ || no "expected exactly one withwt line (out='$out')" +echo +echo "merged branches — the false positive that made the whole report noise:" + +# On 2026-08-26 this guard named 25 stranded locations. Twenty-four of them were +# MERGED PRs (`gh pr list --state all`, all 24, oldest 25 days). The mechanism: +# the fleet squash-merges, GitHub deletes the remote branch, `fetch --prune` +# drops the tracking ref, and the local branch keeps a `branch.*.merge` config +# pointing at a ref that no longer resolves. The old code read that as "no +# upstream" and fell through to origin/main, where the pre-squash commit is +# unreachable by construction — so it counted as unpushed permanently. +# +# These three pin the distinction the fix turns on: upstream GONE is not +# upstream ABSENT. +mrepo="$TMP/merged"; mkdir -p "$mrepo" +git init -q --bare "$TMP/merged.git" +d="$(mkrepo_at "$TMP" merged)" +git -C "$d" remote add origin "$TMP/merged.git" +git -C "$d" branch -M main +git -C "$d" push -q -u origin main + +git -C "$d" checkout -q -b feat/squashed +echo work > "$d/f.txt"; git -C "$d" add -A +GIT_COMMITTER_DATE="$(date -d '20 days ago' -Iseconds)" \ + git -C "$d" -c user.email=t@t -c user.name=t commit -qm "merged work" \ + --no-verify --date="$(date -d '20 days ago' -Iseconds)" +git -C "$d" push -q -u origin feat/squashed +# main gets the CONTENT under a different sha, exactly as a squash-merge does +git -C "$d" push -q origin --delete feat/squashed +git -C "$d" fetch -q --prune origin + +git -C "$d" config --get branch.feat/squashed.merge >/dev/null \ + && ok "the fixture reproduces it: upstream still configured after the prune" \ + || no "fixture is wrong — upstream config should survive a prune" + +line="$(scan_repo "$d")" +u="$(printf '%s' "$line" | cut -f4)" +[ "$u" = "0" ] \ + && ok "a pushed-then-deleted branch is not stranded — it already left the machine" \ + || no "merged branch must scan as 0 unpushed, got $u ('$line')" + +# The other half: never pushed at all is still the worst case and must fire. +git -C "$d" checkout -q -b feat/never-pushed main +echo other > "$d/g.txt"; git -C "$d" add -A +GIT_COMMITTER_DATE="$(date -d '20 days ago' -Iseconds)" \ + git -C "$d" -c user.email=t@t -c user.name=t commit -qm "real stranded work" \ + --no-verify --date="$(date -d '20 days ago' -Iseconds)" +line="$(scan_repo "$d")" +u="$(printf '%s' "$line" | cut -f4)"; uage="$(printf '%s' "$line" | cut -f5)" +[ "$u" = "1" ] && [ "$uage" -ge 19 ] \ + && ok "a branch that never had an upstream still counts (${uage}d)" \ + || no "never-pushed work must still be found, got u=$u age=$uage" + +# And a live upstream still measures against itself, not the default branch. +git -C "$d" push -q -u origin feat/never-pushed +echo more >> "$d/g.txt"; git -C "$d" add -A +GIT_COMMITTER_DATE="$(date -d '20 days ago' -Iseconds)" \ + git -C "$d" -c user.email=t@t -c user.name=t commit -qm "ahead of upstream" \ + --no-verify --date="$(date -d '20 days ago' -Iseconds)" +line="$(scan_repo "$d")" +u="$(printf '%s' "$line" | cut -f4)" +[ "$u" = "1" ] \ + && ok "a resolvable upstream is measured against, not origin/main" \ + || no "expected 1 commit ahead of upstream, got $u ('$line')" + echo echo "end to end:"