@feigi/fleet-ctl 3.79.6 → 3.80.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.79.6",
3
+ "version": "3.80.1",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/scripts/reap.sh CHANGED
@@ -434,11 +434,15 @@ keep() {
434
434
  # subshell before `gp_rc=$?` or the printf below ever run, and `gp_raw` comes
435
435
  # back empty — silently, at the one moment this function exists to not be
436
436
  # silent (measured on this exact shape).
437
+ #
438
+ # The capture is `probe`, over any command; `git_probe` is `probe git`.
439
+ # pr_load's gh call below is the one other caller: its stdout fields are
440
+ # refnames, numbers, object ids and fixed words, none of which can hold 0x02.
437
441
  gp_sep=$(printf '\002')
438
- git_probe() {
442
+ probe() {
439
443
  gp_raw=$(
440
444
  {
441
- if gp_o=$(git "$@" 2>&3); then gp_rc=0; else gp_rc=$?; fi
445
+ if gp_o=$("$@" 2>&3); then gp_rc=0; else gp_rc=$?; fi
442
446
  printf '%s' "$gp_sep$gp_o$gp_sep$gp_rc"
443
447
  } 3>&1
444
448
  )
@@ -448,6 +452,7 @@ git_probe() {
448
452
  gp_rc=${gp_raw#*"$gp_sep"}
449
453
  return "$gp_rc"
450
454
  }
455
+ git_probe() { probe git "$@"; }
451
456
 
452
457
  # True when git's stderr says the directory walk was CUT SHORT — entries are
453
458
  # missing from $gp_out — as opposed to git merely having said something.
@@ -669,6 +674,296 @@ null_head_why() {
669
674
  return 0
670
675
  }
671
676
 
677
+ # THE SECOND MERGE PROOF. `git cherry` answers one question — is each of this
678
+ # branch's commits patch-equivalent to something on $base — and answers it
679
+ # wrongly for a claim the merge bot rebased server-side onto a `main` that had
680
+ # changed the claim's own context lines: the rebased commit carries different
681
+ # context, so a different patch id, and the original reads `+` although
682
+ # everything on it was merged. (A `+` is therefore not always "a commit that
683
+ # exists nowhere else"; the keep it produces is safe but leaky.) Measured on
684
+ # this repository's own [gone] branches, most of the stranded tips are not the
685
+ # bot's doing at all: a member that pushes `HEAD:<branch>` from a detached
686
+ # worktree replaces the remote branch and never moves the local ref, so the
687
+ # local tip is a head the PR's remote branch once held.
688
+ #
689
+ # That is the fact this proof reads, and only GitHub records it: the PR's
690
+ # `headRefOid` and the `beforeCommit` of every force-push on it are the heads
691
+ # its branch held. A tip that equals one, or is an ancestor of one this clone
692
+ # has, was pushed to the PR's remote branch and then replaced by its owner's
693
+ # force-push — nothing on it was ever local-only. Equality or ancestry is
694
+ # enough; no range-diff pairing, which was measured to reap none of the
695
+ # stranded tips (every branch has a `!` pair, context drift again) and which,
696
+ # if it accepted `!`, would also reap a local amend that was never pushed.
697
+ #
698
+ # It is additive. It runs only for a branch `git cherry` already keeps, after
699
+ # that verdict, and only ever turns that keep into a reap: the cherry proof,
700
+ # the worktree guards, the holder re-check, the compare-and-swap on the tip
701
+ # read once and `wt_recheck_delete` are all unchanged, and the tie is computed
702
+ # on that same frozen tip. There is no name filter.
703
+ #
704
+ # One lookup per branch, by head name, MERGED PRs only, and a KEEP with its
705
+ # reason unless ALL of: exactly one PR matches (a reused name can match
706
+ # several); its timeline of force-pushes has no next page, because the tie
707
+ # would then be read off a partial list; its base is the branch BASE_REF
708
+ # names; it is not cross-repository; its `headRefOid` is a commit present
709
+ # here and an ancestor of $base_rev — the ancestry is asked of git, not
710
+ # trusted from the API; and the tip equals one of the PR's recorded heads or
711
+ # is an ancestor of one present here. A squash merge's head is not on $base,
712
+ # so it stays kept.
713
+ #
714
+ # One aliased `gh api graphql` lookup per run, one call per $pr_chunk branches
715
+ # and issued the first time a branch needs it, covers every [gone] branch (a
716
+ # superset of the ones waiting: which will need it is only known branch by
717
+ # branch). Branch names go in as GraphQL VARIABLES, never spliced into the
718
+ # query text — `"` is legal in a refname.
719
+ # Output is captured, never piped, and the call runs under a watchdog. Each of
720
+ # these keeps every affected branch with its own reason and the run carries on:
721
+ # gh missing, a non-zero exit with no complete body (a non-zero exit that DID
722
+ # print a complete body — gh does so when the response also carries an
723
+ # `errors` array — is read, and any alias without an entry keeps, with gh's
724
+ # exit status and stderr in its reason), output that is not the shape the
725
+ # script's own `--jq` produces, and a timeline with a next page. Nothing is
726
+ # cached between runs, so an untied branch is asked about again on every pass.
727
+ #
728
+ # It does not apply — and the reason stays the bare `unmerged commits` — where
729
+ # the repository has no GitHub identity to ask about: $base is not a
730
+ # remote-tracking ref of `origin`, or `remote.origin.url` is not a
731
+ # `host/owner/repo` URL (a local path, say). That is not a failure of the
732
+ # proof, there is simply nobody to put the question to. `git config`, never
733
+ # `git remote get-url`: the latter applies `insteadOf` rewrites, which would
734
+ # name the transport rather than the repository.
735
+ #
736
+ # No `[a-z]`-style range anywhere in this block, and no tool whose result
737
+ # depends on the locale: the pin above is a global, and its inventory is
738
+ # enforced by a test.
739
+ case "$base" in
740
+ origin/*) pr_base=${base#origin/} ;;
741
+ refs/remotes/origin/*) pr_base=${base#refs/remotes/origin/} ;;
742
+ *) pr_base= ;;
743
+ esac
744
+ pr_loaded=false
745
+ pr_off=false
746
+ pr_all_why=
747
+ pr_rows=
748
+ pr_why=
749
+ pr_part_why=
750
+ pr_us=$(printf '\037')
751
+
752
+ # Reads `remote.origin.url` into $pr_owner, $pr_name and $pr_host. Returns 1
753
+ # where it names no `host/owner/repo` — a local path, or a shape with no owner.
754
+ pr_identity() {
755
+ pi_url=$(git config --get remote.origin.url 2>/dev/null) || return 1
756
+ case "$pi_url" in
757
+ /* | ./* | ../* | file://*) return 1 ;;
758
+ *://*)
759
+ pi_rest=${pi_url#*://}
760
+ pi_host=${pi_rest%%/*}
761
+ pi_path=${pi_rest#*/}
762
+ ;;
763
+ *:*)
764
+ pi_host=${pi_url%%:*}
765
+ pi_path=${pi_url#*:}
766
+ ;;
767
+ *) return 1 ;;
768
+ esac
769
+ pi_host=${pi_host##*@}
770
+ pi_host=${pi_host%%:*}
771
+ pi_path=${pi_path%/}
772
+ pi_path=${pi_path%.git}
773
+ pr_owner=${pi_path%%/*}
774
+ pr_name=${pi_path#*/}
775
+ pr_host=$pi_host
776
+ [ -n "$pr_host" ] && [ -n "$pr_owner" ] && [ "$pr_name" != "$pi_path" ] && [ -n "$pr_name" ] || return 1
777
+ case "$pr_name" in */*) return 1 ;; esac
778
+ return 0
779
+ }
780
+
781
+ # `gh "$@"`, neither prompting nor outliving <budget> seconds: the bounded-call
782
+ # shape net_git has for git, over the same kill walk. It lives here and not in
783
+ # net.sh because this is the one gh call in the fleet that is bounded; net.sh
784
+ # records why the others are not. stdout is gh's, stderr is gh's, and the
785
+ # status is gh's own — 143 or 137 when the watchdog fired (net_stalled). The
786
+ # watchdog closes fd 3, pr_load's stderr capture: where net_kill_tree cannot
787
+ # read `ps`, its sleep outlives the call, and holding that pipe open would
788
+ # stall even a fast answer for the whole budget.
789
+ gh_bounded() {
790
+ (
791
+ gb_budget=$1
792
+ shift
793
+ GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 gh "$@" </dev/null &
794
+ gb_pid=$!
795
+ { sleep "$gb_budget"; net_kill_tree "$gb_pid"; } >/dev/null 2>&1 3>&- &
796
+ gb_wd=$!
797
+ gb_status=0
798
+ wait "$gb_pid" 2>/dev/null || gb_status=$?
799
+ net_kill_tree "$gb_wd"
800
+ exit "$gb_status"
801
+ )
802
+ }
803
+
804
+ # One run's lookup. Sets $pr_off (the proof does not apply here), $pr_all_why
805
+ # (it could not be read, for every branch) or $pr_rows (the answer).
806
+ #
807
+ # At most $pr_chunk aliases per call: GitHub refuses the whole query with
808
+ # `Resource limits for this query exceeded` somewhere between 850 aliases
809
+ # (answered) and 1000 (refused), measured against this repository, which would
810
+ # keep every `+` branch on every run once the [gone] backlog grew that far.
811
+ # Alias numbers stay global across calls, so pr_tie reads `b<N>` the same way
812
+ # however many calls the answer took; any call that fails fails them all.
813
+ pr_chunk=500
814
+ pr_load() {
815
+ pr_loaded=true
816
+ [ -n "$pr_base" ] || { pr_off=true; return 0; }
817
+ pr_identity || { pr_off=true; return 0; }
818
+ if ! command -v gh >/dev/null 2>&1; then
819
+ pr_all_why="gh is not installed, so GitHub's record of this tip could not be read"
820
+ return 0
821
+ fi
822
+ # One line per alias, `\037`-joined and closed by a lone `.`, so a short or
823
+ # long row cannot be mistaken for a whole one: the same separator, and the
824
+ # same reason for it, as delete-merged-branch.sh's `gh pr view` read.
825
+ pl_jq='.data.repository | to_entries[] | .key as $k | (.value.nodes // []) as $n
826
+ | [$k, (if .value == null then "null" else ($n | length | tostring) end),
827
+ ($n[0] // {} | ((.number // "" | tostring), (.baseRefName // ""), (.isCrossRepository | tostring), (.headRefOid // ""),
828
+ (.timelineItems.pageInfo.hasNextPage | tostring), ([.timelineItems.nodes[]? | .beforeCommit.oid? // empty] | join(",")))),
829
+ "."] | join("\u001f")'
830
+ pl_budget=$(net_budget 60 "${FLEET_NET_TIMEOUT:-}")
831
+ pl_n=0
832
+ for pl_b in $gone_branches; do pl_n=$((pl_n + 1)); done
833
+ pl_lo=0
834
+ while [ "$pl_lo" -lt "$pl_n" ]; do
835
+ pl_hi=$((pl_lo + pr_chunk))
836
+ pl_decl='$owner:String!,$name:String!'
837
+ pl_sel=
838
+ set -- -f "owner=$pr_owner" -f "name=$pr_name"
839
+ pl_i=0
840
+ for pl_b in $gone_branches; do
841
+ if [ "$pl_i" -ge "$pl_lo" ] && [ "$pl_i" -lt "$pl_hi" ]; then
842
+ pl_decl="$pl_decl,\$h$pl_i:String!"
843
+ pl_sel="${pl_sel}b$pl_i:pullRequests(headRefName:\$h$pl_i,states:MERGED,first:2){nodes{number baseRefName isCrossRepository headRefOid timelineItems(itemTypes:[HEAD_REF_FORCE_PUSHED_EVENT],first:100){pageInfo{hasNextPage} nodes{... on HeadRefForcePushedEvent{beforeCommit{oid}}}}}} "
844
+ set -- "$@" -f "h$pl_i=$pl_b"
845
+ fi
846
+ pl_i=$((pl_i + 1))
847
+ done
848
+ pl_query="query($pl_decl){repository(owner:\$owner,name:\$name){$pl_sel}}"
849
+ # `--hostname` always, github.com included: without it gh asks GH_HOST, or
850
+ # its own default, rather than the host origin names.
851
+ set -- api graphql --hostname "$pr_host" -f "query=$pl_query" "$@" --jq "$pl_jq"
852
+ pl_to=$pl_hi
853
+ [ "$pl_to" -le "$pl_n" ] || pl_to=$pl_n
854
+ echo "\$ gh api graphql (merged PRs by head branch, [gone] branches $((pl_lo + 1))-$pl_to of $pl_n)" >&2
855
+ if probe gh_bounded "$pl_budget" "$@"; then pl_rc=0; else pl_rc=$?; fi
856
+ pl_o=$gp_out
857
+ pl_err=$gp_err
858
+ # Every line must be a whole row; one that is not makes the body unusable.
859
+ pl_ok=false
860
+ if [ -n "$pl_o" ]; then
861
+ pl_ok=true
862
+ while IFS=$pr_us read -r pk pcnt pnum pbase pcross poid phn pbef pend; do
863
+ case "${pk#b}" in '' | *[!0123456789]*) pl_ok=false ;; esac
864
+ [ "$pk" != "${pk#b}" ] || pl_ok=false
865
+ [ "$pend" = . ] || pl_ok=false
866
+ done <<EOF
867
+ $pl_o
868
+ EOF
869
+ fi
870
+ if [ "$pl_ok" = true ]; then
871
+ pr_rows=${pr_rows:+$pr_rows
872
+ }$pl_o
873
+ [ "$pl_rc" = 0 ] || pr_part_why="$pr_part_why (gh exited $pl_rc$(gp_why "$pl_err"))"
874
+ else
875
+ if [ "$pl_rc" = 0 ]; then
876
+ pr_all_why="gh answered with unparseable output, so GitHub's record of this tip could not be read"
877
+ elif net_stalled "$pl_rc"; then
878
+ pr_all_why="gh did not finish within ${pl_budget}s and was killed, so GitHub's record of this tip could not be read"
879
+ else
880
+ pr_all_why="gh failed (exit $pl_rc)$(gp_why "$pl_err"), so GitHub's record of this tip could not be read"
881
+ fi
882
+ pr_rows=
883
+ return 0
884
+ fi
885
+ pl_lo=$pl_hi
886
+ done
887
+ return 0
888
+ }
889
+
890
+ # Does GitHub's record tie branch $1, at frozen tip $2, to a head its merged PR
891
+ # held? Returns 0 when it does; otherwise 1, with the reason in $pr_why.
892
+ pr_tie() {
893
+ pt_b=$1
894
+ pt_tip=$2
895
+ pt_i=0
896
+ for pt_x in $gone_branches; do
897
+ [ "$pt_x" != "$pt_b" ] || break
898
+ pt_i=$((pt_i + 1))
899
+ done
900
+ pt_row=false
901
+ while IFS=$pr_us read -r pk pcnt pnum pbase pcross poid phn pbef pend; do
902
+ if [ "$pk" = "b$pt_i" ]; then pt_row=true; break; fi
903
+ done <<EOF
904
+ $pr_rows
905
+ EOF
906
+ if [ "$pt_row" != true ]; then
907
+ pr_why="GitHub's answer has no entry for $pt_b$pr_part_why"
908
+ return 1
909
+ fi
910
+ case "$pcnt" in
911
+ 0) pr_why="no merged PR has the head branch $pt_b"; return 1 ;;
912
+ 1) ;;
913
+ '' | null | *[!0123456789]*) pr_why="GitHub returned no usable PR list for $pt_b$pr_part_why"; return 1 ;;
914
+ *) pr_why="several merged PRs have the head branch $pt_b"; return 1 ;;
915
+ esac
916
+ case "$pnum" in '' | *[!0123456789]*) pnum="?" ;; esac
917
+ if [ "$phn" != false ]; then
918
+ pr_why="PR #$pnum has more force-pushes than one page of its timeline returned, so its recorded heads are incomplete"
919
+ return 1
920
+ fi
921
+ if [ "$pbase" != "$pr_base" ]; then
922
+ pr_why="PR #$pnum is based on $pbase, not $pr_base"
923
+ return 1
924
+ fi
925
+ if [ "$pcross" != false ]; then
926
+ pr_why="PR #$pnum comes from another repository"
927
+ return 1
928
+ fi
929
+ case "$poid" in '' | *[!0123456789abcdef]*)
930
+ pr_why="PR #$pnum has a headRefOid that is not a commit id: '$poid'"
931
+ return 1
932
+ ;;
933
+ esac
934
+ if ! git_probe cat-file -e "$poid^{commit}"; then
935
+ pr_why="PR #$pnum's headRefOid $poid is not a commit present locally"
936
+ return 1
937
+ fi
938
+ if git_probe merge-base --is-ancestor "$poid" "$base_rev"; then :; else
939
+ if [ "$?" -eq 1 ]; then
940
+ pr_why="PR #$pnum's headRefOid $poid is not an ancestor of $base, so it did not merge by a rebase or a merge commit onto it"
941
+ else
942
+ pr_why="cannot tell whether PR #$pnum's headRefOid $poid is an ancestor of $base$(gp_why)"
943
+ fi
944
+ return 1
945
+ fi
946
+ # The recorded heads: the head the PR ended on, then every force-pushed-over one.
947
+ pt_unsure=false
948
+ pt_unsure_why=
949
+ pt_rest="$poid,$pbef"
950
+ while [ -n "$pt_rest" ]; do
951
+ pt_h=${pt_rest%%,*}
952
+ case "$pt_rest" in *,*) pt_rest=${pt_rest#*,} ;; *) pt_rest= ;; esac
953
+ case "$pt_h" in '' | *[!0123456789abcdef]*) continue ;; esac
954
+ if [ "$pt_h" = "$pt_tip" ]; then return 0; fi
955
+ git_probe cat-file -e "$pt_h^{commit}" || continue
956
+ if git_probe merge-base --is-ancestor "$pt_tip" "$pt_h"; then return 0; else pt_rc=$?; fi
957
+ if [ "$pt_rc" -ne 1 ]; then pt_unsure=true; pt_unsure_why=$(gp_why); fi
958
+ done
959
+ if [ "$pt_unsure" = true ]; then
960
+ pr_why="cannot tell whether the tip is an ancestor of a head the PR recorded$pt_unsure_why"
961
+ return 1
962
+ fi
963
+ pr_why="the tip $pt_tip is not a head the PR recorded, nor an ancestor of one present locally"
964
+ return 1
965
+ }
966
+
672
967
  # %(upstream:track) emits exactly [gone] as its own field — nothing to
673
968
  # pattern-match, and no -v/-vv trap.
674
969
  #
@@ -769,8 +1064,9 @@ for b in $gone_branches; do
769
1064
  tip=$gp_out
770
1065
 
771
1066
  # git cherry against origin/main, not a local main: a local main never
772
- # fast-forwarded reads every merged branch as unmerged. Any + line is a commit
773
- # that exists nowhere else.
1067
+ # fast-forwarded reads every merged branch as unmerged. A + line is a commit
1068
+ # with no patch-equivalent upstream: usually one that exists nowhere else, but
1069
+ # not always — see the second merge proof above the loop.
774
1070
  #
775
1071
  # Captured, not piped: `cmd | grep -q` takes grep's exit status, never cmd's,
776
1072
  # so a `git cherry` that dies (exit 128 — one unreadable loose object is
@@ -833,8 +1129,22 @@ for b in $gone_branches; do
833
1129
  keep "$b" "cherry scan failed — cannot tell if merged$(gp_why "$gq_err")"
834
1130
  continue
835
1131
  elif [ "$gq_rc" -eq 0 ]; then
836
- keep "$b" "unmerged commits"
837
- continue
1132
+ # `+` is a keep unless the second proof (its header is above the loop)
1133
+ # ties this very `$tip` to a head the branch's merged PR held. Everything
1134
+ # after this block — the worktree guards, the holder re-check, the
1135
+ # compare-and-swap on `$tip` — runs exactly as it does for a branch the
1136
+ # cherry cleared.
1137
+ [ "$pr_loaded" = true ] || pr_load
1138
+ if [ "$pr_off" = true ]; then
1139
+ keep "$b" "unmerged commits"
1140
+ continue
1141
+ elif [ -n "$pr_all_why" ]; then
1142
+ keep "$b" "unmerged commits — $pr_all_why"
1143
+ continue
1144
+ elif ! pr_tie "$b" "$tip"; then
1145
+ keep "$b" "unmerged commits — $pr_why"
1146
+ continue
1147
+ fi
838
1148
  fi
839
1149
 
840
1150
  # The listing is read per branch, through worktree.sh's `wt_counts`, which
@@ -1,9 +1,20 @@
1
1
  #!/bin/sh
2
- # Release a claim that was never dispatched: drop the in-progress label, remove
3
- # the worktree, delete the branch — but only when the claim provably carries no
4
- # work. The inverse of claim-ticket.sh.
2
+ # Release a claim: drop the in-progress label, remove the worktree, delete the
3
+ # branch — but only when the claim provably carries no work. The inverse of
4
+ # claim-ticket.sh. Two claim shapes release, told apart by the branch on origin:
5
5
  #
6
- # Dry-run by default; --apply mutates the tracker and the filesystem.
6
+ # - Never dispatched: no branch on origin. The branch must carry no commit
7
+ # beyond origin/main and no commit unique to it.
8
+ # - Closed unmerged: the branch is on origin, and every PR of this repository
9
+ # whose head is that branch reads CLOSED — none OPEN, none MERGED, at least
10
+ # one present. The local branch must sit at the tip origin holds. Under
11
+ # --apply origin's tip is read again right before the remote branch is
12
+ # deleted, and that delete runs before the label drops. It is a plain one,
13
+ # not leased on the tip: a push landing between the re-read and the delete
14
+ # goes with the branch. A branch on origin with no PR, or with a PR list that
15
+ # cannot be read, blocks.
16
+ #
17
+ # Dry-run by default; --apply mutates the tracker, the remote and the filesystem.
7
18
  #
8
19
  # reap.sh will not do this: no merge happened, so no remote branch was ever
9
20
  # deleted, so the branch is not [gone] and reap's for-each-ref filter never
@@ -15,8 +26,10 @@
15
26
  # Every precondition is recomputed inside THIS invocation rather than trusted
16
27
  # from an earlier tool call, for the reason reap.sh gives: the dangerous
17
28
  # direction is a worktree that gained work after it was checked. Two of them are
18
- # recomputed a second time at the moment of the delete, which is why both
19
- # deletes run before the label is dropped rather than after.
29
+ # recomputed a second time at the moment of the delete, and in closed mode
30
+ # origin's tip is re-read at the remote delete as well, which is why every
31
+ # delete — the remote one included — runs before the label is dropped rather
32
+ # than after.
20
33
  #
21
34
  # The dirty check's second opinion is git's own — that is what `worktree remove`
22
35
  # without --force is for. It covers the live-directory case ONLY: `worktree
@@ -27,7 +40,8 @@
27
40
  #
28
41
  # The commit check's is this script's own: the compare-and-swap delete refuses
29
42
  # only a ref that moved, so the `ahead` count is re-run against $base right
30
- # before it. Why not `-d`, and what that recount does and does not cover, is
43
+ # before it — in closed mode, a compare of the local tip against the tip origin
44
+ # held instead. Why not `-d`, and what that recount does and does not cover, is
31
45
  # argued once at the delete site below — don't restate it here.
32
46
  set -eu
33
47
 
@@ -854,6 +868,44 @@ if [ -z "$wt" ] && [ -n "$stray" ]; then
854
868
  fi
855
869
  fi
856
870
 
871
+ # A pushed branch — work that survives the local delete and that a PR may
872
+ # already point at. Live query, so this one cannot be stale; a failure here is
873
+ # an unknown answer, never a "no".
874
+ # stderr stays on stderr, never folded into the value: the emptiness of $remote
875
+ # IS the answer, so an SSH host-key notice on a successful query used to read as
876
+ # a branch that exists. git's own message is more useful on the terminal anyway.
877
+ # 30s: `ls-remote` moves refs and no objects, so this is generous for the work,
878
+ # and it is the budget inflight.sh's own `ls-remote` runs on. `FLEET_NET_TIMEOUT`
879
+ # is the shorten-only override this lookup shares with the fleet's fetches — but
880
+ # NOT with inflight.sh's `ls-remote`, which reads `INFLIGHT_LS_REMOTE_TIMEOUT`
881
+ # and nothing else, so shortening this one does not shorten that one. The rule
882
+ # is net_budget's, in net.sh.
883
+ ls_budget=$(net_budget 30 "${FLEET_NET_TIMEOUT:-}")
884
+ ls_rc=0
885
+ remote=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") || ls_rc=$?
886
+ if [ "$ls_rc" -ne 0 ]; then
887
+ # A killed lookup and a refused one are different facts and get different
888
+ # words. Both leave the same verdict — the answer is unknown, so nothing is
889
+ # released — but only one of them names a cause this script observed.
890
+ if net_stalled "$ls_rc"; then
891
+ die "git ls-remote did not finish within ${ls_budget}s and was killed, so whether $branch was pushed is unknown"
892
+ fi
893
+ die "git ls-remote failed, so whether $branch was pushed is unknown"
894
+ fi
895
+
896
+ # The tip origin holds, split off ls-remote's `<sha><TAB><ref>` line on the tab
897
+ # itself rather than on a `[:space:]` class, which would put a locale-sensitive
898
+ # construct under the `export LC_ALL=C` paragraph above that says there is none.
899
+ # Whether this pushed branch is the one that releases is a tracker question,
900
+ # asked below once nothing local blocks.
901
+ tab=$(printf '\t')
902
+ remote_sha=
903
+ if [ -n "$remote" ]; then
904
+ remote_sha=${remote%%"$tab"*}
905
+ [ "${remote#*"$tab"}" = "refs/heads/$branch" ] ||
906
+ die "git ls-remote did not answer with refs/heads/$branch for $branch, so whether it was pushed is unknown"
907
+ fi
908
+
857
909
  # An Orphaned worktree directory: this claim's directory still on disk with its
858
910
  # registration already cleared. `stray` cannot see it BY CONSTRUCTION — it is
859
911
  # awk over git's registry, and there is no registration left to match — so the
@@ -893,7 +945,10 @@ if [ -z "$wt" ] && [ -z "$stray" ]; then
893
945
  block "worktree directory $orphan is this claim's and has no registration — inspect it and remove the directory by hand"
894
946
  elif ! gone "$orphan"; then
895
947
  block "cannot tell whether an orphaned worktree directory is at $orphan, so whether #$issue can be released is unknown"
896
- elif [ "$has_branch" = false ] && [ -z "$blockers" ]; then
948
+ elif [ "$has_branch" = false ] && [ -z "$blockers" ] && [ -z "$remote" ]; then
949
+ # A branch of this name on origin proves the slug right, so the die below
950
+ # does not fire: a closed-PR claim whose local branch is already gone
951
+ # releases from the remote alone.
897
952
  # A mistyped <slug>/<type> names a branch that does not exist, and without
898
953
  # this die the run would drop the label off a ticket whose real claim is
899
954
  # untouched — invisible to candidates.mjs and still in-flight. Refuse
@@ -927,7 +982,18 @@ if [ -z "$wt" ] && [ -z "$stray" ]; then
927
982
  fi
928
983
  fi
929
984
 
930
- if [ "$has_branch" = true ]; then
985
+ # A closed PR's commits are the branch's whole content, so `ahead` and the `git
986
+ # cherry` count below would refuse every one — and the PR states that tell a
987
+ # closed-PR claim from any other pushed branch are not read until the blockers
988
+ # below are known. So a branch on origin is held to one check here: the local
989
+ # branch must sit at the tip origin holds. Any commit it carries beyond that is
990
+ # unpushed, and the local delete would take it with the branch.
991
+ if [ "$has_branch" = true ] && [ -n "$remote" ]; then
992
+ local_tip=$(git rev-parse --verify "refs/heads/$branch") ||
993
+ die "cannot read $branch's tip, so whether it matches origin's is unknown"
994
+ [ "$local_tip" = "$remote_sha" ] ||
995
+ block "$branch is not at the tip origin holds — unpushed work would be lost"
996
+ elif [ "$has_branch" = true ]; then
931
997
  # Commits ahead — a member that did work. Measured on the branch ref rather
932
998
  # than the worktree's HEAD: the worktree above was located BY that ref, so the
933
999
  # two agree, and this still runs when the worktree is already gone.
@@ -951,32 +1017,6 @@ if [ "$has_branch" = true ]; then
951
1017
  [ "$uniq" -eq 0 ] || block "$uniq commit(s) unique to $branch (git cherry)"
952
1018
  fi
953
1019
 
954
- # A pushed branch — work that survives the local delete and that a PR may
955
- # already point at. Live query, so this one cannot be stale; a failure here is
956
- # an unknown answer, never a "no".
957
- # stderr stays on stderr, never folded into the value: the emptiness of $remote
958
- # IS the answer, so an SSH host-key notice on a successful query used to read as
959
- # a branch that exists. git's own message is more useful on the terminal anyway.
960
- # 30s: `ls-remote` moves refs and no objects, so this is generous for the work,
961
- # and it is the budget inflight.sh's own `ls-remote` runs on. `FLEET_NET_TIMEOUT`
962
- # is the shorten-only override this lookup shares with the fleet's fetches — but
963
- # NOT with inflight.sh's `ls-remote`, which reads `INFLIGHT_LS_REMOTE_TIMEOUT`
964
- # and nothing else, so shortening this one does not shorten that one. The rule
965
- # is net_budget's, in net.sh.
966
- ls_budget=$(net_budget 30 "${FLEET_NET_TIMEOUT:-}")
967
- ls_rc=0
968
- remote=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") || ls_rc=$?
969
- if [ "$ls_rc" -ne 0 ]; then
970
- # A killed lookup and a refused one are different facts and get different
971
- # words. Both leave the same verdict — the answer is unknown, so nothing is
972
- # released — but only one of them names a cause this script observed.
973
- if net_stalled "$ls_rc"; then
974
- die "git ls-remote did not finish within ${ls_budget}s and was killed, so whether $branch was pushed is unknown"
975
- fi
976
- die "git ls-remote failed, so whether $branch was pushed is unknown"
977
- fi
978
- [ -z "$remote" ] || block "branch $branch exists on origin"
979
-
980
1020
  # `-d`, because a directory that is not there holds no uncommitted work. Deleted
981
1021
  # by hand, a worktree leaves its admin files registered, so `worktree list
982
1022
  # --porcelain` keeps listing the entry and the status below ran against a path
@@ -1197,10 +1237,52 @@ if [ -n "$wt" ]; then
1197
1237
  fi
1198
1238
  fi
1199
1239
 
1200
- # Report the blockers before asking GitHub anything. The answer cannot change —
1201
- # every artefact stays put either way — and reaching for the tracker first turns
1202
- # an offline blocked claim into an unanswerable one, burying the finding the
1203
- # caller actually needs. "label":null says it was never read.
1240
+ # The one pushed branch that releases: every PR of this repository whose head
1241
+ # is $branch reads CLOSED, and there is at least one. Read live, and failing
1242
+ # closed — a PR list that cannot be read is an unknown answer, as the lookup
1243
+ # above is. Any other pushed branch blocks, as it always did.
1244
+ #
1245
+ # Asked only once every local check has had its say and none blocked, for the
1246
+ # reason the report below gives: a claim already blocked is answered, and a
1247
+ # tracker that cannot be reached must not turn it into an exit 2 with no
1248
+ # receipt. Such a claim names its pushed branch as one more blocker instead,
1249
+ # without the PR states nothing asked for.
1250
+ closed_pr=false
1251
+ pr_seen=
1252
+ if [ -n "$remote" ] && [ -n "$blockers" ]; then
1253
+ block "branch $branch exists on origin"
1254
+ elif [ -n "$remote" ]; then
1255
+ # One `number:state` token per PR, so the loop below needs no JSON parser.
1256
+ # --limit 1000: gh's default page of 30 would hide a later OPEN PR.
1257
+ # `--head` matches a head branch NAME, from any fork, so a fork's PR whose
1258
+ # branch shares this one's name is dropped before it is counted — the filter
1259
+ # delete-merged-branch.sh applies to its own `--head` lookup. Without it, a
1260
+ # fork's closed PR would read as this branch's and release a branch no PR of
1261
+ # ours names.
1262
+ prs=$(gh pr list --state all --head "$branch" --limit 1000 --json number,state,isCrossRepository \
1263
+ --jq '.[] | select(.isCrossRepository | not) | "\(.number):\(.state)"') ||
1264
+ die "gh pr list failed for $branch, so whether its PR closed unmerged is unknown"
1265
+ pr_bad=
1266
+ for pr in $prs; do
1267
+ pr_seen="$pr_seen #${pr%%:*} ${pr#*:};"
1268
+ case ${pr#*:} in
1269
+ CLOSED) ;;
1270
+ *) pr_bad="$pr_bad #${pr%%:*} ${pr#*:};" ;;
1271
+ esac
1272
+ done
1273
+ if [ -z "$pr_seen" ]; then
1274
+ block "branch $branch exists on origin and no PR names it"
1275
+ elif [ -n "$pr_bad" ]; then
1276
+ block "branch $branch exists on origin and its PR(s) are not closed unmerged:$pr_bad"
1277
+ else
1278
+ closed_pr=true
1279
+ fi
1280
+ fi
1281
+
1282
+ # Report the blockers before asking GitHub anything more. The answer cannot
1283
+ # change — every artefact stays put either way — and reaching for the tracker
1284
+ # first turns an offline blocked claim into an unanswerable one, burying the
1285
+ # finding the caller actually needs. "label":null says it was never read.
1204
1286
  if [ -n "$blockers" ]; then
1205
1287
  echo "$NAME: #$issue NOT released — nothing was touched" >&2
1206
1288
  printf '{"issue":%s,"branch":"%s","branchRewritten":%s,"worktree":"%s","worktreeRewritten":%s,"label":null,"released":false,"applied":%s,"blockers":[%s]}\n' \
@@ -1242,6 +1324,10 @@ if [ "$apply" = false ]; then
1242
1324
  [ "$has_label" = true ] && echo " would: gh issue edit $issue --remove-label in-progress" >&2
1243
1325
  [ -n "$wt" ] && printf ' would: git worktree remove %s\n' "$wt" >&2
1244
1326
  [ "$has_branch" = true ] && echo " would: git update-ref -d refs/heads/$branch <its current tip>" >&2
1327
+ if [ "$closed_pr" = true ]; then
1328
+ echo " PR states on $branch:$pr_seen" >&2
1329
+ echo " would: git push origin --delete refs/heads/$branch (origin read at $remote_sha)" >&2
1330
+ fi
1245
1331
  else
1246
1332
  # Label LAST. The two local deletes are the ones that refuse — that refusal is
1247
1333
  # the dirty check recomputed by git at the moment of the delete, so it is
@@ -1249,7 +1335,10 @@ else
1249
1335
  # left in-progress already gone: with the worktree still standing the ticket
1250
1336
  # read free to candidates.mjs while re-claiming failed on the existing branch.
1251
1337
  # Run them first and a refusal leaves the claim exactly as it was, label and
1252
- # all, which reads as still taken — the direction that costs nothing.
1338
+ # all, which reads as still taken — the direction that costs nothing. Closed
1339
+ # mode's remote delete runs after them, and ahead of the label for the same
1340
+ # reason.
1341
+
1253
1342
  if [ -n "$wt" ]; then
1254
1343
  # No --force, ever. A refusal is a finding to report, never something to
1255
1344
  # force past. Quote git's own reason: this fires precisely when something
@@ -1294,8 +1383,16 @@ else
1294
1383
  halt "cannot read $branch's tip at the delete"
1295
1384
  n=$(git rev-list --count "$base_rev..$tip") ||
1296
1385
  halt "cannot recount commits on $branch against $base at the delete"
1297
- [ "$n" -eq 0 ] ||
1298
- halt "$branch gained $n commit(s) since the checks — not deleted"
1386
+ if [ "$closed_pr" != true ]; then
1387
+ [ "$n" -eq 0 ] ||
1388
+ halt "$branch gained $n commit(s) since the checks — not deleted"
1389
+ else
1390
+ # A closed PR's commits are the whole content of the branch, so the
1391
+ # recount above refuses every one of them. What holds instead: the local
1392
+ # tip has not moved off the tip origin held when the checks read it.
1393
+ [ "$tip" = "$remote_sha" ] ||
1394
+ halt "$branch is no longer at the tip origin holds — not deleted"
1395
+ fi
1299
1396
 
1300
1397
  # What the CAS does not give back: `git update-ref` is ref-only plumbing
1301
1398
  # and, unlike `-D`, consults no worktree at all — so the one guard the CAS
@@ -1371,6 +1468,47 @@ else
1371
1468
  done_branch=true
1372
1469
  fi
1373
1470
 
1471
+ # Closed mode only: the remote branch goes after the local one and before the
1472
+ # label drops, for the same reason as the local deletes above.
1473
+ #
1474
+ # Origin is read again first, so a push that landed since the checks halts
1475
+ # the release here rather than going with the branch. Here, beside the remote
1476
+ # delete, and not inside the local branch delete: a closed-PR claim whose
1477
+ # local branch is already gone releases from the remote alone, and has no
1478
+ # local delete to hang the re-read on. The delete itself is a plain one: a
1479
+ # push landing in the gap between that re-read and this call is deleted with
1480
+ # the branch. A lease would close that gap, but this file spells no form of
1481
+ # force, so the gap stays.
1482
+ #
1483
+ # A non-zero rc does not say the delete failed to land: the watchdog can kill
1484
+ # the push after origin applied it. So a failed push is followed by a read of
1485
+ # origin, as delete-merged-branch.sh does, and the halt names what origin
1486
+ # holds rather than calling every non-zero rc a refusal. A branch already gone
1487
+ # from origin is the outcome this step exists for, whoever removed it.
1488
+ if [ "$closed_pr" = true ]; then
1489
+ recheck=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") ||
1490
+ halt "cannot re-read origin's $branch before the delete — not deleted"
1491
+ [ "${recheck%%"$tab"*}" = "$remote_sha" ] ||
1492
+ halt "origin's $branch moved since the checks — not deleted"
1493
+
1494
+ # $ls_budget: a ref-only `push --delete` moves no objects either, so it
1495
+ # shares the lookup's budget, as delete-merged-branch.sh's own push does.
1496
+ echo "\$ git push origin --delete refs/heads/$branch" >&2
1497
+ push_rc=0
1498
+ net_git "" "$ls_budget" push origin --delete "refs/heads/$branch" >&2 || push_rc=$?
1499
+ if [ "$push_rc" -ne 0 ]; then
1500
+ echo "\$ git ls-remote --heads origin refs/heads/$branch" >&2
1501
+ back=$(net_git "" "$ls_budget" ls-remote --heads origin "refs/heads/$branch") ||
1502
+ halt "git push --delete exited $push_rc and origin could not be read back, so whether origin still holds $branch is unknown"
1503
+ if [ -n "$back" ] && net_stalled "$push_rc"; then
1504
+ halt "git push --delete did not finish within ${ls_budget}s and was killed — origin still holds $branch"
1505
+ elif [ -n "$back" ]; then
1506
+ halt "git push --delete refused $branch on origin (rc $push_rc) — origin still holds it"
1507
+ fi
1508
+ fi
1509
+ done_branch=true
1510
+ fi
1511
+
1374
1512
  if [ "$has_label" = true ]; then
1375
1513
  echo "\$ gh issue edit $issue --remove-label in-progress" >&2
1376
1514
  gh issue edit "$issue" --remove-label in-progress >/dev/null ||
@@ -22,11 +22,14 @@ Every precondition recomputed **inside** same command as delete:
22
22
  - **`for-each-ref`, not `git branch | grep`.** `%(upstream:track)` emits exactly `[gone]` as own field. Nothing to pattern-match, no `-v`/`-vv` trap.
23
23
  - **A branch with no upstream is reported `kept`, never selected.** `%(upstream)` is empty for it, so `%(upstream:track)` has no `[gone]` to match. The sweep reports it with reason `no upstream — never selected` and runs no other guard on it. Its upstream arrives with the first `-u` push, or when the controller settles the implementer's report.
24
24
  - **Recompute per branch, in this command.** Branch list from earlier tool call already false: one observed run listed 28 gone branches, two calls later 27 reaped by concurrent session. Benign direction is no-op; dangerous one is worktree that gained work *after* check.
25
- - **`git cherry origin/main`, not `git diff main..`.** Against `origin/main` — local `main` you never fast-forwarded reads every merged branch as unmerged. Any `+` line is commit that exists nowhere else.
25
+ - **`git cherry origin/main`, not `git diff main..`.** Against `origin/main` — local `main` you never fast-forwarded reads every merged branch as unmerged. A `+` line is a commit with no patch-equivalent upstream, which is usually a commit that exists nowhere else but not always: a claim the merge bot rebased server-side onto a `main` that had changed its context lines carries a different patch id, so its original tip reads `+` although everything on it merged. That keep is safe but leaky, and the second proof below is what closes it.
26
26
  - **The delete is `git update-ref -d`'s compare-and-swap on a tip read once, authorized by that cherry check and by a fresh check, re-read immediately before the delete, that no worktree holds the branch.** Tip read before the cherry, cherry runs against that SHA, delete refuses unless ref still equals it — commit landing between check and delete is kept with git's own `cannot lock ref`, never force-deleted, as it was under `git branch -D`, the delete this replaced. `--no-deref`, so symref branch deletes the symref, not its target (measured). Branch's config section removed after, as `-D` did — left behind, re-created branch of same name inherits dead upstream and reads `[gone]` at once. `git branch -d` would refuse everything here: upstream gone, so falls back to comparing against `HEAD`, a possibly-behind local `main`. Never delete branch whose cherry output you did not just read.
27
27
  - **A branch any worktree holds is a KEEP, checked right before the delete.** `update-ref` consults no worktree; `-D` did. So listing is re-read and worktree.sh's `wt_holding` asks: held by a `branch` line, or by detached worktree stopped mid `rebase -i` / mid `bisect` on it (admin dir's `head-name` / `BISECT_START`) — both routes `-D` refused on. Worktree it cannot resolve, or listing that will not re-read, is a KEEP. `--apply` only, like the delete: dry run cannot predict this refusal — not the same limitation `-D`'s dry run had, since an unrelated worktree elsewhere in the listing that this check cannot resolve keeps every `[gone]` branch in the pass (see below), a repo-wide case `-D` never produced.
28
28
  - **A worktree that checked the branch out while the delete ran is caught after it, and the branch is put back.** git has no lock that stops `git worktree add <p> <b>` between that holder check and the delete, and such an add writes only the new worktree's `HEAD` — the ref does not move, so the compare-and-swap succeeds and leaves the worktree on a branch that no longer exists (measured, git 2.50.1). So worktree.sh's `wt_recheck_delete` re-reads the listing right after the delete — and again, up to 20 times 0.1s apart, while an add in progress is listed `locked initializing` — and when a worktree holds the branch, or the wait runs out, recreates it at the tip with a create-only `git update-ref`. The branch is a KEEP naming the worktree or the entry, and its config section stays. A restore that fails is a KEEP naming the tip, git's error and the command that restores it by hand — or, when something recreated the branch since the delete, the commit it now sits at, left as it is, since the create-only restore never overwrites it. The branch's reflog does not come back.
29
29
  - **A cherry that cannot answer is a KEEP.** Capture git's output; never pipe git itself: `cmd | grep -q` takes grep's exit status, never cmd's, so a `git cherry` that dies (rc 128 — one unreadable loose object is enough) prints nothing, grep exits 1, and a dead probe reads identical to "nothing unmerged" (verified, git 2.50.1). Unanswerable probe authorizes no delete — same fail-closed shape as `worktree remove` below. Read the `+` anchored, too: the capture folds stderr in, so an unanchored match reads a `+` in a diagnostic as a commit. Pinned by a test.
30
+ - **A branch `git cherry` keeps gets a second proof, from GitHub's record of the PR's heads.** A stranded tip is a head the PR's remote branch once held: a member's `HEAD:<branch>` push from a detached worktree, or the merge bot's server-side rebase, replaced the remote branch and never moved the local ref. GitHub records those heads — the PR's `headRefOid` and the `beforeCommit` of every force-push on it. A tip that **equals** a recorded head, or is an **ancestor** of one this clone has, was pushed and then replaced by its owner's force-push, so nothing on it was ever local-only. Equality or ancestry is enough: no `git range-diff` pairing, which reaped none of the measured tips (every branch has a `!` pair, context drift again) and, if it accepted `!`, would also reap a local amend that was never pushed. The tie is computed on the same tip the delete's compare-and-swap uses, and everything after it — worktree guards, holder re-check, `wt_recheck_delete`, `worktree remove` without `--force` — runs unchanged. There is no name filter.
31
+ - **The second proof is a KEEP with a reason unless every check holds.** One lookup by head name, merged PRs only. Exactly one PR matches — none, or several (a reused name), keeps. Its base is the branch `BASE_REF` names. It is not cross-repository. Its `headRefOid` is a commit present locally and an ancestor of `origin/main`, asked of git and never trusted from the API — a squash merge's head is not on `origin/main`, so it stays kept. Its timeline has no next page of force-pushes, since the tie would be read off a partial list. A tip that is none of the recorded heads, or an ancestor of none present locally, is kept.
32
+ - **The second proof fails closed, per branch and per cause.** One aliased `gh api graphql` lookup per run covers every `[gone]` branch, at most 500 branches per call (GitHub refused 1000 aliases in one query as over its resource limits, measured), and is issued the first time a branch needs it; a run with no `+` branch never calls `gh`. The host is always the one `origin` names, passed as `--hostname`, so `GH_HOST` cannot redirect it. Branch names go in as GraphQL variables, never spliced into the query. Output is captured, never piped, and the call runs under a bounded timeout. `gh` missing, a non-zero exit with no complete body, a stall past the budget, output that does not parse, and a next page each keep every affected branch with its own reason after `unmerged commits —`, and the run carries on; a non-zero exit that did print a complete body is read, and a branch missing from it keeps with gh's exit status and stderr in its reason. Nothing is cached: an untied branch is asked about again on every pass. Where `origin` has no `host/owner/repo` URL (a local path) or `BASE_REF` is not an `origin` ref there is nobody to ask, and the reason stays the bare `unmerged commits`. An `origin` whose host is an ssh alias (`git@github-work:owner/repo`) or an `insteadOf` shorthand (`gh:owner/repo`) is read as written, so `gh` is asked about a host it cannot reach and every affected branch keeps with `gh failed`. Only the `git fetch` failure still aborts the run.
30
33
  - **A worktree registry that cannot be read is a KEEP, for every `[gone]` branch in that pass.** The lookup decides whether the dirty, ignored-files and removal checks run at all; an unread registry reads identical to "no worktree", and `-D`, the delete then, needed no answer from the registry to delete a branch that has none — so failing open force-deleted merged branches blind. The holder check before the delete asks only whether a worktree holds the branch, never those other questions, so the lookup still fails closed on its own. The listing is re-read per branch, so one repo-level failure keeps every `[gone]` branch in the pass, each with git's own cause. Reap runs after every merge pass, so the next one retries: nothing is lost, nothing is reaped blind.
31
34
  - **A registry the listing does not match is a KEEP for the whole pass, not just one branch — if the fault is standing.** Eight real admin-directory faults leave `git worktree list` at rc 0 while the held entry loses its `branch` line (admin `HEAD` garbage, empty, missing or chmod 000) or leaves the listing (`gitdir` missing or chmod 000, admin dir chmod 000, `.git/worktrees` replaced by a file) — and git's own "used by worktree" `-D` refusal read the same admin state, so it deleted the branch a live worktree held (measured, git 2.50.1). The holder check that replaced that refusal reads the same listing and answers "not held" for seven of the eight (measured; "cannot tell" only for `HEAD` missing), so it is no backstop either. So before the lookup is trusted, linked entries listed are counted against entries under `<git-common-dir>/worktrees/` (worktree.sh's `wt_counts`, recount-before-refuse included), and any linked entry with a null `HEAD` and no `branch` line fails it too — the HEAD faults keep the counts equal. A dropped or nameless entry says nothing about which branch it held. The check re-reads the listing per branch, so a standing fault — one still there on every branch's own read — keeps every `[gone]` branch in the pass, the healthy ones included; a transient one, healed before the next branch's read, keeps only the branches whose listing it touched. Either way nothing is reaped blind, and the next pass retries once the registry reads consistent again.
32
35
  - **`worktree remove` without `--force`.** Refuses on modifications *and* untracked files, so double-covers dirty check. Refusal is finding to report, never something to force past.
@@ -40,7 +43,7 @@ Above walk finds branch's worktree by `branch refs/heads/<name>` line `git workt
40
43
  So `reap.sh` enumerates worktrees git lists with **no `branch` line at all** and decides each on own state:
41
44
 
42
45
  - **Bounded to `*/.worktrees/*` — fleet's own worktree home — and everything outside it is a `kept` entry.** Branch sweep authorizes its branch delete on evidence PR merged (`%(upstream:track)` reads exactly `[gone]`). This sweep has none to read: branchless + clean + patch-equivalent also describes human's `git worktree add --detach` scratch checkout that was never ticket. Unbounded, `--apply` deleted it (measured in review). Bound is ownership, never contents.
43
- - **`git cherry` against `origin/main`, never `merge-base --is-ancestor`.** Branch was rebased before it merged, so tip fully upstream is patch-equivalent, not ancestor — ancestry answers no for exactly work that is safe. Subject is full object id off porcelain, so no refname shadows it.
46
+ - **`git cherry` against `origin/main`, never `merge-base --is-ancestor`.** Branch was rebased before it merged, so tip fully upstream is patch-equivalent, not ancestor — ancestry answers no for exactly work that is safe. A `+` can also be context drift rather than missing work, so its keep is safe but leaky; this sweep's code is unchanged, and it has no branch name to look a PR up by. Subject is full object id off porcelain, so no refname shadows it.
44
47
  - **Absent `branch` line is not by itself detached HEAD.** git prints none for worktree whose HEAD it cannot resolve either, reporting null object id instead — four measured corruption routes for admin `HEAD`. Nothing about such worktree decidable, so report and leave. Null id alone is not the test: unborn branch is legitimate null id and DOES carry `branch` line.
45
48
  - **A git operation in progress is a keep, and git is no backstop for it.** Measured, git 2.50.1 (Apple Git-155): `worktree remove` *without* `--force` removes worktree holding interrupted rebase, and one holding bisect, at rc 0. Both detach, both leave `status --porcelain` empty — so every other check passes them and sequencer state, todo list and original head go with directory. Interrupted rebase is exactly how fleet worktree wanders off its branch (see `release-ticket.sh`'s own stray guard).
46
49
  - **Every decline is a `kept` entry, `branch` null.** No branch to name. Gap was invisible precisely because nothing was reported.
@@ -58,10 +61,12 @@ So nothing in the run cleans it up. Next run, the in-flight probe's local-worktr
58
61
 
59
62
  - **`ahead` + dirty catch a member that did work.** `ahead` against `origin/main`. Not redundant with `git cherry`: commit already cherry-picked upstream is upstream-equivalent (`-` line, not `+`), and only `ahead` still sees it. Pinned by a test.
60
63
  - **`git cherry` catches commits that exist only locally.** Against `origin/main`, never a local main — same trap reap has.
61
- - **Remote check catches a pushed branch.** Live `git ls-remote`, so it cannot be stale. Its failure is an unknown answer, never a "not pushed" — swallow it and a release deletes the local copy of work already on the server.
64
+ - **Remote check catches a pushed branch.** Live `git ls-remote`, so it cannot be stale. Its failure is an unknown answer, never a "not pushed" — swallow it and a release deletes the local copy of work already on the server. A pushed branch blocks unless it is the closed-unmerged shape below.
62
65
  - **Label read first, `gh` failure fatal.** Worktree and branch gone with `in-progress` left behind hides the ticket from `candidates.mjs` entirely — worse than not releasing. All three artefacts or none.
63
66
 
64
- **The delete is `git update-ref -d`'s compare-and-swap here too, and for reap's reason** — authorized by the `ahead` and `git cherry` checks above, plus the `ahead` recount re-run against `origin/main` immediately before the delete itself (a commit can land across the `gh issue view` between the checks and the delete; `git cherry` is not re-run, because such a commit is ahead of `origin/main` by construction), plus a fresh check, re-read immediately before the delete, that no worktree holds the branch (`update-ref` consults no worktree, where `-D` refused a held branch; a worktree it cannot resolve, or a listing that will not re-read, stops the delete too), and by nothing else. A claim used to carry `origin/main` as its upstream, so `-d` compared against *that* and forgave a behind local main; now `claim-ticket.sh` passes `--no-track` (so `@{u}` cannot silently answer about main), a claim has no upstream until its first `push -u`, and `-d` falls back to comparing against local `HEAD`. Measured: with local `main` behind `origin/main`, `-d` refuses a pristine claim — half-releasing it, worktree deleted and branch stranded with `in-progress` still on the ticket. The two checks above are strictly stronger than what `-d` would have asked, and both measure against `origin/main` rather than a local ref.
67
+ **One pushed branch releases: a claim whose PR closed unmerged.** Every PR of this repository whose head is the branch reads CLOSED — none OPEN, none MERGED, at least one present. Read with `gh pr list` only once no local check blocks, so a blocked claim still reports without asking GitHub; a list that cannot be read, or names no PR, blocks. `ahead` and `git cherry` do not apply — a closed PR's commits are the branch's whole content — so the local branch must sit at the tip origin holds instead, re-checked at the local delete. The remote branch is a fourth artefact: `--apply` deletes it after the local deletes and before the label drops, re-reading origin's tip first and halting if it moved. A plain delete, not leased on that tip, so a push landing between the re-read and the delete goes with the branch. A delete whose `git push` fails is read back from origin: gone counts as done.
68
+
69
+ **The delete is `git update-ref -d`'s compare-and-swap here too, and for reap's reason** — authorized by the `ahead` and `git cherry` checks above, plus the `ahead` recount re-run against `origin/main` immediately before the delete itself (a commit can land across the `gh issue view` between the checks and the delete; `git cherry` is not re-run, because such a commit is ahead of `origin/main` by construction), plus a fresh check, re-read immediately before the delete, that no worktree holds the branch (`update-ref` consults no worktree, where `-D` refused a held branch; a worktree it cannot resolve, or a listing that will not re-read, stops the delete too), and by nothing else. In the closed-unmerged shape the tip-at-origin check and its re-check at the delete stand in for `ahead`, `git cherry` and the recount; the worktree check stays. A claim used to carry `origin/main` as its upstream, so `-d` compared against *that* and forgave a behind local main; now `claim-ticket.sh` passes `--no-track` (so `@{u}` cannot silently answer about main), a claim has no upstream until its first `push -u`, and `-d` falls back to comparing against local `HEAD`. Measured: with local `main` behind `origin/main`, `-d` refuses a pristine claim — half-releasing it, worktree deleted and branch stranded with `in-progress` still on the ticket. The two checks above are strictly stronger than what `-d` would have asked, and both measure against `origin/main` rather than a local ref.
65
70
 
66
71
  **A checkout that lands between that fresh check and the delete halts the release after it.** `git worktree add` of an existing branch does not move the ref, so the compare-and-swap succeeds under it. The same `wt_recheck_delete` reap uses then finds the worktree, recreates the branch at the tip, and the release halts with exit 2, `released:false` and a blocker naming that worktree — the shape of a holder caught before the delete. A restore that fails halts the same way, with the tip and the repair command in the blocker.
67
72