@feigi/fleet-ctl 3.79.2 → 3.79.4
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/fleet-event.mjs +39 -1
- package/scripts/ledger.mjs +49 -10
- package/scripts/reap.sh +25 -9
- 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/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/ledger.mjs
CHANGED
|
@@ -134,10 +134,14 @@ function cause(...candidates) {
|
|
|
134
134
|
// one stall can EXCEED it — up to GIT_TIMEOUT_MS + 20 s — when `gh` still
|
|
135
135
|
// runs (measured). `check`'s worst case is the sum of whichever of its
|
|
136
136
|
// children actually stall, not a single fixed term. That is three children
|
|
137
|
-
// normally; in those layouts it is three git children
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// and
|
|
137
|
+
// normally; in those layouts it is three git children and `gh` is a fourth.
|
|
138
|
+
// Where the ledger's repository has no work tree (the `.bare` layout, a bare
|
|
139
|
+
// repository, a `--separate-git-dir` clone; not a submodule) repoCheck()'s
|
|
140
|
+
// `--show-toplevel` is refused and it asks the same directory for
|
|
141
|
+
// `--git-common-dir` to bind `gh` to, so there it is four git children and
|
|
142
|
+
// `gh` a fifth. `gh` runs in every one of those layouts. With `--file` the
|
|
143
|
+
// fleetFile() children drop out: two children normally, three where the
|
|
144
|
+
// repository has no work tree.
|
|
141
145
|
//
|
|
142
146
|
// `LEDGER_GIT_TIMEOUT` is this script's OWN override, in seconds, and it can
|
|
143
147
|
// only ever SHORTEN. The rule and the reasons for it are net_budget's, in
|
|
@@ -156,6 +160,13 @@ function gitBudget(defaultSeconds, override) {
|
|
|
156
160
|
}
|
|
157
161
|
const GIT_TIMEOUT_MS = gitBudget(10, process.env.LEDGER_GIT_TIMEOUT);
|
|
158
162
|
|
|
163
|
+
// What `git rev-parse --show-toplevel` says (exit 128, under LC_ALL=C) in a
|
|
164
|
+
// repository that has no work tree to name. NOT fleet-dir.mjs's
|
|
165
|
+
// NOT_A_WORK_TREE, which also matches "not a git repository": that one is
|
|
166
|
+
// "there is nothing here", this one is "there is a repository, and it has no
|
|
167
|
+
// work tree", and only the second leaves a repository for `gh` to bind to.
|
|
168
|
+
const NO_WORK_TREE = /must be run in a work tree/;
|
|
169
|
+
|
|
159
170
|
// There is ONE ledger per run, and it lives in the main checkout. Members run
|
|
160
171
|
// from their own worktrees, where a cwd-relative `.fleet/ledger.md` does not
|
|
161
172
|
// exist — `check` then warns and reports every subject as safe to file, which
|
|
@@ -1144,10 +1155,12 @@ function runCheck() {
|
|
|
1144
1155
|
// the repo) the two agree, but an explicit --file naming a ledger
|
|
1145
1156
|
// outside the caller's repo diverges silently: the query searches the
|
|
1146
1157
|
// wrong tracker and reports a confident, empty result. Bind gh's
|
|
1147
|
-
// cwd to the ledger's OWN repository instead of leaving it implicit
|
|
1148
|
-
//
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1158
|
+
// cwd to the ledger's OWN repository instead of leaving it implicit —
|
|
1159
|
+
// its work tree's root, or its common git dir where it has no work tree
|
|
1160
|
+
// (see the probe below). No --repo flag needed — gh's remote-based
|
|
1161
|
+
// resolution does the rest once it is pointed at the right directory,
|
|
1162
|
+
// and a worktree ledger (shared .git, own working tree) still resolves
|
|
1163
|
+
// to the same repo either way.
|
|
1151
1164
|
//
|
|
1152
1165
|
// cwd is not the whole story: inherited git vars outrank it, so an
|
|
1153
1166
|
// ambient GIT_DIR/GIT_WORK_TREE retargets BOTH the probe below and gh's
|
|
@@ -1181,7 +1194,27 @@ function runCheck() {
|
|
|
1181
1194
|
// the missing directory instead reported every first `check` as not being
|
|
1182
1195
|
// in a repository and dropped the tracker query outright.
|
|
1183
1196
|
while (!existsSync(ledgerDir) && dirname(ledgerDir) !== ledgerDir) ledgerDir = dirname(ledgerDir);
|
|
1184
|
-
|
|
1197
|
+
// LC_ALL=C on this probe alone: the no-work-tree refusal below is told
|
|
1198
|
+
// apart from every other failure by git's own wording, which a translated
|
|
1199
|
+
// git would otherwise spell differently. gh keeps `queryEnv` untouched.
|
|
1200
|
+
const probeEnv = { ...queryEnv, LC_ALL: "C" };
|
|
1201
|
+
let repoCheck = spawnSync("git", ["-C", ledgerDir, "rev-parse", "--show-toplevel"], { encoding: "utf8", env: probeEnv, timeout: GIT_TIMEOUT_MS });
|
|
1202
|
+
// A repository with no work tree — the `.bare` layout and its linked
|
|
1203
|
+
// worktrees, a bare repository, a `--separate-git-dir` clone — is where
|
|
1204
|
+
// fleetFile() puts the ledger INSIDE the git dir or beside it, and git
|
|
1205
|
+
// refuses `--show-toplevel` there ("must be run in a work tree", exit
|
|
1206
|
+
// 128). That is not "no repository": the same directory answers
|
|
1207
|
+
// `--git-common-dir`, and `gh` resolves its remote from a git dir just as
|
|
1208
|
+
// it does from a checkout (measured on a bare repository with a remote).
|
|
1209
|
+
// Bind to the common git dir — the very repository fleetFile() resolved
|
|
1210
|
+
// the ledger against — never to this process's cwd. ONLY that refusal
|
|
1211
|
+
// gets this: "not a git repository", git missing, a dubious-ownership
|
|
1212
|
+
// refusal, an unreadable gitfile and a probe killed at its bound all stay
|
|
1213
|
+
// on the failure branch below, and so does a second probe that fails.
|
|
1214
|
+
const noWorkTree = repoCheck.status === 128 && NO_WORK_TREE.test(repoCheck.stderr);
|
|
1215
|
+
if (noWorkTree) {
|
|
1216
|
+
repoCheck = spawnSync("git", ["-C", ledgerDir, "rev-parse", "--git-common-dir"], { encoding: "utf8", env: probeEnv, timeout: GIT_TIMEOUT_MS });
|
|
1217
|
+
}
|
|
1185
1218
|
if (repoCheck.status !== 0) {
|
|
1186
1219
|
// More than one cause lands here: a ledger path genuinely outside any
|
|
1187
1220
|
// repository, but also git missing entirely (spawn ENOENT, so `status`
|
|
@@ -1225,7 +1258,13 @@ function runCheck() {
|
|
|
1225
1258
|
const why = cause(repoCheck.error && repoCheck.error.message, repoCheck.stderr);
|
|
1226
1259
|
tracker = { ok: false, query, error: `cannot resolve the ledger's repository (${ledgerDir})${why ? `: ${why}` : ""}` };
|
|
1227
1260
|
} else {
|
|
1228
|
-
|
|
1261
|
+
// The work tree's root, or the common git dir where the repository has
|
|
1262
|
+
// no work tree. `--git-common-dir` may answer relative to the directory
|
|
1263
|
+
// it ran in (`.` when that is the git dir itself) or absolute, and
|
|
1264
|
+
// resolve() takes either. Read only past the status check above: a
|
|
1265
|
+
// probe that never spawned (git missing) leaves `stdout` undefined.
|
|
1266
|
+
const probed = repoCheck.stdout.trim();
|
|
1267
|
+
const ghCwd = noWorkTree ? resolve(ledgerDir, probed) : probed;
|
|
1229
1268
|
try {
|
|
1230
1269
|
const out = execFileSync(
|
|
1231
1270
|
"gh",
|
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
|
|
@@ -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.
|