Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions scripts/fleet/stranded-work.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.<n>.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
Expand Down
64 changes: 64 additions & 0 deletions scripts/fleet/test-stranded-work.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:"

Expand Down