@feigi/fleet-ctl 3.79.1 → 3.79.3
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/claim-ticket.sh +5 -3
- package/scripts/diff-stats.mjs +15 -5
- package/scripts/fleet-event.mjs +39 -1
- package/scripts/reap.sh +25 -9
- package/scripts/review-core.mjs +52 -16
- package/skills/run-team/references/reaping.md +1 -0
package/package.json
CHANGED
package/scripts/claim-ticket.sh
CHANGED
|
@@ -289,9 +289,11 @@ else
|
|
|
289
289
|
# computes `%(upstream:track)` against the REMOTE ref, and an upstream naming
|
|
290
290
|
# a remote ref that has never existed reads as `[gone]`, not as "no upstream"
|
|
291
291
|
# — measured, a freshly claimed branch was reaped by `reap.sh --apply` before
|
|
292
|
-
# any work was done in it. The real upstream arrives with the documented
|
|
293
|
-
#
|
|
294
|
-
# step 7; its no-lease fallback carries `-u` too),
|
|
292
|
+
# any work was done in it. The real upstream arrives with the first push with `-u`: the documented form,
|
|
293
|
+
# `git push --force-with-lease -u origin HEAD` (skills/next-ticket/SKILL.md
|
|
294
|
+
# step 7; its no-lease fallback carries `-u` too), or else when the controller
|
|
295
|
+
# settles the implementer's report, which writes it. Only then can the branch
|
|
296
|
+
# be `[gone]`. Until then reap.sh reports it kept.
|
|
295
297
|
#
|
|
296
298
|
# `--no-track` is also why no branch delete uses `-d`: `-d` measures an
|
|
297
299
|
# upstream-less branch against local HEAD, refusing a pristine claim behind
|
package/scripts/diff-stats.mjs
CHANGED
|
@@ -131,8 +131,8 @@ export function computeStats(files, changedFiles) {
|
|
|
131
131
|
// --- CLI (runs only when executed directly, never on import) ---------------
|
|
132
132
|
|
|
133
133
|
// die()/numArg() shared with the other fleet scripts — see arg.mjs for the
|
|
134
|
-
// fail-open and pipe-safety rationale. `--pr` is
|
|
135
|
-
// the
|
|
134
|
+
// fail-open and pipe-safety rationale. `--pr` is the numeric flag read here
|
|
135
|
+
// (`--repo` is the other, a plain string for gh's own `-R`), and numArg() rather than arg() because this file
|
|
136
136
|
// was the live half of a defect: the `if (!pr)` below caught a trailing
|
|
137
137
|
// `--pr` — `undefined` is falsy — but never a non-numeric one, so `--pr abc`
|
|
138
138
|
// reached `gh pr view abc` and printed `{"pr":null,…}` at exit 0. Measured
|
|
@@ -140,7 +140,7 @@ export function computeStats(files, changedFiles) {
|
|
|
140
140
|
// it resolves as a BRANCH, so the stats underneath were genuine and about
|
|
141
141
|
// whatever PR that branch heads, reported under a null id.
|
|
142
142
|
const die = makeDie(NAME);
|
|
143
|
-
const { numArg, sweep, stray } = defineFlags(die, { flags: { pr: "value" } });
|
|
143
|
+
const { arg, numArg, sweep, stray } = defineFlags(die, { flags: { pr: "value", repo: "value" } });
|
|
144
144
|
|
|
145
145
|
// The one truncation rule for the raw gh value this script quotes into a
|
|
146
146
|
// refusal: clip behind a visible marker, and only when the value was really
|
|
@@ -211,7 +211,17 @@ function main() {
|
|
|
211
211
|
// required, where gh answers it truthfully as no such PR. Absent is this
|
|
212
212
|
// file's own refusal and malformed is numArg()'s, per arg.mjs.
|
|
213
213
|
const pr = numArg("pr");
|
|
214
|
-
if (pr === null) die("usage: diff-stats.mjs --pr <number>");
|
|
214
|
+
if (pr === null) die("usage: diff-stats.mjs --pr <number> [--repo <[HOST/]OWNER/REPO | url>]");
|
|
215
|
+
// Which repository the PR is in. Absent, `gh` resolves it from this process's
|
|
216
|
+
// working directory — the right answer for a human at their own checkout and
|
|
217
|
+
// the wrong one for review-core.mjs's snapshot agent, which starts in the
|
|
218
|
+
// controller's checkout while the PR belongs to the worktree's repository: a
|
|
219
|
+
// PR in the cwd's repository that shares the number answers instead, exit 0,
|
|
220
|
+
// a well-formed `files` array and a profile that sizes another change.
|
|
221
|
+
// `arg()` refuses an empty or flag-shaped value, so a caller whose repository
|
|
222
|
+
// came back empty gets a refusal here, never the cwd fallback an empty `-R ""`
|
|
223
|
+
// would be.
|
|
224
|
+
const repo = arg("repo");
|
|
215
225
|
// The misspelled-flag sweep, weaker here for the same reason as pr-overlap.mjs: `--pr` is
|
|
216
226
|
// required, so a misspelled `--prr 5` already fell through to the usage die
|
|
217
227
|
// above. What was silently ignored at exit 0 is a stray riding along with a
|
|
@@ -234,7 +244,7 @@ function main() {
|
|
|
234
244
|
// fail-closed rule as run() above: a broken response is not an empty diff.
|
|
235
245
|
// `changedFiles` rides the same query for free and is the only thing that
|
|
236
246
|
// reveals the 100-file cap on `files` — see computeStats.
|
|
237
|
-
const raw = run("gh", ["pr", "view", String(pr), "--json", "files,changedFiles"]);
|
|
247
|
+
const raw = run("gh", ["pr", "view", String(pr), "--json", "files,changedFiles", ...(repo === null ? [] : ["-R", repo])]);
|
|
238
248
|
let info;
|
|
239
249
|
try {
|
|
240
250
|
info = JSON.parse(raw);
|
package/scripts/fleet-event.mjs
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
// not on origin/main, and releases; any other answer, or a detail that
|
|
50
50
|
// opens with no sha, is a NEED and N is left untouched, claim and all.
|
|
51
51
|
// The PR a `covered` detail names is never written to.
|
|
52
|
+
// A `PR#M` outcome also sets the claim branch's upstream when it has none: `reap.sh` keeps a branch with no upstream, and only an upstream's `[gone]` state makes it reapable. It writes `branch.<b>.remote` and `branch.<b>.merge` only while the merge key is unset, and prints `NEED upstream <b> — <why>` when a write fails.
|
|
52
53
|
// fix-report <fix-pr-M[-x]>
|
|
53
54
|
// `settle --from-report`, which records the filings and rulings the
|
|
54
55
|
// report carries. Settled `applied:`/`no-op`, on a PR whose rows carry
|
|
@@ -512,8 +513,45 @@ function blocker(r) {
|
|
|
512
513
|
return said(r);
|
|
513
514
|
}
|
|
514
515
|
|
|
516
|
+
// One word of a command printed for a human to paste. A branch name git
|
|
517
|
+
// accepts can still carry `;`, `$`, a backtick or `'`, so any word outside a
|
|
518
|
+
// plain charset is single-quoted, its own quotes closed, escaped and reopened.
|
|
519
|
+
const shellWord = (s) => (/^[\w/.@%+=:,-]+$/.test(s) ? s : `'${s.replaceAll("'", "'\\''")}'`);
|
|
520
|
+
|
|
521
|
+
// The claim branch's upstream, set once a PR is opened from it. Only the merge
|
|
522
|
+
// key being unset triggers the write, so a configured upstream is never touched.
|
|
523
|
+
function claimUpstream(ev, member) {
|
|
524
|
+
const read = ev.report(member);
|
|
525
|
+
if (read.state !== "ok") {
|
|
526
|
+
ev.need(`upstream of ${member}'s claim branch — its report ${read.path} is ${read.state}: set the upstream by hand`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const b = read.report.branch;
|
|
530
|
+
const want = `run git config ${shellWord(`branch.${b}.remote`)} origin && git config ${shellWord(`branch.${b}.merge`)} ${shellWord(`refs/heads/${b}`)}`;
|
|
531
|
+
const fail = (r) => ev.need(`upstream ${b} — ${said(r)}; ${want}`);
|
|
532
|
+
const exists = run("git", ["show-ref", "--verify", "--quiet", `refs/heads/${b}`]);
|
|
533
|
+
if (exists.status === 1) {
|
|
534
|
+
ev.note(`upstream ${b} not set: no local branch`);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (exists.status !== 0) return fail(exists);
|
|
538
|
+
const merge = run("git", ["config", "--get", `branch.${b}.merge`]);
|
|
539
|
+
if (merge.status === 0) {
|
|
540
|
+
ev.note(`upstream ${b} already set`);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (merge.status !== 1) return fail(merge);
|
|
544
|
+
const remote = run("git", ["config", `branch.${b}.remote`, "origin"]);
|
|
545
|
+
if (remote.status !== 0) return fail(remote);
|
|
546
|
+
const set = run("git", ["config", `branch.${b}.merge`, `refs/heads/${b}`]);
|
|
547
|
+
if (set.status !== 0) return fail(set);
|
|
548
|
+
ev.note(`upstream ${b} set`);
|
|
549
|
+
}
|
|
550
|
+
|
|
515
551
|
function implReport(ev, member) {
|
|
516
|
-
|
|
552
|
+
const outcome = ev.settle(member);
|
|
553
|
+
if (outcome === "bailed") bailed(ev, member);
|
|
554
|
+
else if (outcome?.startsWith("PR#")) claimUpstream(ev, member);
|
|
517
555
|
}
|
|
518
556
|
|
|
519
557
|
function fixReport(ev, member) {
|
package/scripts/reap.sh
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
|
-
# Delete local branches whose upstream is gone, and their worktrees.
|
|
2
|
+
# Delete local branches whose upstream is gone, and their worktrees. A branch with no upstream is reported as kept and never deleted.
|
|
3
3
|
#
|
|
4
4
|
# Dry-run by default; --apply mutates. Every precondition is recomputed inside
|
|
5
5
|
# this invocation, because a branch list from an earlier call is already false:
|
|
@@ -683,12 +683,15 @@ null_head_why() {
|
|
|
683
683
|
# away from them. %(refname) is always refs/heads/<name>, so the strip yields
|
|
684
684
|
# the bare name for every branch, ambiguous or not.
|
|
685
685
|
#
|
|
686
|
-
# The
|
|
687
|
-
#
|
|
688
|
-
#
|
|
689
|
-
#
|
|
690
|
-
# forms that DO carry a space, `[ahead 1]` and its siblings, split so
|
|
691
|
-
# holds `[ahead` — not `[gone]` either way.
|
|
686
|
+
# The [gone] test reads $3 and the no-upstream test reads $2: a space is not
|
|
687
|
+
# legal in a branch name (measured — git refuses `has space` as invalid), so
|
|
688
|
+
# the name is always the whole of $1, and $2 is `upstream=` followed by
|
|
689
|
+
# %(upstream)'s ref — exactly `upstream=` when the branch has none. The
|
|
690
|
+
# tracking forms that DO carry a space, `[ahead 1]` and its siblings, split so
|
|
691
|
+
# that $3 holds `[ahead` — not `[gone]` either way. That layout holds only
|
|
692
|
+
# while the upstream ref carries no whitespace: %(upstream) is built from the
|
|
693
|
+
# branch.<b>.merge value, which git does not validate, and a space there moves
|
|
694
|
+
# the track out of $3 (measured, git 2.50.1).
|
|
692
695
|
# A command substitution inside a `for ... in` word list discards its own
|
|
693
696
|
# exit status entirely, in every shell measured here (bash, dash, macOS
|
|
694
697
|
# /bin/sh): `for x in $(false); do …; done` completes at the loop's own end,
|
|
@@ -713,9 +716,13 @@ null_head_why() {
|
|
|
713
716
|
# the same reason — dying here would also abort the second sweep and the
|
|
714
717
|
# final `worktree prune`, neither of which this enumeration failing has
|
|
715
718
|
# anything to do with, over a failure that only defers reaping to a later pass.
|
|
716
|
-
|
|
719
|
+
# `upstream=` makes an unset %(upstream) a field of its own. Without it the
|
|
720
|
+
# whitespace split drops the empty field, and `$2` reads the track instead, so
|
|
721
|
+
# `$2=="upstream="` is exactly the no-upstream case and `$3` the [gone] test.
|
|
722
|
+
if ! git_probe for-each-ref --format='%(refname) upstream=%(upstream) %(upstream:track)' refs/heads; then
|
|
717
723
|
keep "" "could not enumerate [gone] branches — none reaped, and none reported reapable either$(gp_why)"
|
|
718
724
|
gone_branches=""
|
|
725
|
+
no_upstream=""
|
|
719
726
|
else
|
|
720
727
|
# git_probe captures git's stderr into $gp_err instead of leaving it on the
|
|
721
728
|
# real fd — the whole reason it exists — and every OTHER call site in this
|
|
@@ -726,11 +733,20 @@ else
|
|
|
726
733
|
# `git … | awk …` pipeline with git's stderr inherited, and now reaches no
|
|
727
734
|
# one unless forwarded here explicitly.
|
|
728
735
|
[ -z "$gp_err" ] || printf '%s' "$gp_err" >&2
|
|
729
|
-
if ! gone_branches=$(printf '%s\n' "$gp_out" | awk '$
|
|
736
|
+
if ! gone_branches=$(printf '%s\n' "$gp_out" | awk '$3=="[gone]"{sub(/^refs\/heads\//,"",$1); print $1}'); then
|
|
730
737
|
keep "" "could not enumerate [gone] branches — none reaped, and none reported reapable either"
|
|
731
738
|
gone_branches=""
|
|
732
739
|
fi
|
|
740
|
+
if ! no_upstream=$(printf '%s\n' "$gp_out" | awk '$2=="upstream="{sub(/^refs\/heads\//,"",$1); print $1}'); then
|
|
741
|
+
keep "" "could not enumerate branches with no upstream — none reported kept"
|
|
742
|
+
no_upstream=""
|
|
743
|
+
fi
|
|
733
744
|
fi
|
|
745
|
+
# A branch with no upstream has no [gone] state to act on. It is reported and
|
|
746
|
+
# never selected for deletion, and no other guard runs on it.
|
|
747
|
+
for b in $no_upstream; do
|
|
748
|
+
keep "$b" "no upstream — never selected"
|
|
749
|
+
done
|
|
734
750
|
for b in $gone_branches; do
|
|
735
751
|
|
|
736
752
|
# The tip is read ONCE, here, and everything after it measures that frozen
|
package/scripts/review-core.mjs
CHANGED
|
@@ -115,6 +115,11 @@ export const SNAPSHOT_SCHEMA = {
|
|
|
115
115
|
// printed instead of SNAPSHOT_TREE_MATCH. Optional, because a verified
|
|
116
116
|
// snapshot has nothing to say here.
|
|
117
117
|
repoError: { type: "string" },
|
|
118
|
+
// Only when the PR-capture block printed SNAPSHOT_REPO_UNRESOLVED: the
|
|
119
|
+
// error `gh` gave for the worktree's repository, verbatim. Optional, so a
|
|
120
|
+
// resolved repository has nothing to say here; present, it is the one
|
|
121
|
+
// record of why the review then runs with no diff and no sizing.
|
|
122
|
+
repoUnresolved: { type: "string" },
|
|
118
123
|
diffStats: { type: "string" },
|
|
119
124
|
diffPath: { type: "string" },
|
|
120
125
|
diffLines: { type: "integer" },
|
|
@@ -1071,17 +1076,41 @@ bare \`git\` — reads or writes THERE. Add nothing relative to it, and
|
|
|
1071
1076
|
chain a \`cd\` into "$RUN" or "$SNAP" for anything you run beyond it.
|
|
1072
1077
|
|
|
1073
1078
|
Then capture the PR's diff for the specialists, plus the three facts the caller
|
|
1074
|
-
needs to judge whether it is usable
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1079
|
+
needs to judge whether it is usable. Every 'gh' call here names the repository
|
|
1080
|
+
with -R, and that repository is the one 'gh repo view' resolves from inside the
|
|
1081
|
+
worktree: this dispatch starts in the controller's checkout, which may be a
|
|
1082
|
+
different repository from the worktree's, and a 'gh' with no -R reads whichever
|
|
1083
|
+
PR in THAT repository shares the number — a diff, a branch and a head that
|
|
1084
|
+
belong to another PR entirely, with nothing in the output saying so. It is
|
|
1085
|
+
gh's answer rather than the raw 'origin' URL because gh resolves a checkout's
|
|
1086
|
+
remotes through ssh-config host aliases ('git@my-alias:owner/repo.git') and -R
|
|
1087
|
+
does not: handed that URL raw, every read below fails. It is also the remote gh
|
|
1088
|
+
would pick for any command run in that checkout — 'origin', unless an
|
|
1089
|
+
'upstream' remote or 'gh repo set-default' names another:
|
|
1090
|
+
|
|
1091
|
+
REPO=$( { cd ${worktree} && gh repo view --json url -q .url; } 2>"$RUN"/repo.err )
|
|
1092
|
+
if [ -n "$REPO" ]; then
|
|
1093
|
+
gh pr diff ${pr} -R "$REPO" > "$RUN"/pr.diff
|
|
1094
|
+
crossRepo=$(gh pr view ${pr} -R "$REPO" --json isCrossRepository -q .isCrossRepository)
|
|
1095
|
+
branch=$(gh pr view ${pr} -R "$REPO" --json headRefName -q .headRefName)
|
|
1096
|
+
ref=""
|
|
1097
|
+
[ "$crossRepo" = "true" ] || ref=$(git -C ${worktree} ls-remote origin "refs/heads/$branch" | cut -f1)
|
|
1098
|
+
[ -n "$ref" ] || ref=$(git -C ${worktree} ls-remote origin "refs/pull/${pr}/head" | cut -f1)
|
|
1099
|
+
echo "$ref"
|
|
1100
|
+
gh pr view ${pr} -R "$REPO" --json headRefOid -q .headRefOid
|
|
1101
|
+
wc -l < "$RUN"/pr.diff
|
|
1102
|
+
else
|
|
1103
|
+
echo SNAPSHOT_REPO_UNRESOLVED
|
|
1104
|
+
cat "$RUN"/repo.err
|
|
1105
|
+
fi
|
|
1106
|
+
|
|
1107
|
+
If the block printed SNAPSHOT_REPO_UNRESOLVED, no 'gh pr' read ran: gh could not
|
|
1108
|
+
name the worktree's repository, and the controller's own repository is never a
|
|
1109
|
+
substitute. Report \`repoUnresolved\` = every line the block printed after
|
|
1110
|
+
SNAPSHOT_REPO_UNRESOLVED, verbatim — gh's own reason, such as no remote, or no
|
|
1111
|
+
remote on a GitHub host it is logged in to — or 'no error output' if it printed
|
|
1112
|
+
none. Omit \`diffPath\`, \`diffLines\`, \`refHead\` and \`prHead\`, and run
|
|
1113
|
+
nothing else against the PR yourself.
|
|
1085
1114
|
|
|
1086
1115
|
Report \`diffPath\` = the SNAPSHOT_RUN_ROOT value with '/pr.diff' appended, ONLY
|
|
1087
1116
|
if 'gh pr diff' exited 0. Report \`refHead\` = the sha the 'echo "$ref"' line
|
|
@@ -1120,9 +1149,15 @@ Report \`uncovered\` = its stdout, one line of JSON copied verbatim, ONLY if it
|
|
|
1120
1149
|
exited 0. If it exited non-zero, report \`uncoveredError\` = its stderr and omit
|
|
1121
1150
|
\`uncovered\`. Run none of the steps it lists.
|
|
1122
1151
|
|
|
1123
|
-
Then size the diff
|
|
1152
|
+
Then size the diff, naming the same repository for the same reason — diff-stats.mjs
|
|
1153
|
+
given no --repo reads the PR from the controller's checkout. This block resolves
|
|
1154
|
+
it again because it is a separate command; the capture block above already
|
|
1155
|
+
reported why when it could not:
|
|
1124
1156
|
|
|
1125
|
-
|
|
1157
|
+
REPO=$(cd ${worktree} && gh repo view --json url -q .url 2>/dev/null)
|
|
1158
|
+
if [ -n "$REPO" ]; then ~/.fleet/bin/fleet-run diff-stats.mjs --pr ${pr} --repo "$REPO"; else echo SNAPSHOT_REPO_UNRESOLVED; fi
|
|
1159
|
+
|
|
1160
|
+
If that printed SNAPSHOT_REPO_UNRESOLVED, omit \`diffStats\`.
|
|
1126
1161
|
|
|
1127
1162
|
Report \`runRoot\` = the SNAPSHOT_RUN_ROOT value and \`path\` = the SNAPSHOT_DEST
|
|
1128
1163
|
value, both copied verbatim. Each ends in a component the shell substituted —
|
|
@@ -1135,8 +1170,9 @@ specialists into another run's tree. Report the HEAD sha, and — in
|
|
|
1135
1170
|
STDOUT, copied verbatim. Only runRoot, path, head, pathVerified and repoVerified
|
|
1136
1171
|
are ever required — diffStats, diffPath, diffLines, refHead and prHead are each
|
|
1137
1172
|
omitted independently when their command failed, repoError only accompanies
|
|
1138
|
-
a false repoVerified,
|
|
1139
|
-
and uncoveredError only
|
|
1173
|
+
a false repoVerified, repoUnresolved only accompanies SNAPSHOT_REPO_UNRESOLVED,
|
|
1174
|
+
installError only replaces installCmd and installExit, and uncoveredError only
|
|
1175
|
+
replaces uncovered.`,
|
|
1140
1176
|
{ label: `snapshot${forPr}`, phase: "Snapshot", agentType: SNAPSHOT_AGENT_TYPE, schema: SNAPSHOT_SCHEMA },
|
|
1141
1177
|
);
|
|
1142
1178
|
|
|
@@ -1164,7 +1200,7 @@ and uncoveredError only replaces uncovered.`,
|
|
|
1164
1200
|
log(
|
|
1165
1201
|
usable
|
|
1166
1202
|
? `diff ${usable} (${snap.diffLines} lines)`
|
|
1167
|
-
: `no diff — diffPath=${snap.diffPath ?? "(absent)"} diffLines=${snap.diffLines ?? "(absent)"} refHead=${snap.refHead ?? "(absent)"} prHead=${snap.prHead ?? "(absent)"} head=${snap.head} — specialists get the fallback read rules`,
|
|
1203
|
+
: `no diff — diffPath=${snap.diffPath ?? "(absent)"} diffLines=${snap.diffLines ?? "(absent)"} refHead=${snap.refHead ?? "(absent)"} prHead=${snap.prHead ?? "(absent)"} head=${snap.head}${typeof snap.repoUnresolved === "string" ? ` — repository unresolved: ${oneLine(snap.repoUnresolved) || "(no reason given)"}` : ""} — specialists get the fallback read rules`,
|
|
1168
1204
|
);
|
|
1169
1205
|
|
|
1170
1206
|
let stats = null;
|
|
@@ -20,6 +20,7 @@ Skill is maintainer's to fix. Do not patch it; do reap yourself.
|
|
|
20
20
|
Every precondition recomputed **inside** same command as delete:
|
|
21
21
|
|
|
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
|
+
- **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.
|
|
23
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.
|
|
24
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
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.
|