@feigi/fleet-ctl 3.80.0 → 3.80.2
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 +1 -1
- package/scripts/reap.sh +316 -6
- package/skills/run-team/references/reaping.md +5 -2
package/package.json
CHANGED
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
|
-
|
|
442
|
+
probe() {
|
|
439
443
|
gp_raw=$(
|
|
440
444
|
{
|
|
441
|
-
if gp_o=$(
|
|
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.
|
|
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
|
|
837
|
-
|
|
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
|
|
@@ -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.
|
|
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.
|