@feigi/fleet-ctl 3.23.8 → 3.23.10
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-cherry-check-prose.test.mjs +80 -0
- package/scripts/reap.sh +21 -18
- package/scripts/recipe-prove.mjs +19 -4
- package/scripts/recipe-prove.test.mjs +106 -0
package/package.json
CHANGED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// reap.sh's branch sweep runs more checks than `git cherry` before its
|
|
2
|
+
// `git update-ref --no-deref -d`: the worktree registry and listing checks,
|
|
3
|
+
// the dirty check, the `worktree remove` refusal and the `wt_holding` re-read
|
|
4
|
+
// can each keep the branch. The cherry check is the only one of them that
|
|
5
|
+
// asks whether the tip is merged, so a comment saying the delete is
|
|
6
|
+
// "authorized by this check and by nothing else" is false as written. The
|
|
7
|
+
// component doc said reap deletes with `-D`; it deletes with `update-ref -d`.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { bullet, paragraph, phrase, runAbove, stripHashGutter } from "./prose-pin.mjs";
|
|
13
|
+
|
|
14
|
+
const REPO = join(import.meta.dirname, "..", "..");
|
|
15
|
+
const SCRIPT = readFileSync(join(REPO, "plugin", "scripts", "reap.sh"), "utf8");
|
|
16
|
+
const DOC = readFileSync(join(REPO, "docs", "components", "reaping-and-liveness.md"), "utf8");
|
|
17
|
+
|
|
18
|
+
// The comment block above the branch sweep's cherry probe, and the one above
|
|
19
|
+
// its cherry scan: the two blocks that justify why the cherry check fails
|
|
20
|
+
// closed.
|
|
21
|
+
const probeComment = () =>
|
|
22
|
+
stripHashGutter(runAbove(SCRIPT, 'if ! cherry=$(git cherry "$base_rev" "$tip"', "reap.sh cherry probe", "#"));
|
|
23
|
+
const scanComment = () =>
|
|
24
|
+
stripHashGutter(
|
|
25
|
+
runAbove(SCRIPT, `if ! grep_probe "$cherry" '^+'; then keep "$b"`, "reap.sh cherry scan", "#"),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const NOTHING_ELSE = phrase("authorized by this check and by nothing else");
|
|
29
|
+
|
|
30
|
+
for (const [name, comment] of [
|
|
31
|
+
["cherry-probe", probeComment],
|
|
32
|
+
["cherry-scan", scanComment],
|
|
33
|
+
]) {
|
|
34
|
+
test(`reap.sh: the ${name} comment does not call the cherry check the delete's sole authorizer`, () => {
|
|
35
|
+
assert.doesNotMatch(
|
|
36
|
+
comment(),
|
|
37
|
+
NOTHING_ELSE,
|
|
38
|
+
`reap.sh's ${name} comment says the delete is authorized by the cherry check and by nothing else — the worktree checks and \`wt_holding\` can stop the delete too`,
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test("reap.sh: the cherry-probe comment does not quote the retired \"By nothing else\" sentence", () => {
|
|
44
|
+
assert.doesNotMatch(
|
|
45
|
+
probeComment(),
|
|
46
|
+
phrase('"By nothing else" bounds'),
|
|
47
|
+
"reap.sh's cherry-probe comment says \"By nothing else\" bounds what ELSE authorizes the delete — the worktree checks and `wt_holding` can stop the delete too, and the sentence restates the sole-authorizer claim",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reap.sh: the cherry comments still say why an unanswerable probe must keep", () => {
|
|
52
|
+
// Input the pins above must ACCEPT: the fail-closed argument the old
|
|
53
|
+
// sentence carried survives, restated as what makes the cherry check
|
|
54
|
+
// unique — it is the only check that asks whether the tip is merged.
|
|
55
|
+
assert.match(probeComment(), phrase("this is the only one that asks whether `$tip` is merged"));
|
|
56
|
+
assert.match(probeComment(), phrase("never a `$tip` that is unmerged — so an unanswerable probe must KEEP"));
|
|
57
|
+
assert.match(scanComment(), phrase("no check between here and the delete below asks whether the branch is merged"));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const reapStep = () =>
|
|
61
|
+
bullet(DOC, "1. **Reap after every merge pass**", "2. **Release a claim", "reaping-and-liveness.md reap step");
|
|
62
|
+
// The doc's last list item, so the blank-line bound is the end of the file.
|
|
63
|
+
const reapAndRelease = () =>
|
|
64
|
+
paragraph(
|
|
65
|
+
DOC,
|
|
66
|
+
"- **Reap and release are two different scripts on purpose.**",
|
|
67
|
+
"reaping-and-liveness.md reap-and-release choice",
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
test("reaping-and-liveness.md: the reap step names the delete as a compare-and-swap `update-ref -d`, not `-D`", () => {
|
|
71
|
+
const step = reapStep();
|
|
72
|
+
assert.match(step, phrase("compare-and-swap `git update-ref -d`"));
|
|
73
|
+
assert.doesNotMatch(step, /`-D`/, "reaping-and-liveness.md's reap step says the delete is `-D` — reap.sh deletes with `git update-ref -d`");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("reaping-and-liveness.md: the reap-and-release choice names reap's delete as `update-ref -d`, not `-D`", () => {
|
|
77
|
+
const choice = reapAndRelease();
|
|
78
|
+
assert.match(choice, phrase("Reap authorizes a destructive `git update-ref -d`"));
|
|
79
|
+
assert.doesNotMatch(choice, /`-D`/, "reaping-and-liveness.md says reap authorizes a `-D` — reap.sh deletes with `git update-ref -d`");
|
|
80
|
+
});
|
package/scripts/reap.sh
CHANGED
|
@@ -938,23 +938,26 @@ for b in $gone_branches; do
|
|
|
938
938
|
# enough) prints nothing, grep sees empty input and exits 1 — the identical
|
|
939
939
|
# verdict a genuinely clean cherry produces, so "merged" is indistinguishable
|
|
940
940
|
# from "the probe could not answer". An unmerged branch is never the ambiguous
|
|
941
|
-
# one: its `+` line makes grep exit 0 and always keeps.
|
|
942
|
-
#
|
|
943
|
-
#
|
|
944
|
-
#
|
|
945
|
-
#
|
|
941
|
+
# one: its `+` line makes grep exit 0 and always keeps. Of the checks that
|
|
942
|
+
# must pass before the delete, this is the only one that asks whether `$tip`
|
|
943
|
+
# is merged — the worktree checks below and the `worktree remove` refusal
|
|
944
|
+
# can each keep the branch, but none of them looks at its commits, and the
|
|
945
|
+
# compare-and-swap refuses a ref that moved off `$tip`, never a `$tip` that
|
|
946
|
+
# is unmerged — so an unanswerable probe must KEEP, the same fail-closed
|
|
947
|
+
# shape the worktree `status` check below already uses.
|
|
946
948
|
# `refs/heads/$b`, never a bare `$b`, where the tip is read above. Restoring
|
|
947
949
|
# the bare name makes a branch that shares its name with a tag ambiguous AS A
|
|
948
950
|
# REV again, and git resolves an ambiguous one by preferring refs/tags/ over
|
|
949
951
|
# refs/heads/ (measured, git 2.50.1 Apple Git-155). This probe would then
|
|
950
952
|
# answer about the TAG's commit while the delete below removes the BRANCH —
|
|
951
953
|
# and a tag sitting on a merged commit reports clean for a branch whose
|
|
952
|
-
# commits exist nowhere else.
|
|
953
|
-
#
|
|
954
|
-
# on a fixture, the enumeration fix alone turned a branch
|
|
955
|
-
# currently KEEPS into `REAPED`, at exit 0, with an empty
|
|
956
|
-
# changes nothing for an ordinary branch — both spellings
|
|
957
|
-
# commit — and it is the same key the worktree lookup below
|
|
954
|
+
# commits exist nowhere else. No other check before the delete asks whether
|
|
955
|
+
# those commits are merged, so that reads straight through to destroying
|
|
956
|
+
# them: measured on a fixture, the enumeration fix alone turned a branch
|
|
957
|
+
# this script currently KEEPS into `REAPED`, at exit 0, with an empty
|
|
958
|
+
# kept[]. Qualifying changes nothing for an ordinary branch — both spellings
|
|
959
|
+
# name the same commit — and it is the same key the worktree lookup below
|
|
960
|
+
# already builds.
|
|
958
961
|
#
|
|
959
962
|
# `$base_rev` is the other side of the same rule, and it was missing until
|
|
960
963
|
# #924: `$base` reached this `git cherry` exactly as BASE_REF spelled it, so a
|
|
@@ -963,10 +966,10 @@ for b in $gone_branches; do
|
|
|
963
966
|
# deleted the BRANCH — measured, an unmerged [gone] branch whose commit
|
|
964
967
|
# existed nowhere else REAPED at exit 0 with an empty kept[]. The
|
|
965
968
|
# qualification is built at the top of the file, where its own comment
|
|
966
|
-
# records why the accept-list beside it is what makes prefixing sound.
|
|
967
|
-
#
|
|
968
|
-
# check itself can be wrong — which is why BOTH revs it
|
|
969
|
-
# qualified. #634
|
|
969
|
+
# records why the accept-list beside it is what makes prefixing sound. Being
|
|
970
|
+
# the only merge check bounds what ELSE could catch an unmerged branch, not
|
|
971
|
+
# whether this check itself can be wrong — which is why BOTH revs it
|
|
972
|
+
# consumes are qualified. #634
|
|
970
973
|
if ! cherry=$(git cherry "$base_rev" "$tip" 2>&1); then
|
|
971
974
|
keep "$b" "cherry probe failed — cannot tell if merged: $(printf '%s' "$cherry" | tr '\n' ' ')"
|
|
972
975
|
continue
|
|
@@ -981,9 +984,9 @@ for b in $gone_branches; do
|
|
|
981
984
|
# What "grep's is the only status left to take" missed, and what this shape
|
|
982
985
|
# exists for: taking it is not the same as READING it. grep answers three
|
|
983
986
|
# ways, and until #1419 the `if` had two arms — an rc 2 scan that never
|
|
984
|
-
# examined `$cherry` fell into the merged arm, and
|
|
985
|
-
#
|
|
986
|
-
# a merge status no tool had established. `grep_probe` splits the two
|
|
987
|
+
# examined `$cherry` fell into the merged arm, and no check between here and
|
|
988
|
+
# the delete below asks whether the branch is merged. So the deletion went
|
|
989
|
+
# ahead on a merge status no tool had established. `grep_probe` splits the two
|
|
987
990
|
# questions; the answers are ruled on here, in the order that makes the
|
|
988
991
|
# unanswerable case fail CLOSED like every other could-not-check in this
|
|
989
992
|
# file: scan first, verdict second.
|
package/scripts/recipe-prove.mjs
CHANGED
|
@@ -148,11 +148,25 @@ function parseArgs(argv) {
|
|
|
148
148
|
// proof that did not hold. Only a spawn that produced no process counts:
|
|
149
149
|
// spawnSync also sets `error` for a git that ran and was killed — ENOBUFS,
|
|
150
150
|
// when its output outgrows the default maxBuffer — and that is a failed git
|
|
151
|
-
// command
|
|
151
|
+
// command, never a git that could not be started.
|
|
152
|
+
//
|
|
153
|
+
// A git killed by a signal (that buffer kill, an OOM kill, a timeout wrapper)
|
|
154
|
+
// is still a failed command, never a throw: each caller maps a failed git to
|
|
155
|
+
// its own refusal. But it has no exit status and mostly no stderr, so `err`
|
|
156
|
+
// names the kill, after whatever git did write — a caller quoting `err` would
|
|
157
|
+
// otherwise quote an empty reason.
|
|
152
158
|
function git(args, cwd) {
|
|
153
159
|
const r = spawnSync("git", args, { cwd, env: ENV, encoding: "utf8" });
|
|
154
160
|
if (r.error && !r.pid) throw cannot(`could not start git: ${r.error.message}`);
|
|
155
|
-
|
|
161
|
+
const err = (r.stderr ?? "").trim();
|
|
162
|
+
const killed = r.signal && `git was killed by ${r.signal}${killCause(r.error)}`;
|
|
163
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").replace(/\n$/, ""), err: [err, killed].filter(Boolean).join("; ") };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function killCause(error) {
|
|
167
|
+
if (!error) return "";
|
|
168
|
+
if (error.code === "ENOBUFS") return " (ENOBUFS: its output outgrew the spawn buffer)";
|
|
169
|
+
return ` (${error.code ?? error.message})`;
|
|
156
170
|
}
|
|
157
171
|
|
|
158
172
|
// Run a Recipe command through `sh -c` from the worktree root, its output to
|
|
@@ -338,12 +352,13 @@ function removeLogs(logs) {
|
|
|
338
352
|
|
|
339
353
|
function main(argv) {
|
|
340
354
|
const o = parseArgs(argv);
|
|
341
|
-
|
|
355
|
+
const gitDir = git(["rev-parse", "--git-dir"], o.repo);
|
|
356
|
+
if (!gitDir.ok) throw cannot(`${o.repo} is not a git repository: ${gitDir.err}`);
|
|
342
357
|
const common = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], o.repo);
|
|
343
358
|
const workspace = common.ok ? workspaceDirFromGitCommonDir(common.out, o.repo) : null;
|
|
344
359
|
if (!workspace) throw cannot(`cannot resolve the common git dir of ${o.repo}: ${common.err}`);
|
|
345
360
|
const sha = git(["rev-parse", "--verify", "origin/main^{commit}"], o.repo);
|
|
346
|
-
if (!sha.ok) throw cannot(
|
|
361
|
+
if (!sha.ok) throw cannot(`origin/main does not resolve to a commit (${sha.err}) — fetch it; the proof runs against it`);
|
|
347
362
|
|
|
348
363
|
const logs = mkdtempSync(join(tmpdir(), `${NAME}-`));
|
|
349
364
|
const cache = join(workspace, ".fleet", "recipe.json");
|
|
@@ -506,6 +506,112 @@ test("a git whose output outgrows the spawn buffer is a failed command, not a gi
|
|
|
506
506
|
assert.doesNotMatch(r.err, /could not start git/);
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
+
// A `git` first on PATH that runs `act` when its arguments match the sh `case`
|
|
510
|
+
// pattern `when`, and otherwise execs the real one.
|
|
511
|
+
function fakeGit(when, act) {
|
|
512
|
+
const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
|
|
513
|
+
const bin = tempDir("recipe-prove-path-");
|
|
514
|
+
writeExecStub(join(bin, "git"), `#!/bin/sh\ncase "$*" in ${when}) ${act} ;; esac\nexec '${realGit}' "$@"\n`);
|
|
515
|
+
return `${bin}:${BIN}:${process.env.PATH}`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const COUNT_PROOF = ["--install", "true", "--test", "echo 'tests 1'", "--count-line", "tests 1", "--test-count", "1"];
|
|
519
|
+
|
|
520
|
+
// A git that ran and was killed has no exit status and, mostly, no stderr: its
|
|
521
|
+
// failure must name the kill, or every caller quoting git's reason quotes an
|
|
522
|
+
// empty one. Each caller keeps its own refusal class — NOT PROVEN (1) inside
|
|
523
|
+
// the proof, no verdict (2) for the probes that come before it.
|
|
524
|
+
const KILLED_GIT = [
|
|
525
|
+
{
|
|
526
|
+
name: "a git killed by SIGKILL reading the tree state",
|
|
527
|
+
when: "status*", act: "kill -9 $$",
|
|
528
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: git was killed by SIGKILL; install output: /,
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
name: "a git killed by SIGTERM reading the tree state",
|
|
532
|
+
when: "status*", act: "kill -TERM $$",
|
|
533
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: git was killed by SIGTERM; install output: /,
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: "a git killed for outgrowing the spawn buffer reading the tree state",
|
|
537
|
+
when: "status*", act: "yes | head -c 2000000; exit 0",
|
|
538
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: git was killed by SIGTERM \(ENOBUFS: its output outgrew the spawn buffer\); install output: /,
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
name: "a git that wrote to stderr and was then killed",
|
|
542
|
+
when: "status*", act: "echo boom >&2; kill -9 $$",
|
|
543
|
+
status: 1, reason: /NOT PROVEN — could not read the tree state in the throwaway worktree: boom; git was killed by SIGKILL; install output: /,
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "a git killed restoring the tree before the mutation",
|
|
547
|
+
when: "reset*", act: "kill -9 $$", files: GO_FILES, args: GO_PROOF,
|
|
548
|
+
status: 1, reason: /NOT PROVEN — could not restore the tree before the mutation: git was killed by SIGKILL; test output: /,
|
|
549
|
+
},
|
|
550
|
+
{
|
|
551
|
+
name: "a git killed reading what the mutation changed",
|
|
552
|
+
when: "diff*", act: "kill -9 $$", files: GO_FILES, args: GO_PROOF,
|
|
553
|
+
status: 1, reason: /NOT PROVEN — could not read what the mutation changed: git was killed by SIGKILL; its output: /,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
name: "a git killed probing for the repository",
|
|
557
|
+
when: '"rev-parse --git-dir"', act: "kill -9 $$",
|
|
558
|
+
status: 2, reason: /^recipe-prove: .* is not a git repository: git was killed by SIGKILL$/m,
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: "a git killed resolving the common git dir",
|
|
562
|
+
when: '"rev-parse --path-format=absolute --git-common-dir"', act: "kill -9 $$",
|
|
563
|
+
status: 2, reason: /^recipe-prove: cannot resolve the common git dir of .*: git was killed by SIGKILL$/m,
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
name: "a git killed resolving origin/main",
|
|
567
|
+
when: '"rev-parse --verify"*', act: "kill -9 $$",
|
|
568
|
+
status: 2, reason: /^recipe-prove: origin\/main does not resolve to a commit \(git was killed by SIGKILL\) — fetch it/m,
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
name: "a git killed creating the throwaway worktree",
|
|
572
|
+
when: '"worktree add"*', act: "kill -9 $$",
|
|
573
|
+
status: 2, reason: /^recipe-prove: could not create the throwaway worktree: git was killed by SIGKILL$/m,
|
|
574
|
+
},
|
|
575
|
+
];
|
|
576
|
+
|
|
577
|
+
for (const c of KILLED_GIT) {
|
|
578
|
+
test(`${c.name} names the signal in its refusal`, () => {
|
|
579
|
+
const { dir } = repo(c.files ?? MAVEN_FILES);
|
|
580
|
+
const r = prove(dir, c.args ?? COUNT_PROOF, { env: { PATH: fakeGit(c.when, c.act) } });
|
|
581
|
+
assert.equal(r.status, c.status, r.err);
|
|
582
|
+
assert.match(r.err, c.reason);
|
|
583
|
+
assert.doesNotMatch(r.err, /could not start git/);
|
|
584
|
+
if (c.status === 2) assert.doesNotMatch(r.err, /NOT PROVEN/);
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// The control: a git that ran and exited non-zero is quoted as it wrote, with
|
|
589
|
+
// no kill named.
|
|
590
|
+
test("a git that exits non-zero is refused with exactly its stderr, naming no signal", () => {
|
|
591
|
+
const { dir } = repo(MAVEN_FILES);
|
|
592
|
+
const r = prove(dir, COUNT_PROOF, { env: { PATH: fakeGit("status*", "echo boom >&2; exit 3") } });
|
|
593
|
+
assert.equal(r.status, 1, r.err);
|
|
594
|
+
assert.match(r.err, /NOT PROVEN — could not read the tree state in the throwaway worktree: boom; install output: /);
|
|
595
|
+
assert.doesNotMatch(r.err, /killed/);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// A killed `worktree remove` is a failed one: cleanup falls back to the plain
|
|
599
|
+
// delete, and the proof's own NOT PROVEN — which keeps its log directory,
|
|
600
|
+
// the throwaway worktree's parent — stands.
|
|
601
|
+
test("a git killed removing the throwaway worktree still leaves it deleted", () => {
|
|
602
|
+
const { dir } = repo(MAVEN_FILES);
|
|
603
|
+
const r = prove(dir, ["--install", "false", "--test", "true", "--count-line", "x 1", "--test-count", "1"], {
|
|
604
|
+
env: { PATH: fakeGit('"worktree remove"*', "kill -9 $$") },
|
|
605
|
+
});
|
|
606
|
+
assert.equal(r.status, 1, r.err);
|
|
607
|
+
assert.match(r.err, /NOT PROVEN — the Install step 'false' failed/);
|
|
608
|
+
assert.doesNotMatch(r.err, /skipped git/);
|
|
609
|
+
const kept = logDirs(r.tmp);
|
|
610
|
+
assert.equal(kept.length, 1, `log directories in ${r.tmp}: ${kept}`);
|
|
611
|
+
assert.equal(existsSync(join(r.tmp, kept[0], "wt")), false, "the throwaway worktree is gone");
|
|
612
|
+
assert.equal(git(dir, "worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length, 1, "only the main worktree stays registered");
|
|
613
|
+
});
|
|
614
|
+
|
|
509
615
|
// A git that starts for the worktree removal and not for the prune after it:
|
|
510
616
|
// the stub deletes itself once `worktree remove` has run. The proof's own
|
|
511
617
|
// outcome is NOT PROVEN (the Install step fails), and the prune that cannot
|