@feigi/fleet-ctl 3.18.11 → 3.18.13
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-lifecycle.test.mjs +2 -1
- package/scripts/fleet-tick.test.mjs +148 -101
- package/scripts/reap.test.mjs +19 -13
- package/scripts/release-ticket.test.mjs +9 -7
- package/scripts/worktree-porcelain.mjs +35 -0
- package/scripts/worktree-porcelain.test.mjs +102 -0
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
21
|
+
import { worktreeNames } from "./worktree-porcelain.mjs";
|
|
21
22
|
|
|
22
23
|
const CLAIM = fileURLToPath(new URL("./claim-ticket.sh", import.meta.url));
|
|
23
24
|
const REAP = fileURLToPath(new URL("./reap.sh", import.meta.url));
|
|
@@ -111,7 +112,7 @@ test("a freshly claimed branch is not [gone], and reap.sh leaves it alone", (t)
|
|
|
111
112
|
assert.deepEqual(JSON.parse(reaped.stdout).reaped, []);
|
|
112
113
|
// Positive control on the same run: the branch and its worktree survived.
|
|
113
114
|
assert.equal(f.git(f.w, "rev-parse", "--verify", "refs/heads/fix/42-slug").length, 40);
|
|
114
|
-
assert.
|
|
115
|
+
assert.ok(worktreeNames(f.git(f.w, "worktree", "list", "--porcelain")).includes("42-slug"));
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
test("after push -u, merge and prune, reap.sh does see the branch as [gone]", (t) => {
|
|
@@ -1240,55 +1240,86 @@ function runCli(args = [], {
|
|
|
1240
1240
|
// every path below the resolved one. (A symlinked path no longer stops a
|
|
1241
1241
|
// copy running its own main(): is-cli.mjs compares by realpath.)
|
|
1242
1242
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-")));
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1243
|
+
// Everything from here to the return can throw: a failed `git init` or
|
|
1244
|
+
// `--record`, or spawnBounded's timeout (#2320). The dir goes whenever that
|
|
1245
|
+
// happens, `keep` or not — a throw hands the caller no `r.dir` to remove.
|
|
1246
|
+
// `keep` only spares it on a normal return.
|
|
1247
|
+
let returned = false;
|
|
1248
|
+
try {
|
|
1249
|
+
const bin = join(dir, "bin");
|
|
1250
|
+
const repo = join(dir, "repo");
|
|
1251
|
+
mkdirSync(bin);
|
|
1252
|
+
mkdirSync(join(repo, ".fleet"), { recursive: true });
|
|
1253
|
+
assert.equal(spawnBounded("git", ["init", "-q", repo]).status, 0);
|
|
1254
|
+
writeFileSync(join(repo, ".git", "info", "exclude"), ".fleet/\n.worktrees/\n");
|
|
1255
|
+
writeExecStub(join(bin, "gh"), GH_STUB);
|
|
1256
|
+
const script = join(bin, "fleet-tick.mjs");
|
|
1257
|
+
writeFileSync(script, readFileSync(SCRIPT));
|
|
1258
|
+
for (const [name, path] of SIBLING_MODULES) writeFileSync(join(bin, name), readFileSync(path));
|
|
1259
|
+
writeFileSync(join(bin, "shortlist.mjs"), SHORTLIST_STUB);
|
|
1260
|
+
const fx = (name, content) => { const p = join(dir, name); writeFileSync(p, content); return p; };
|
|
1261
|
+
if (ledger !== undefined) writeFileSync(join(repo, ".fleet", "ledger.md"), ledgerText(ledger));
|
|
1262
|
+
if (shortlist !== undefined) writeFileSync(join(repo, ".fleet", "shortlist.json"), shortlist);
|
|
1263
|
+
beforeRun(repo);
|
|
1264
|
+
if (baseline) {
|
|
1265
|
+
const rec = spawnBounded(process.execPath, [join(bin, "main-checkout.mjs"), "--record"], { cwd: repo });
|
|
1266
|
+
assert.equal(rec.status, 0, rec.stderr);
|
|
1267
|
+
}
|
|
1268
|
+
afterBaseline(repo);
|
|
1269
|
+
const refreshLog = fx("refresh.log", "");
|
|
1270
|
+
const issueViewLog = fx("issue-view.log", "");
|
|
1271
|
+
// Every case gets its own state file unless it names one: the default path
|
|
1272
|
+
// resolves against the git common dir, and a shared streak and digest would
|
|
1273
|
+
// make the fold cases order-dependent. `defaultState` opts out for the case
|
|
1274
|
+
// whose subject IS that resolution.
|
|
1275
|
+
const stateArg = args.includes("--state") || defaultState ? [] : ["--state", join(dir, "heartbeat.json")];
|
|
1276
|
+
const r = spawnBounded(process.execPath, [script, ...args, ...stateArg], {
|
|
1277
|
+
cwd: repo,
|
|
1278
|
+
env: {
|
|
1279
|
+
...process.env, PATH: `${bin}:${process.env.PATH}`,
|
|
1280
|
+
FIXTURE_PRS: fx("prs.json", JSON.stringify(prs)),
|
|
1281
|
+
FIXTURE_CLAIMED: fx("claimed.json", JSON.stringify(claimed)),
|
|
1282
|
+
FIXTURE_ISSUE_STATES: fx("issue-states.json", JSON.stringify(issueStates)),
|
|
1283
|
+
FIXTURE_REFRESH: fx("refresh.json", refresh),
|
|
1284
|
+
REFRESH_LOG: refreshLog, ISSUE_VIEW_LOG: issueViewLog,
|
|
1285
|
+
...(refreshFail ? { REFRESH_FAIL: "1" } : {}),
|
|
1286
|
+
...extraEnv,
|
|
1287
|
+
},
|
|
1288
|
+
});
|
|
1289
|
+
r.refreshed = readFileSync(refreshLog, "utf8").split("\n").filter(Boolean).length;
|
|
1290
|
+
r.issueViews = readFileSync(issueViewLog, "utf8").split("\n").filter(Boolean);
|
|
1291
|
+
r.repo = repo;
|
|
1292
|
+
r.dir = dir;
|
|
1293
|
+
returned = true;
|
|
1294
|
+
return r;
|
|
1295
|
+
} finally {
|
|
1296
|
+
if (!returned || !keep) rmSync(dir, { recursive: true, force: true });
|
|
1261
1297
|
}
|
|
1262
|
-
afterBaseline(repo);
|
|
1263
|
-
const refreshLog = fx("refresh.log", "");
|
|
1264
|
-
const issueViewLog = fx("issue-view.log", "");
|
|
1265
|
-
// Every case gets its own state file unless it names one: the default path
|
|
1266
|
-
// resolves against the git common dir, and a shared streak and digest would
|
|
1267
|
-
// make the fold cases order-dependent. `defaultState` opts out for the case
|
|
1268
|
-
// whose subject IS that resolution.
|
|
1269
|
-
const stateArg = args.includes("--state") || defaultState ? [] : ["--state", join(dir, "heartbeat.json")];
|
|
1270
|
-
const r = spawnBounded(process.execPath, [script, ...args, ...stateArg], {
|
|
1271
|
-
cwd: repo,
|
|
1272
|
-
env: {
|
|
1273
|
-
...process.env, PATH: `${bin}:${process.env.PATH}`,
|
|
1274
|
-
FIXTURE_PRS: fx("prs.json", JSON.stringify(prs)),
|
|
1275
|
-
FIXTURE_CLAIMED: fx("claimed.json", JSON.stringify(claimed)),
|
|
1276
|
-
FIXTURE_ISSUE_STATES: fx("issue-states.json", JSON.stringify(issueStates)),
|
|
1277
|
-
FIXTURE_REFRESH: fx("refresh.json", refresh),
|
|
1278
|
-
REFRESH_LOG: refreshLog, ISSUE_VIEW_LOG: issueViewLog,
|
|
1279
|
-
...(refreshFail ? { REFRESH_FAIL: "1" } : {}),
|
|
1280
|
-
...extraEnv,
|
|
1281
|
-
},
|
|
1282
|
-
});
|
|
1283
|
-
r.refreshed = readFileSync(refreshLog, "utf8").split("\n").filter(Boolean).length;
|
|
1284
|
-
r.issueViews = readFileSync(issueViewLog, "utf8").split("\n").filter(Boolean);
|
|
1285
|
-
r.repo = repo;
|
|
1286
|
-
r.dir = dir;
|
|
1287
|
-
if (!keep) rmSync(dir, { recursive: true, force: true });
|
|
1288
|
-
return r;
|
|
1289
1298
|
}
|
|
1290
1299
|
const lineOf = (r, role) => r.stdout.split("\n").filter((l) => l.startsWith(role));
|
|
1291
1300
|
|
|
1301
|
+
// A throw inside runCli (a failed `git init`/`--record` assert, spawnBounded's
|
|
1302
|
+
// timeout) used to leave its `fleet-tick-*` fixture in the tmpdir.
|
|
1303
|
+
// `beforeRun` runs after the dir exists, so it stands in for any such throw.
|
|
1304
|
+
for (const keep of [false, true]) {
|
|
1305
|
+
test(`CLI: runCli removes its fixture dir when it throws mid-helper, keep: ${keep}`, () => {
|
|
1306
|
+
let dir;
|
|
1307
|
+
assert.throws(() => runCli([], { keep, beforeRun: (repo) => { dir = join(repo, ".."); throw new Error("boom"); } }), /boom/);
|
|
1308
|
+
assert.ok(dir, "beforeRun never ran, so the throw happened before the dir existed");
|
|
1309
|
+
assert.equal(existsSync(dir), false);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
test("CLI: runCli with keep: true hands back a fixture dir that still exists", () => {
|
|
1314
|
+
const r = runCli([], { keep: true });
|
|
1315
|
+
try {
|
|
1316
|
+
assert.equal(existsSync(r.dir), true);
|
|
1317
|
+
} finally {
|
|
1318
|
+
rmSync(r.dir, { recursive: true, force: true });
|
|
1319
|
+
}
|
|
1320
|
+
assert.equal(existsSync(runCli([]).dir), false, "without keep the dir is gone on return");
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1292
1323
|
test("CLI: the #1692 shape, read off the ledger — two live implementers at cap 2 pull nothing", () => {
|
|
1293
1324
|
const r = runCli([], {
|
|
1294
1325
|
ledger: { rows: ["#412 impl-412 · tier-ok=impl-412:fleet-implementer", "#415 impl-415 · tier-ok=impl-415:fleet-implementer"], dispatched: ["impl-412", "impl-415"] },
|
|
@@ -1463,15 +1494,15 @@ test("CLI: a ledger token outside the grammar refuses the tick, printing no row"
|
|
|
1463
1494
|
assert.match(r.stderr, /^fleet-tick: .*impl-9.*'merged' is not an outcome/m);
|
|
1464
1495
|
});
|
|
1465
1496
|
|
|
1466
|
-
test("CLI: a ledger ledger.mjs itself refuses is a refusal here too", () => {
|
|
1497
|
+
test("CLI: a ledger ledger.mjs itself refuses is a refusal here too", (t) => {
|
|
1467
1498
|
// Two drain markers: ledger.mjs read exits 2 on the broken invariant.
|
|
1468
1499
|
const dir = runCli([], { shortlist: shortlistText([]), keep: true });
|
|
1500
|
+
t.after(() => rmSync(dir.dir, { recursive: true, force: true }));
|
|
1469
1501
|
writeFileSync(join(dir.repo, ".fleet", "ledger.md"), `${ledgerText({ drain: "a" })}\n- b\n`);
|
|
1470
1502
|
const r = spawnBounded(process.execPath, [join(dir.dir, "bin", "fleet-tick.mjs"), "--state", join(dir.dir, "hb.json")], {
|
|
1471
1503
|
cwd: dir.repo,
|
|
1472
1504
|
env: { ...process.env, PATH: `${join(dir.dir, "bin")}:${process.env.PATH}`, FIXTURE_PRS: join(dir.dir, "prs.json") },
|
|
1473
1505
|
});
|
|
1474
|
-
rmSync(dir.dir, { recursive: true, force: true });
|
|
1475
1506
|
assert.equal(r.status, 2, r.stderr);
|
|
1476
1507
|
assert.equal(r.stdout.trim(), "");
|
|
1477
1508
|
assert.match(r.stderr, /fleet-tick: ledger\.mjs read exited 2/);
|
|
@@ -1676,38 +1707,38 @@ test("CLI: a past-pin halt prints DISPATCH review for its PR (#2083)", () => {
|
|
|
1676
1707
|
assert.match(r.stdout, /^reviewers {4}0\/6 → DISPATCH review PR#40 /m);
|
|
1677
1708
|
});
|
|
1678
1709
|
|
|
1679
|
-
test("CLI: an ambient GIT_DIR naming another repository cannot move the shortlist read", () => {
|
|
1710
|
+
test("CLI: an ambient GIT_DIR naming another repository cannot move the shortlist read", (t) => {
|
|
1680
1711
|
const decoy = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-decoy-")));
|
|
1712
|
+
t.after(() => rmSync(decoy, { recursive: true, force: true }));
|
|
1681
1713
|
spawnBounded("git", ["init", "-q", decoy]);
|
|
1682
1714
|
mkdirSync(join(decoy, ".fleet"));
|
|
1683
1715
|
writeFileSync(join(decoy, ".fleet", "shortlist.json"), shortlistText([666]));
|
|
1684
1716
|
const r = runCli([], { shortlist: shortlistText([7, 8]), env: { GIT_DIR: join(decoy, ".git") } });
|
|
1685
|
-
rmSync(decoy, { recursive: true, force: true });
|
|
1686
1717
|
assert.equal(r.status, 0, r.stderr);
|
|
1687
1718
|
assert.match(r.stdout, /^implementers 0\/2 → PULL #7 #8 /m);
|
|
1688
1719
|
});
|
|
1689
1720
|
|
|
1690
|
-
test("CLI: an inherited GIT_DIR cannot retarget the tier-mismatch closed-ticket probe", () => {
|
|
1721
|
+
test("CLI: an inherited GIT_DIR cannot retarget the tier-mismatch closed-ticket probe", (t) => {
|
|
1691
1722
|
const decoy = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-decoy-tier-")));
|
|
1723
|
+
t.after(() => rmSync(decoy, { recursive: true, force: true }));
|
|
1692
1724
|
spawnBounded("git", ["init", "-q", decoy]);
|
|
1693
1725
|
const r = runCli([], {
|
|
1694
1726
|
shortlist: shortlistText([1, 2, 3]), ledger: { rows: ["#7 impl-7=tier-mismatch"], dispatched: ["impl-7=tier-mismatch"] },
|
|
1695
1727
|
issueStates: { 7: "OPEN" }, env: { GIT_DIR: join(decoy, ".git"), GH_REPO: "someone/else" },
|
|
1696
1728
|
});
|
|
1697
|
-
rmSync(decoy, { recursive: true, force: true });
|
|
1698
1729
|
assert.equal(r.status, 0, r.stderr);
|
|
1699
1730
|
assert.deepEqual(r.issueViews, ["7"], "the probe must still run");
|
|
1700
1731
|
assert.match(r.stdout, /^implementers 0\/2 → HOLD \(tier mismatch impl-7\)/m, "#7 is OPEN in the case's own repository - the hold stands");
|
|
1701
1732
|
});
|
|
1702
1733
|
|
|
1703
|
-
test("CLI: an inherited GIT_DIR cannot retarget the behind-issue premise probe", () => {
|
|
1734
|
+
test("CLI: an inherited GIT_DIR cannot retarget the behind-issue premise probe", (t) => {
|
|
1704
1735
|
const decoy = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-decoy-gh-")));
|
|
1736
|
+
t.after(() => rmSync(decoy, { recursive: true, force: true }));
|
|
1705
1737
|
spawnBounded("git", ["init", "-q", decoy]);
|
|
1706
1738
|
const r = runCli([], {
|
|
1707
1739
|
shortlist: shortlistText([1, 2, 3]), ledger: { rows: ["#50 excluded · behind-issue:#9"] }, issueStates: { 9: "OPEN" },
|
|
1708
1740
|
env: { GIT_DIR: join(decoy, ".git"), GH_REPO: "someone/else" },
|
|
1709
1741
|
});
|
|
1710
|
-
rmSync(decoy, { recursive: true, force: true });
|
|
1711
1742
|
assert.equal(r.status, 0, r.stderr);
|
|
1712
1743
|
assert.deepEqual(r.issueViews, ["9"], "the probe must still run");
|
|
1713
1744
|
assert.equal(r.refreshed, 0, "#9 is OPEN in the case's own repository — the exclusion stands");
|
|
@@ -1724,11 +1755,12 @@ const tickAgain = (r) => spawnBounded(process.execPath, [join(r.dir, "bin", "fle
|
|
|
1724
1755
|
env: { ...process.env, PATH: `${join(r.dir, "bin")}:${process.env.PATH}`, FIXTURE_PRS: join(r.dir, "prs.json") },
|
|
1725
1756
|
});
|
|
1726
1757
|
|
|
1727
|
-
test("CLI: a stray write after the baseline prints MAIN-CHECKOUT-DIRTY first, naming the path and the live members, and holds every dispatching row", () => {
|
|
1758
|
+
test("CLI: a stray write after the baseline prints MAIN-CHECKOUT-DIRTY first, naming the path and the live members, and holds every dispatching row", (t) => {
|
|
1728
1759
|
const r = runCli([], {
|
|
1729
1760
|
...LIVE, prs: [pr(350)], keep: true,
|
|
1730
1761
|
afterBaseline: (repo) => writeFileSync(join(repo, "stray.mjs"), "x\n"),
|
|
1731
1762
|
});
|
|
1763
|
+
t.after(() => rmSync(r.dir, { recursive: true, force: true }));
|
|
1732
1764
|
assert.equal(r.status, 0, r.stderr);
|
|
1733
1765
|
const lines = r.stdout.trim().split("\n");
|
|
1734
1766
|
assert.match(lines[0], /^MAIN-CHECKOUT-DIRTY stray\.mjs — changed since the run's baseline; live members: impl-412 — dispatch held: resolve the stray paths FIRST, then re-baseline with ~\/\.fleet\/bin\/fleet-run main-checkout\.mjs --record/);
|
|
@@ -1744,7 +1776,6 @@ test("CLI: a stray write after the baseline prints MAIN-CHECKOUT-DIRTY first, na
|
|
|
1744
1776
|
const rec = spawnBounded(process.execPath, [join(r.dir, "bin", "main-checkout.mjs"), "--record"], { cwd: r.repo });
|
|
1745
1777
|
assert.equal(rec.status, 0, rec.stderr);
|
|
1746
1778
|
const after = tickAgain(r);
|
|
1747
|
-
rmSync(r.dir, { recursive: true, force: true });
|
|
1748
1779
|
assert.equal(after.status, 0, after.stderr);
|
|
1749
1780
|
assert.doesNotMatch(after.stdout, /MAIN-CHECKOUT|main checkout/);
|
|
1750
1781
|
assert.match(after.stdout, /^implementers 1\/2 → PULL #420 /m);
|
|
@@ -1782,25 +1813,41 @@ test("CLI: a main checkout git cannot read is MAIN-CHECKOUT-UNKNOWN and held, ne
|
|
|
1782
1813
|
// Three `--fold-unchanged` ticks over one kept fixture and one state file.
|
|
1783
1814
|
const foldTicks = (fixture) => {
|
|
1784
1815
|
const first = runCli(["--fold-unchanged"], { ...fixture, keep: true });
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1816
|
+
try {
|
|
1817
|
+
const state = join(first.dir, "heartbeat.json");
|
|
1818
|
+
const quiet = () => JSON.parse(readFileSync(state, "utf8")).quiet;
|
|
1819
|
+
const ticks = [{ stdout: first.stdout, status: first.status, stderr: first.stderr, quiet: quiet() }];
|
|
1820
|
+
for (let i = 0; i < 2; i++) {
|
|
1821
|
+
const t = spawnBounded(process.execPath, [join(first.dir, "bin", "fleet-tick.mjs"), "--fold-unchanged", "--state", state], {
|
|
1822
|
+
cwd: first.repo,
|
|
1823
|
+
env: {
|
|
1824
|
+
...process.env, PATH: `${join(first.dir, "bin")}:${process.env.PATH}`,
|
|
1825
|
+
FIXTURE_PRS: join(first.dir, "prs.json"), FIXTURE_CLAIMED: join(first.dir, "claimed.json"),
|
|
1826
|
+
FIXTURE_ISSUE_STATES: join(first.dir, "issue-states.json"), FIXTURE_REFRESH: join(first.dir, "refresh.json"),
|
|
1827
|
+
REFRESH_LOG: join(first.dir, "refresh.log"), ISSUE_VIEW_LOG: join(first.dir, "issue-view.log"),
|
|
1828
|
+
},
|
|
1829
|
+
});
|
|
1830
|
+
ticks.push({ stdout: t.stdout, status: t.status, stderr: t.stderr, quiet: quiet() });
|
|
1831
|
+
}
|
|
1832
|
+
return ticks;
|
|
1833
|
+
} finally {
|
|
1834
|
+
rmSync(first.dir, { recursive: true, force: true });
|
|
1799
1835
|
}
|
|
1800
|
-
rmSync(first.dir, { recursive: true, force: true });
|
|
1801
|
-
return ticks;
|
|
1802
1836
|
};
|
|
1803
1837
|
|
|
1838
|
+
// foldTicks keeps its fixture across three ticks, so a throw after the first
|
|
1839
|
+
// run (here: `heartbeat.json` is a directory, so reading the state fails)
|
|
1840
|
+
// must still remove the dir — the caller never gets it back.
|
|
1841
|
+
test("CLI: foldTicks removes its kept fixture dir when a tick throws", () => {
|
|
1842
|
+
let dir;
|
|
1843
|
+
assert.throws(() => foldTicks({
|
|
1844
|
+
...LIVE,
|
|
1845
|
+
afterBaseline: (repo) => { dir = join(repo, ".."); mkdirSync(join(dir, "heartbeat.json")); },
|
|
1846
|
+
}), /EISDIR/);
|
|
1847
|
+
assert.ok(dir, "afterBaseline never ran, so the throw happened before the dir existed");
|
|
1848
|
+
assert.equal(existsSync(dir), false);
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1804
1851
|
for (const [state, afterBaseline] of [
|
|
1805
1852
|
["dirty", (repo) => writeFileSync(join(repo, "stray.mjs"), "x\n")],
|
|
1806
1853
|
["unknown", (repo) => writeFileSync(join(repo, ".git", "index"), "not an index")],
|
|
@@ -1839,12 +1886,12 @@ test("CLI: an in-flight review with no member token is named as review:PR#<n> on
|
|
|
1839
1886
|
assert.match(r.stdout, /^MAIN-CHECKOUT-DIRTY stray\.mjs — changed since the run's baseline; live members: review:PR#51 — /m);
|
|
1840
1887
|
});
|
|
1841
1888
|
|
|
1842
|
-
test("CLI: an ambient GIT_DIR naming a dirty repository does not move the main-checkout check", () => {
|
|
1889
|
+
test("CLI: an ambient GIT_DIR naming a dirty repository does not move the main-checkout check", (t) => {
|
|
1843
1890
|
const decoy = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-decoy-mc-")));
|
|
1891
|
+
t.after(() => rmSync(decoy, { recursive: true, force: true }));
|
|
1844
1892
|
spawnBounded("git", ["init", "-q", decoy]);
|
|
1845
1893
|
writeFileSync(join(decoy, "decoy-stray.txt"), "x\n");
|
|
1846
1894
|
const r = runCli([], { ...LIVE, env: { GIT_DIR: join(decoy, ".git"), GIT_WORK_TREE: decoy } });
|
|
1847
|
-
rmSync(decoy, { recursive: true, force: true });
|
|
1848
1895
|
assert.equal(r.status, 0, r.stderr);
|
|
1849
1896
|
assert.doesNotMatch(r.stdout, /MAIN-CHECKOUT/);
|
|
1850
1897
|
assert.match(r.stdout, /^implementers 1\/2 → PULL #420 /m);
|
|
@@ -1857,8 +1904,9 @@ test("CLI: an ambient GIT_DIR naming a dirty repository does not move the main-c
|
|
|
1857
1904
|
|
|
1858
1905
|
const IDLE = { shortlist: shortlistText([]), refresh: shortlistText([]) };
|
|
1859
1906
|
|
|
1860
|
-
test("CLI: the quiet streak lengthens on an idle tick and resets on an actionable one", () => {
|
|
1907
|
+
test("CLI: the quiet streak lengthens on an idle tick and resets on an actionable one", (t) => {
|
|
1861
1908
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-state-")));
|
|
1909
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1862
1910
|
const path = join(dir, "heartbeat.json");
|
|
1863
1911
|
runCli(["--state", path], IDLE);
|
|
1864
1912
|
assert.equal(JSON.parse(readFileSync(path, "utf8")).quiet, 1);
|
|
@@ -1866,22 +1914,22 @@ test("CLI: the quiet streak lengthens on an idle tick and resets on an actionabl
|
|
|
1866
1914
|
assert.equal(JSON.parse(readFileSync(path, "utf8")).quiet, 2);
|
|
1867
1915
|
runCli(["--state", path], { ...IDLE, shortlist: shortlistText([9]) });
|
|
1868
1916
|
assert.equal(JSON.parse(readFileSync(path, "utf8")).quiet, 0);
|
|
1869
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1870
1917
|
});
|
|
1871
1918
|
|
|
1872
|
-
test("CLI: --fold-unchanged folds a repeated idle tick to one line", () => {
|
|
1919
|
+
test("CLI: --fold-unchanged folds a repeated idle tick to one line", (t) => {
|
|
1873
1920
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-fold-")));
|
|
1921
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1874
1922
|
const path = join(dir, "heartbeat.json");
|
|
1875
1923
|
const first = runCli(["--fold-unchanged", "--state", path], IDLE);
|
|
1876
1924
|
assert.equal(first.status, 0, first.stderr);
|
|
1877
1925
|
assert.ok(first.stdout.trim().split("\n").length >= 3);
|
|
1878
1926
|
const second = runCli(["--fold-unchanged", "--state", path], IDLE);
|
|
1879
1927
|
assert.deepEqual(second.stdout.trim().split("\n"), ["fleet-tick: unchanged, nothing to act on (quiet=2) — full rows on the next change"]);
|
|
1880
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1881
1928
|
});
|
|
1882
1929
|
|
|
1883
|
-
test("CLI: --fold-unchanged never folds an actionable tick, even an identical one", () => {
|
|
1930
|
+
test("CLI: --fold-unchanged never folds an actionable tick, even an identical one", (t) => {
|
|
1884
1931
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-fold-act-")));
|
|
1932
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1885
1933
|
const path = join(dir, "heartbeat.json");
|
|
1886
1934
|
const busy = { shortlist: shortlistText([9, 10]) };
|
|
1887
1935
|
const first = runCli(["--fold-unchanged", "--state", path], busy);
|
|
@@ -1889,11 +1937,11 @@ test("CLI: --fold-unchanged never folds an actionable tick, even an identical on
|
|
|
1889
1937
|
assert.equal(second.stdout, first.stdout);
|
|
1890
1938
|
assert.match(second.stdout, /PULL #9 #10/);
|
|
1891
1939
|
assert.equal(JSON.parse(readFileSync(path, "utf8")).quiet, 0);
|
|
1892
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1893
1940
|
});
|
|
1894
1941
|
|
|
1895
|
-
test("CLI: --fold-unchanged does not fold when the rows change, even with nothing to act on", () => {
|
|
1942
|
+
test("CLI: --fold-unchanged does not fold when the rows change, even with nothing to act on", (t) => {
|
|
1896
1943
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-fold-differs-")));
|
|
1944
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1897
1945
|
const path = join(dir, "heartbeat.json");
|
|
1898
1946
|
runCli(["--fold-unchanged", "--state", path], IDLE);
|
|
1899
1947
|
// A queued candidate, held behind a lower PR: still nothing to act on.
|
|
@@ -1906,25 +1954,25 @@ test("CLI: --fold-unchanged does not fold when the rows change, even with nothin
|
|
|
1906
1954
|
assert.doesNotMatch(second.stdout, /nothing to act on/);
|
|
1907
1955
|
const third = runCli(["--fold-unchanged", "--state", path], held);
|
|
1908
1956
|
assert.match(third.stdout, /^fleet-tick: unchanged, nothing to act on \(quiet=3\)/);
|
|
1909
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1910
1957
|
});
|
|
1911
1958
|
|
|
1912
|
-
test("CLI: with no --state, the tick resolves the run's shared default", () => {
|
|
1959
|
+
test("CLI: with no --state, the tick resolves the run's shared default", (t) => {
|
|
1913
1960
|
const r = runCli([], { ...IDLE, defaultState: true, keep: true });
|
|
1961
|
+
t.after(() => rmSync(r.dir, { recursive: true, force: true }));
|
|
1914
1962
|
const state = join(r.repo, ".fleet", "heartbeat.json");
|
|
1915
1963
|
assert.equal(r.status, 0, r.stderr);
|
|
1916
1964
|
assert.doesNotMatch(r.stderr, /WARNING/);
|
|
1917
1965
|
assert.ok(existsSync(state));
|
|
1918
1966
|
assert.equal(JSON.parse(readFileSync(state, "utf8")).quiet, 1);
|
|
1919
|
-
rmSync(r.dir, { recursive: true, force: true });
|
|
1920
1967
|
});
|
|
1921
1968
|
|
|
1922
1969
|
// The prior run's liveness - #1597.
|
|
1923
1970
|
const beat = (ms, interval, stopped = "") =>
|
|
1924
1971
|
JSON.stringify({ quiet: 0, elapsed: 0, digest: "", beat: { at: Date.now() - ms, interval, stopped } });
|
|
1925
1972
|
|
|
1926
|
-
test("CLI: a stale prior beat is reported, and reported FIRST, with the stranded claims and the supply", () => {
|
|
1973
|
+
test("CLI: a stale prior beat is reported, and reported FIRST, with the stranded claims and the supply", (t) => {
|
|
1927
1974
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-")));
|
|
1975
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1928
1976
|
const path = join(dir, "heartbeat.json");
|
|
1929
1977
|
writeFileSync(path, beat(90 * 60_000, 1200));
|
|
1930
1978
|
const r = runCli(["--state", path], { shortlist: shortlistText([9], 1), claimed: [{ number: 41 }, { number: 42 }, { number: 43 }] });
|
|
@@ -1934,31 +1982,31 @@ test("CLI: a stale prior beat is reported, and reported FIRST, with the stranded
|
|
|
1934
1982
|
assert.match(lines[1], /^implementers/);
|
|
1935
1983
|
assert.match(lines[0], /3 ticket\(s\) claimed and in flight/);
|
|
1936
1984
|
assert.match(lines[0], /pool supply 1/);
|
|
1937
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1938
1985
|
});
|
|
1939
1986
|
|
|
1940
|
-
test("CLI: an unreadable shortlist makes the stall report's supply unknown, never zero", () => {
|
|
1987
|
+
test("CLI: an unreadable shortlist makes the stall report's supply unknown, never zero", (t) => {
|
|
1941
1988
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-supply-")));
|
|
1989
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1942
1990
|
const path = join(dir, "heartbeat.json");
|
|
1943
1991
|
writeFileSync(path, beat(90 * 60_000, 1200));
|
|
1944
1992
|
const r = runCli(["--state", path], { shortlist: "{" });
|
|
1945
1993
|
assert.match(r.stdout.split("\n")[0], /pool supply unknown/);
|
|
1946
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1947
1994
|
});
|
|
1948
1995
|
|
|
1949
|
-
test("CLI: the stall is announced even when the reconcile then refuses", () => {
|
|
1996
|
+
test("CLI: the stall is announced even when the reconcile then refuses", (t) => {
|
|
1950
1997
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-refuse-")));
|
|
1998
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1951
1999
|
const path = join(dir, "heartbeat.json");
|
|
1952
2000
|
writeFileSync(path, beat(90 * 60_000, 1200));
|
|
1953
2001
|
const r = runCli(["--state", path], { ...IDLE, claimed: [{ number: 41 }], env: { PR_FAIL: "1" } });
|
|
1954
2002
|
assert.equal(r.status, 2);
|
|
1955
2003
|
assert.match(r.stderr, /gh pr list failed/);
|
|
1956
2004
|
assert.match(r.stdout, /heartbeat STALLED/);
|
|
1957
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1958
2005
|
});
|
|
1959
2006
|
|
|
1960
|
-
test("CLI: an unreadable claim count is `unknown`, never zero", () => {
|
|
2007
|
+
test("CLI: an unreadable claim count is `unknown`, never zero", (t) => {
|
|
1961
2008
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-unknown-")));
|
|
2009
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1962
2010
|
const path = join(dir, "heartbeat.json");
|
|
1963
2011
|
writeFileSync(path, beat(90 * 60_000, 1200));
|
|
1964
2012
|
const r = runCli(["--state", path], { ...IDLE, env: { CLAIMED_FAIL: "1" } });
|
|
@@ -1966,7 +2014,6 @@ test("CLI: an unreadable claim count is `unknown`, never zero", () => {
|
|
|
1966
2014
|
assert.match(r.stdout, /unknown ticket\(s\) claimed and in flight/);
|
|
1967
2015
|
assert.match(r.stderr, /claimed ticket count unknown/);
|
|
1968
2016
|
assert.match(r.stderr, /boom/);
|
|
1969
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1970
2017
|
});
|
|
1971
2018
|
|
|
1972
2019
|
// The claimed-count query is capped at CLAIMED_LIMIT (200). At exactly the cap
|
|
@@ -1981,8 +2028,9 @@ test("CLI: an unreadable claim count is `unknown`, never zero", () => {
|
|
|
1981
2028
|
// /; 199 ticket/). The literal `199 ticket(s)` adjacency — no `+` allowed
|
|
1982
2029
|
// between the digits and the space — is what refuses `199+`; the `; ` prefix
|
|
1983
2030
|
// only anchors the match to the claimed-count field.
|
|
1984
|
-
test("CLI: a claim count AT the query cap is disclosed as a floor (`200+`); one below it is exact", () => {
|
|
2031
|
+
test("CLI: a claim count AT the query cap is disclosed as a floor (`200+`); one below it is exact", (t) => {
|
|
1985
2032
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stall-cap-")));
|
|
2033
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
1986
2034
|
const path = join(dir, "heartbeat.json");
|
|
1987
2035
|
const stalled = (count) => {
|
|
1988
2036
|
// Re-staled every call: each run records its own tick in the state file.
|
|
@@ -1996,21 +2044,21 @@ test("CLI: a claim count AT the query cap is disclosed as a floor (`200+`); one
|
|
|
1996
2044
|
};
|
|
1997
2045
|
assert.match(stalled(200), /; 200\+ ticket\(s\) claimed and in flight/);
|
|
1998
2046
|
assert.match(stalled(199), /; 199 ticket\(s\) claimed and in flight/);
|
|
1999
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2000
2047
|
});
|
|
2001
2048
|
|
|
2002
|
-
test("CLI: a beat within the interval it promised is not reported at all", () => {
|
|
2049
|
+
test("CLI: a beat within the interval it promised is not reported at all", (t) => {
|
|
2003
2050
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-quiet-")));
|
|
2051
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
2004
2052
|
const path = join(dir, "heartbeat.json");
|
|
2005
2053
|
writeFileSync(path, beat(20 * 60_000, 1200));
|
|
2006
2054
|
assert.doesNotMatch(runCli(["--state", path], IDLE).stdout, /STALLED/);
|
|
2007
2055
|
writeFileSync(path, beat(20 * 60_000, 300));
|
|
2008
2056
|
assert.match(runCli(["--state", path], IDLE).stdout, /STALLED/);
|
|
2009
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2010
2057
|
});
|
|
2011
2058
|
|
|
2012
|
-
test("CLI: the streak write carries the heartbeat's mark instead of erasing it, and writes its own", () => {
|
|
2059
|
+
test("CLI: the streak write carries the heartbeat's mark instead of erasing it, and writes its own", (t) => {
|
|
2013
2060
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-mark-carry-")));
|
|
2061
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
2014
2062
|
const path = join(dir, "heartbeat.json");
|
|
2015
2063
|
const mark = { at: Date.now() - 60_000, interval: 300, stopped: "" };
|
|
2016
2064
|
writeFileSync(path, JSON.stringify({ quiet: 3, elapsed: 7, digest: "old", beat: mark }));
|
|
@@ -2020,11 +2068,11 @@ test("CLI: the streak write carries the heartbeat's mark instead of erasing it,
|
|
|
2020
2068
|
assert.deepEqual(after.beat, mark);
|
|
2021
2069
|
assert.equal(after.quiet, 4);
|
|
2022
2070
|
assert.ok(after.ticked && after.ticked.at >= Date.now() - 5000);
|
|
2023
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2024
2071
|
});
|
|
2025
2072
|
|
|
2026
|
-
test("CLI: a deliberate stop is reported with its reason, and a run that never beat says nothing", () => {
|
|
2073
|
+
test("CLI: a deliberate stop is reported with its reason, and a run that never beat says nothing", (t) => {
|
|
2027
2074
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-stopped-")));
|
|
2075
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
2028
2076
|
const path = join(dir, "heartbeat.json");
|
|
2029
2077
|
writeFileSync(path, beat(6 * 60_000, 300, "context ceiling reached"));
|
|
2030
2078
|
const r = runCli(["--state", path], IDLE);
|
|
@@ -2033,13 +2081,13 @@ test("CLI: a deliberate stop is reported with its reason, and a run that never b
|
|
|
2033
2081
|
assert.doesNotMatch(r.stdout, /without a recorded reason/);
|
|
2034
2082
|
writeFileSync(path, JSON.stringify({ quiet: 0, elapsed: 0, digest: "" }));
|
|
2035
2083
|
assert.doesNotMatch(runCli(["--state", path], IDLE).stdout, /STALLED/);
|
|
2036
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2037
2084
|
});
|
|
2038
2085
|
|
|
2039
|
-
test("CLI: a busy, fully-staffed fleet is not reported STALLED just because the queue never drained", () => {
|
|
2086
|
+
test("CLI: a busy, fully-staffed fleet is not reported STALLED just because the queue never drained", (t) => {
|
|
2040
2087
|
// `beat` refreshes only when the queue drains; a tick two minutes ago on a
|
|
2041
2088
|
// completion edge is what tells a busy run from a dead one.
|
|
2042
2089
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "fleet-tick-busy-not-stalled-")));
|
|
2090
|
+
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
2043
2091
|
const path = join(dir, "heartbeat.json");
|
|
2044
2092
|
writeFileSync(path, JSON.stringify({
|
|
2045
2093
|
quiet: 0, elapsed: 0, digest: "",
|
|
@@ -2053,5 +2101,4 @@ test("CLI: a busy, fully-staffed fleet is not reported STALLED just because the
|
|
|
2053
2101
|
assert.equal(r.status, 0, r.stderr);
|
|
2054
2102
|
assert.doesNotMatch(r.stdout, /STALLED/);
|
|
2055
2103
|
assert.match(r.stdout, /^implementers 2\/2 → AT CAP/m);
|
|
2056
|
-
rmSync(dir, { recursive: true, force: true });
|
|
2057
2104
|
});
|
package/scripts/reap.test.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { join } from "node:path";
|
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { slowTransport, SSH_URL } from "./slow-transport.mjs";
|
|
19
19
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
20
|
+
import { hasAttribute, worktreeNames } from "./worktree-porcelain.mjs";
|
|
20
21
|
|
|
21
22
|
const SCRIPT = fileURLToPath(new URL("./reap.sh", import.meta.url));
|
|
22
23
|
|
|
@@ -43,6 +44,9 @@ const ENV = {
|
|
|
43
44
|
const git = (cwd, ...args) =>
|
|
44
45
|
execFileSync("git", args, { cwd, env: ENV, encoding: "utf8" }).trim();
|
|
45
46
|
|
|
47
|
+
/** worktreeNames() of `w`'s listing: never its paths, which carry TMPDIR (#2531). */
|
|
48
|
+
const listedNames = (w) => worktreeNames(git(w, "worktree", "list", "--porcelain"));
|
|
49
|
+
|
|
46
50
|
// Absolute path to the real git, for any test that shadows `git` on PATH.
|
|
47
51
|
const REAL_GIT = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
|
|
48
52
|
|
|
@@ -746,14 +750,14 @@ test("a successful --apply run still reaps, still prunes, and exits 0 unchanged"
|
|
|
746
750
|
const wtDir = join(w, "..", "stale-wt");
|
|
747
751
|
git(w, "worktree", "add", "-q", "-b", "scratch/stale", wtDir, "main");
|
|
748
752
|
rmSync(wtDir, { recursive: true, force: true });
|
|
749
|
-
assert.
|
|
753
|
+
assert.ok(listedNames(w).includes("stale-wt"), "fixture must start with a prunable registration");
|
|
750
754
|
|
|
751
755
|
const { code, json } = runReap(w, ["--apply"]);
|
|
752
756
|
|
|
753
757
|
assert.equal(code, 0);
|
|
754
758
|
assert.deepEqual(json.reaped, ["feature/merged"]);
|
|
755
759
|
assert.deepEqual(json.kept, []);
|
|
756
|
-
assert.
|
|
760
|
+
assert.ok(!listedNames(w).includes("stale-wt"), "the prune must still run and clear the stale registration");
|
|
757
761
|
});
|
|
758
762
|
|
|
759
763
|
// The other half of the same guard, and the half no test had: the default
|
|
@@ -1307,7 +1311,7 @@ test("a [gone] branch whose worktree directory was deleted by hand is reaped, ne
|
|
|
1307
1311
|
const w = repo(t);
|
|
1308
1312
|
const wt = mergedGoneBranchWithWorktree(w, "feature/merged", "merged work");
|
|
1309
1313
|
rmSync(wt, { recursive: true, force: true });
|
|
1310
|
-
assert.
|
|
1314
|
+
assert.ok(listedNames(w).includes("feature/merged"), "fixture: the stale registration must still be listed");
|
|
1311
1315
|
|
|
1312
1316
|
const { code, json } = runReap(w, ["--apply"]);
|
|
1313
1317
|
|
|
@@ -1315,7 +1319,7 @@ test("a [gone] branch whose worktree directory was deleted by hand is reaped, ne
|
|
|
1315
1319
|
assert.deepEqual(json.reaped, ["feature/merged"], "a directory that is not there holds no work to protect");
|
|
1316
1320
|
assert.deepEqual(json.kept, []);
|
|
1317
1321
|
assert.equal(branchExists(w, "feature/merged"), false);
|
|
1318
|
-
assert.
|
|
1322
|
+
assert.ok(!listedNames(w).includes("feature/merged"), "the stale registration must not survive the run");
|
|
1319
1323
|
});
|
|
1320
1324
|
|
|
1321
1325
|
test("a dry run and --apply agree about a deleted worktree directory (#128)", (t) => {
|
|
@@ -2010,7 +2014,7 @@ test("a refusal that LEFT the registration in place is reported as such, distinc
|
|
|
2010
2014
|
assert.doesNotMatch(json.kept[0].reason, /\n/, "git's message here really is two lines — it must arrive flattened");
|
|
2011
2015
|
assert.match(stderr, /KEEP feature\/merged/);
|
|
2012
2016
|
|
|
2013
|
-
assert.
|
|
2017
|
+
assert.ok(listedNames(w).includes("feature/merged"), "the registration really did survive");
|
|
2014
2018
|
assert.equal(existsSync(wt), true);
|
|
2015
2019
|
assert.equal(branchExists(w, "feature/merged"), true);
|
|
2016
2020
|
});
|
|
@@ -2543,9 +2547,9 @@ test("the dry run removes nothing, and still cannot predict a refusal (#391)", (
|
|
|
2543
2547
|
// Nothing was touched: directories, registrations and branches all intact.
|
|
2544
2548
|
assert.equal(existsSync(healthy), true, "a dry run must not remove the worktree it COULD have removed");
|
|
2545
2549
|
assert.equal(existsSync(locked), true);
|
|
2546
|
-
const reg =
|
|
2547
|
-
assert.
|
|
2548
|
-
assert.
|
|
2550
|
+
const reg = listedNames(w);
|
|
2551
|
+
assert.ok(reg.includes("feature/a-healthy"));
|
|
2552
|
+
assert.ok(reg.includes("feature/b-locked"));
|
|
2549
2553
|
assert.equal(branchExists(w, "feature/a-healthy"), true);
|
|
2550
2554
|
assert.equal(branchExists(w, "feature/b-locked"), true);
|
|
2551
2555
|
});
|
|
@@ -2663,7 +2667,8 @@ test("a detached worktree whose HEAD is merged is removed, not walked past (#381
|
|
|
2663
2667
|
const w = repo(t);
|
|
2664
2668
|
const wt = detachedMergedWorktree(w, "docs/79-brief", "work that landed");
|
|
2665
2669
|
assert.ok(existsSync(wt), "fixture");
|
|
2666
|
-
assert.
|
|
2670
|
+
assert.ok(hasAttribute(git(w, "worktree", "list", "--porcelain"), "detached"), "fixture: the worktree must really be detached");
|
|
2671
|
+
assert.ok(listedNames(w).includes("79-brief"), "fixture: the worktree must start out listed");
|
|
2667
2672
|
|
|
2668
2673
|
const { code, json, stderr } = runReap(w, ["--apply"]);
|
|
2669
2674
|
|
|
@@ -2674,7 +2679,7 @@ test("a detached worktree whose HEAD is merged is removed, not walked past (#381
|
|
|
2674
2679
|
assert.ok(stderr.includes(` REMOVED worktree ${wt}`), `the removal must be reported: ${stderr}`);
|
|
2675
2680
|
// The registration goes with it, or the in-flight probe still reads the
|
|
2676
2681
|
// ticket as taken — the whole cost this ticket exists to stop.
|
|
2677
|
-
assert.
|
|
2682
|
+
assert.ok(!listedNames(w).includes("79-brief"));
|
|
2678
2683
|
});
|
|
2679
2684
|
|
|
2680
2685
|
test("a branchless sweep whose awk cannot read the listing declines, never sweeps silently (#993)", (t) => {
|
|
@@ -2798,7 +2803,7 @@ test("a detached worktree whose HEAD git cannot resolve is kept, never swept (#3
|
|
|
2798
2803
|
writeFileSync(join(adminEntry(w, wt), "HEAD"), "not an object id\n");
|
|
2799
2804
|
assert.match(
|
|
2800
2805
|
git(w, "worktree", "list", "--porcelain"),
|
|
2801
|
-
|
|
2806
|
+
/^HEAD 0{40}$/m,
|
|
2802
2807
|
"fixture: git must report the null object id for this worktree",
|
|
2803
2808
|
);
|
|
2804
2809
|
|
|
@@ -2958,14 +2963,15 @@ test("a detached worktree whose directory was deleted by hand still has its regi
|
|
|
2958
2963
|
const w = repo(t);
|
|
2959
2964
|
const wt = detachedMergedWorktree(w, "docs/79-brief", "work that landed");
|
|
2960
2965
|
rmSync(wt, { recursive: true, force: true });
|
|
2961
|
-
assert.
|
|
2966
|
+
assert.ok(hasAttribute(git(w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the stale registration must still be listed");
|
|
2967
|
+
assert.ok(listedNames(w).includes("79-brief"), "fixture: the stale registration must be listed by name before the run");
|
|
2962
2968
|
|
|
2963
2969
|
const { code, json } = runReap(w, ["--apply"]);
|
|
2964
2970
|
|
|
2965
2971
|
assert.equal(code, 0);
|
|
2966
2972
|
assert.deepEqual(json.worktreesRemoved, [wt]);
|
|
2967
2973
|
assert.deepEqual(json.kept, []);
|
|
2968
|
-
assert.
|
|
2974
|
+
assert.ok(!listedNames(w).includes("79-brief"));
|
|
2969
2975
|
});
|
|
2970
2976
|
|
|
2971
2977
|
test("a detached worktree behind an unreadable parent is kept, never removed as absent (#381, #83)", (t) => {
|
|
@@ -22,6 +22,7 @@ import { dirname, join } from "node:path";
|
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
23
|
import { slowTransport, SSH_URL } from "./slow-transport.mjs";
|
|
24
24
|
import { writeExecStub } from "./exec-stub.mjs";
|
|
25
|
+
import { hasAttribute } from "./worktree-porcelain.mjs";
|
|
25
26
|
|
|
26
27
|
const SCRIPT = fileURLToPath(new URL("./release-ticket.sh", import.meta.url));
|
|
27
28
|
const INFLIGHT = fileURLToPath(new URL("./inflight.sh", import.meta.url));
|
|
@@ -994,6 +995,7 @@ test("the locked-stray unlock hint survives a literal ' in the worktree path (#1
|
|
|
994
995
|
git(c.wt, "checkout", "-q", "--detach", "HEAD");
|
|
995
996
|
git(r.w, "worktree", "lock", c.wt, "--reason", "held by a review");
|
|
996
997
|
rmSync(c.wt, { recursive: true, force: true });
|
|
998
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"), "fixture: the stray must really be locked before the remedy runs");
|
|
997
999
|
|
|
998
1000
|
const { code, json, stderr } = release(r, c);
|
|
999
1001
|
assert.equal(code, 1, stderr);
|
|
@@ -1005,7 +1007,7 @@ test("the locked-stray unlock hint survives a literal ' in the worktree path (#1
|
|
|
1005
1007
|
execFileSync("sh", ["-c", remedy], { cwd: r.w, encoding: "utf8", env: ENV });
|
|
1006
1008
|
|
|
1007
1009
|
assert.ok(
|
|
1008
|
-
!git(r.w, "worktree", "list", "--porcelain")
|
|
1010
|
+
!hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"),
|
|
1009
1011
|
`the pasted command actually unlocked the real entry, not a truncated one: ${remedy}`,
|
|
1010
1012
|
);
|
|
1011
1013
|
});
|
|
@@ -1034,6 +1036,7 @@ test("the locked-stray unlock hint survives a plain space in the worktree path (
|
|
|
1034
1036
|
git(c.wt, "checkout", "-q", "--detach", "HEAD");
|
|
1035
1037
|
git(r.w, "worktree", "lock", c.wt, "--reason", "held by a review");
|
|
1036
1038
|
rmSync(c.wt, { recursive: true, force: true });
|
|
1039
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"), "fixture: the stray must really be locked before the remedy runs");
|
|
1037
1040
|
|
|
1038
1041
|
const { code, json, stderr } = release(r, c);
|
|
1039
1042
|
assert.equal(code, 1, stderr);
|
|
@@ -1045,7 +1048,7 @@ test("the locked-stray unlock hint survives a plain space in the worktree path (
|
|
|
1045
1048
|
execFileSync("sh", ["-c", remedy], { cwd: r.w, encoding: "utf8", env: ENV });
|
|
1046
1049
|
|
|
1047
1050
|
assert.ok(
|
|
1048
|
-
!git(r.w, "worktree", "list", "--porcelain")
|
|
1051
|
+
!hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "locked"),
|
|
1049
1052
|
`the pasted command actually unlocked the real entry, not a truncated one: ${remedy}`,
|
|
1050
1053
|
);
|
|
1051
1054
|
});
|
|
@@ -2732,7 +2735,7 @@ test("an unrelated prunable worktree does not block release of a branch it never
|
|
|
2732
2735
|
const stale = join(r.w, "..", "stale-wt");
|
|
2733
2736
|
git(r.w, "worktree", "add", "-q", "--detach", stale, "origin/main");
|
|
2734
2737
|
rmSync(stale, { recursive: true, force: true });
|
|
2735
|
-
assert.
|
|
2738
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the stale registration must still be listed");
|
|
2736
2739
|
|
|
2737
2740
|
const { code, json, stderr } = release(r, c);
|
|
2738
2741
|
assert.equal(code, 0, stderr);
|
|
@@ -2748,7 +2751,7 @@ test("a prunable sibling that genuinely holds the claim via a stopped rebase sti
|
|
|
2748
2751
|
// no longer exists to be entered.
|
|
2749
2752
|
const { r, c, sib } = heldSibling(t, stopAtEditRebase);
|
|
2750
2753
|
rmSync(sib, { recursive: true, force: true });
|
|
2751
|
-
assert.
|
|
2754
|
+
assert.ok(hasAttribute(git(r.w, "worktree", "list", "--porcelain"), "prunable"), "fixture: the sibling's directory must be gone, its registration still there");
|
|
2752
2755
|
|
|
2753
2756
|
const { code, json, stderr } = release(r, c);
|
|
2754
2757
|
assert.equal(code, 2, stderr);
|
|
@@ -3605,9 +3608,8 @@ test("a worktree with no surviving ancestor below / still releases, never a perm
|
|
|
3605
3608
|
// died BEFORE it, so a fix that merely downgraded that `die` to a warning
|
|
3606
3609
|
// would exit 0 here too, with the stale registration still standing and the
|
|
3607
3610
|
// claim reported released — the same false success from the other side.
|
|
3608
|
-
assert.
|
|
3609
|
-
git(r.w, "worktree", "list", "--porcelain"),
|
|
3610
|
-
/nonexistent-top-level-178/,
|
|
3611
|
+
assert.ok(
|
|
3612
|
+
!git(r.w, "worktree", "list", "--porcelain").split("\n").includes(`worktree ${dest}`),
|
|
3611
3613
|
"the registration must actually be gone, not merely un-refused",
|
|
3612
3614
|
);
|
|
3613
3615
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// What a test guard may ask of a `git worktree list --porcelain` listing.
|
|
2
|
+
//
|
|
3
|
+
// Every `worktree` line carries the full path, and a fixture's path starts
|
|
4
|
+
// with the operator's TMPDIR, so a guard that greps the whole listing for a
|
|
5
|
+
// fixture's name or for an attribute answers for TMPDIR as well: one holding
|
|
6
|
+
// `locked`, `stale-wt` or `79-brief` failed the guard that looks for its
|
|
7
|
+
// absence (#2531). These read only the parts a fixture controls.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The names the listing gives its worktrees: each `worktree` line's last path
|
|
11
|
+
* component, and each `branch refs/heads/` line's branch. The directories
|
|
12
|
+
* above a worktree are never a name.
|
|
13
|
+
*/
|
|
14
|
+
export const worktreeNames = (porcelain) =>
|
|
15
|
+
porcelain.split("\n").flatMap((l) =>
|
|
16
|
+
l.startsWith("worktree ") ? [l.slice("worktree ".length).replace(/.*\//, "")]
|
|
17
|
+
: l.startsWith("branch refs/heads/") ? [l.slice("branch refs/heads/".length)]
|
|
18
|
+
: []);
|
|
19
|
+
|
|
20
|
+
const ATTRIBUTES = new Set(["locked", "prunable", "detached"]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether any worktree in the listing carries the attribute `attr`
|
|
24
|
+
* (`locked`, `prunable`, `detached`). git prints an attribute on a line of its
|
|
25
|
+
* own, the bare word or the word, a space and a reason, so the line is matched
|
|
26
|
+
* whole and a path containing the word never is. Any other `attr` throws: a
|
|
27
|
+
* misspelling would answer false for ever, and a guard that asserts the
|
|
28
|
+
* attribute's absence would pass on it.
|
|
29
|
+
*/
|
|
30
|
+
export const hasAttribute = (porcelain, attr) => {
|
|
31
|
+
if (!ATTRIBUTES.has(attr)) {
|
|
32
|
+
throw new Error(`hasAttribute: unknown attribute ${JSON.stringify(attr)}; one of ${[...ATTRIBUTES].join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
return porcelain.split("\n").some((l) => l === attr || l.startsWith(`${attr} `));
|
|
35
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// worktree-porcelain.mjs against real `git worktree list --porcelain` output.
|
|
2
|
+
// Zero deps: `node --test plugin/scripts/worktree-porcelain.test.mjs`.
|
|
3
|
+
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { hasAttribute, worktreeNames } from "./worktree-porcelain.mjs";
|
|
11
|
+
|
|
12
|
+
const ENV = {
|
|
13
|
+
...process.env,
|
|
14
|
+
GIT_DIR: undefined,
|
|
15
|
+
GIT_WORK_TREE: undefined,
|
|
16
|
+
GIT_AUTHOR_NAME: "t",
|
|
17
|
+
GIT_AUTHOR_EMAIL: "t@example.com",
|
|
18
|
+
GIT_COMMITTER_NAME: "t",
|
|
19
|
+
GIT_COMMITTER_EMAIL: "t@example.com",
|
|
20
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
21
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const git = (cwd, ...args) =>
|
|
25
|
+
execFileSync("git", args, { cwd, env: ENV, encoding: "utf8" }).trim();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A checkout on `main` whose parent directories spell the attributes the guards
|
|
29
|
+
* look for (`locked`, `prunable`, `detached`) and some of the names they look
|
|
30
|
+
* for (`feature/merged`, `79-brief`), the way an operator's TMPDIR can.
|
|
31
|
+
*/
|
|
32
|
+
function repoUnder(t) {
|
|
33
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "worktree-porcelain-")));
|
|
34
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
35
|
+
const w = join(root, "locked", "prunable", "detached", "feature", "merged", "79-brief", "w");
|
|
36
|
+
mkdirSync(w, { recursive: true });
|
|
37
|
+
execFileSync("git", ["-c", "init.defaultBranch=main", "init", "-q", w], { env: ENV });
|
|
38
|
+
git(w, "commit", "-q", "--allow-empty", "-m", "root");
|
|
39
|
+
return w;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const listing = (w) => git(w, "worktree", "list", "--porcelain");
|
|
43
|
+
|
|
44
|
+
test("a name or attribute that is only in a worktree's parent directories is not reported", (t) => {
|
|
45
|
+
const w = repoUnder(t);
|
|
46
|
+
git(w, "worktree", "add", "-q", "-b", "fix/9-x", join(w, ".worktrees", "9-x"), "main");
|
|
47
|
+
const l = listing(w);
|
|
48
|
+
for (const word of ["locked", "prunable", "detached", "feature/merged", "79-brief"]) {
|
|
49
|
+
assert.ok(l.includes(word), `fixture: the listing must carry ${word} in a path: ${l}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(worktreeNames(l), ["w", "main", "9-x", "fix/9-x"]);
|
|
53
|
+
for (const attr of ["locked", "prunable", "detached"]) {
|
|
54
|
+
assert.equal(hasAttribute(l, attr), false, `${attr} is only a parent directory here: ${l}`);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("a worktree's own basename, branch and attributes are reported", (t) => {
|
|
59
|
+
const w = repoUnder(t);
|
|
60
|
+
const brief = join(w, ".worktrees", "79-brief");
|
|
61
|
+
git(w, "worktree", "add", "-q", "-b", "feature/merged", brief, "main");
|
|
62
|
+
const lone = join(w, ".worktrees", "lone");
|
|
63
|
+
git(w, "worktree", "add", "-q", "--detach", lone, "main");
|
|
64
|
+
|
|
65
|
+
assert.deepEqual(worktreeNames(listing(w)), ["w", "main", "79-brief", "feature/merged", "lone"]);
|
|
66
|
+
assert.equal(hasAttribute(listing(w), "detached"), true, "the bare-word form");
|
|
67
|
+
|
|
68
|
+
git(w, "worktree", "lock", brief, "--reason", "held by a review");
|
|
69
|
+
assert.match(listing(w), /^locked held by a review$/m, "fixture: a reason follows the word on its line");
|
|
70
|
+
assert.equal(hasAttribute(listing(w), "locked"), true, "the word-and-reason form");
|
|
71
|
+
git(w, "worktree", "unlock", brief);
|
|
72
|
+
git(w, "worktree", "lock", brief);
|
|
73
|
+
assert.equal(hasAttribute(listing(w), "locked"), true);
|
|
74
|
+
git(w, "worktree", "unlock", brief);
|
|
75
|
+
assert.equal(hasAttribute(listing(w), "locked"), false);
|
|
76
|
+
|
|
77
|
+
rmSync(lone, { recursive: true, force: true });
|
|
78
|
+
assert.equal(hasAttribute(listing(w), "prunable"), true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a lock reason that spells a guard's prefix is not a name", (t) => {
|
|
82
|
+
const w = repoUnder(t);
|
|
83
|
+
const a = join(w, ".worktrees", "a");
|
|
84
|
+
git(w, "worktree", "add", "-q", "-b", "fix/a", a, "main");
|
|
85
|
+
git(w, "worktree", "lock", a, "--reason", "worktree on usb");
|
|
86
|
+
const b = join(w, ".worktrees", "b");
|
|
87
|
+
git(w, "worktree", "add", "-q", "-b", "fix/b", b, "main");
|
|
88
|
+
git(w, "worktree", "lock", b, "--reason", "branch refs/heads/held");
|
|
89
|
+
const c = join(w, ".worktrees", "c");
|
|
90
|
+
git(w, "worktree", "add", "-q", "-b", "fix/c", c, "main");
|
|
91
|
+
git(w, "worktree", "lock", c, "--reason", "see refs/heads/held");
|
|
92
|
+
const l = listing(w);
|
|
93
|
+
for (const reason of ["worktree on usb", "branch refs/heads/held", "see refs/heads/held"]) {
|
|
94
|
+
assert.match(l, new RegExp(`^locked ${reason}$`, "m"), `fixture: the reason is on its own line: ${l}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
assert.deepEqual(worktreeNames(l), ["w", "main", "a", "fix/a", "b", "fix/b", "c", "fix/c"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an attribute git never prints is refused, not answered false", () => {
|
|
101
|
+
assert.throws(() => hasAttribute("worktree /x\nlocked\n", "lock"), /unknown attribute "lock"/);
|
|
102
|
+
});
|