@feigi/fleet-ctl 3.19.4 → 3.19.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.19.4",
3
+ "version": "3.19.6",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -45,8 +45,9 @@
45
45
  // Exit 1: NOT PROVEN — no cache written; the reason on stderr.
46
46
  // Exit 2: no verdict on the proof — it could not be attempted (usage, not a
47
47
  // repository, origin/main missing, the worktree could not be made), or
48
- // a filesystem fault stopped it before the cache was settled (the
49
- // cache or its temp file could not be written).
48
+ // a git or filesystem fault stopped it before the cache was settled
49
+ // (a git command could not be started; the cache or its temp file
50
+ // could not be written).
50
51
 
51
52
  import { spawnSync } from "node:child_process";
52
53
  import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
@@ -108,8 +109,16 @@ function parseArgs(argv) {
108
109
  };
109
110
  }
110
111
 
112
+ // A git that cannot be started is no verdict, as for `sh` below: read as a
113
+ // failed git command, it would surface as whatever that command's failure
114
+ // means to its caller — a missing repository, an unfetched origin/main, a
115
+ // proof that did not hold. Only a spawn that produced no process counts:
116
+ // spawnSync also sets `error` for a git that ran and was killed — ENOBUFS,
117
+ // when its output outgrows the default maxBuffer — and that is a failed git
118
+ // command like any other, never a git that could not be started.
111
119
  function git(args, cwd) {
112
120
  const r = spawnSync("git", args, { cwd, env: ENV, encoding: "utf8" });
121
+ if (r.error && !r.pid) throw cannot(`could not start git: ${r.error.message}`);
113
122
  return { ok: r.status === 0, out: (r.stdout ?? "").replace(/\n$/, ""), err: (r.stderr ?? "").trim() };
114
123
  }
115
124
 
@@ -231,9 +240,19 @@ function writeCache(cache, recipe, repo) {
231
240
  // is the usual one; the repo ignores them) makes both git's removal and a
232
241
  // plain recursive delete fail, so the fallback makes the tree writable first.
233
242
  // The prune always runs: it is what drops the registration of a worktree
234
- // whose directory is gone.
243
+ // whose directory is gone. A git that cannot be started is reported and
244
+ // skipped here, since this runs in the proof's `finally`.
235
245
  function removeWorktree(wt, repo) {
236
- if (!git(["worktree", "remove", "--force", wt], repo).ok) {
246
+ const tryGit = (args) => {
247
+ try {
248
+ return git(args, repo).ok;
249
+ } catch (e) {
250
+ if (!(e instanceof Refusal)) throw e;
251
+ process.stderr.write(`${NAME}: skipped git ${args.join(" ")}: ${e.message}\n`);
252
+ return false;
253
+ }
254
+ };
255
+ if (!tryGit(["worktree", "remove", "--force", wt])) {
237
256
  spawnSync("chmod", ["-R", "u+w", wt], { stdio: "ignore" });
238
257
  try {
239
258
  rmSync(wt, { recursive: true, force: true });
@@ -241,7 +260,7 @@ function removeWorktree(wt, repo) {
241
260
  process.stderr.write(`${NAME}: could not remove the throwaway worktree ${wt}: ${e.message}\n`);
242
261
  }
243
262
  }
244
- git(["worktree", "prune"], repo);
263
+ tryGit(["worktree", "prune"]);
245
264
  }
246
265
 
247
266
  function main(argv) {
@@ -1,7 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { execFileSync, spawnSync } from "node:child_process";
4
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { tempDir } from "./temp-dir.mjs";
7
7
  import { writeExecStub } from "./exec-stub.mjs";
@@ -371,13 +371,75 @@ test("a proof that cannot be attempted exits 2, distinct from a Recipe that is n
371
371
  "a refusal at the argument boundary creates no worktree");
372
372
  });
373
373
 
374
+ // Every temp dir the proof made, scanned for the throwaway worktree it names `wt`.
375
+ function assertNoWorktreeLeft(tmp) {
376
+ for (const d of readdirSync(tmp)) {
377
+ assert.deepEqual(readdirSync(join(tmp, d)).filter((n) => n === "wt"), [], `${d} still holds the throwaway worktree`);
378
+ }
379
+ }
380
+
374
381
  test("the proof leaves no worktree directory behind in its temp dir", () => {
375
382
  const { dir } = repo(MAVEN_FILES);
376
383
  const r = prove(dir, MAVEN_PROOF);
377
384
  assert.equal(r.status, 0, r.err);
378
- for (const d of readdirSync(r.tmp)) {
379
- assert.deepEqual(readdirSync(join(r.tmp, d)).filter((n) => n === "wt"), [], `${d} still holds the throwaway worktree`);
380
- }
385
+ assertNoWorktreeLeft(r.tmp);
386
+ });
387
+
388
+ test("a git that cannot be started is named as such, not reported as a missing repository", () => {
389
+ const { dir } = repo(MAVEN_FILES);
390
+ const r = prove(dir, MAVEN_PROOF, { env: { PATH: "/nonexistent" } });
391
+ assert.equal(r.status, 2, r.err);
392
+ assert.match(r.err, /^recipe-prove: could not start git: .*ENOENT/);
393
+ assert.doesNotMatch(r.err, /not a git repository/);
394
+ });
395
+
396
+ // A PATH holding only `sh` and a `git` that execs the real one, so the Install
397
+ // step can take git away by deleting it: every later git call then
398
+ // fails to start, in the proof and in the worktree cleanup alike.
399
+ test("a git that stops starting during the Install step is no verdict, never NOT PROVEN — and the worktree is still removed", () => {
400
+ const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
401
+ const { dir } = repo(MAVEN_FILES);
402
+ const bin = tempDir("recipe-prove-path-");
403
+ writeExecStub(join(bin, "git"), `#!/bin/sh\nexec '${realGit}' "$@"\n`);
404
+ symlinkSync("/bin/sh", join(bin, "sh"));
405
+ const install = `'${process.execPath}' -e 'require("fs").rmSync(process.argv[1])' '${join(bin, "git")}'`;
406
+ const r = prove(dir, ["--install", install, "--test", "true", "--count-line", "x 1", "--test-count", "1"], { env: { PATH: bin } });
407
+ assert.equal(r.status, 2, r.err);
408
+ assert.match(r.err, /could not start git: .*ENOENT/);
409
+ assert.match(r.err, /skipped git worktree remove/);
410
+ assert.doesNotMatch(r.err, /NOT PROVEN/);
411
+ assertNoWorktreeLeft(r.tmp);
412
+ });
413
+
414
+ // A git that started and ran is never "could not start": here its status
415
+ // output outgrows spawnSync's default 1 MiB buffer, which kills it and sets
416
+ // `error` (ENOBUFS) on a process that did run. The tree the Install step left
417
+ // is unreadable, so the proof does not hold — NOT PROVEN, exit 1.
418
+ test("a git whose output outgrows the spawn buffer is a failed command, not a git that could not be started", () => {
419
+ const { dir } = repo(MAVEN_FILES);
420
+ const flood = 'const fs = require("fs"); for (let i = 0; i < 7000; i++) fs.writeFileSync("f".repeat(200) + i, "")';
421
+ const install = `'${process.execPath}' -e '${flood}'`;
422
+ const r = prove(dir, ["--install", install, "--test", "true", "--count-line", "x 1", "--test-count", "1"]);
423
+ assert.equal(r.status, 1, r.err);
424
+ assert.match(r.err, /NOT PROVEN — could not read the tree state in the throwaway worktree/);
425
+ assert.doesNotMatch(r.err, /could not start git/);
426
+ });
427
+
428
+ // A git that starts for the worktree removal and not for the prune after it:
429
+ // the stub deletes itself once `worktree remove` has run. The proof's own
430
+ // outcome is NOT PROVEN (the Install step fails), and the prune that cannot
431
+ // start must not replace it.
432
+ test("a git that stops starting during cleanup does not replace the proof's own NOT PROVEN", () => {
433
+ const realGit = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim();
434
+ const { dir } = repo(MAVEN_FILES);
435
+ const bin = tempDir("recipe-prove-path-");
436
+ const stub = join(bin, "git");
437
+ writeExecStub(stub, `#!/bin/sh\n'${realGit}' "$@"\nrc=$?\nif [ "$2" = remove ]; then /bin/rm -f '${stub}'; fi\nexit $rc\n`);
438
+ symlinkSync("/bin/sh", join(bin, "sh"));
439
+ const r = prove(dir, ["--install", "false", "--test", "true", "--count-line", "x 1", "--test-count", "1"], { env: { PATH: bin } });
440
+ assert.equal(r.status, 1, r.err);
441
+ assert.match(r.err, /NOT PROVEN/);
442
+ assert.match(r.err, /skipped git worktree prune: could not start git/);
381
443
  });
382
444
 
383
445
  // Whether the process ignores file modes: root writes into a 0555 directory.
@@ -393,9 +455,7 @@ test("read-only install output cannot turn a proof that held into a crash", { sk
393
455
  const r = prove(dir, ["--install", READ_ONLY_INSTALL, ...MAVEN_PROOF.slice(2)]);
394
456
  assert.equal(r.status, 0, r.err);
395
457
  assert.equal(JSON.parse(readFileSync(cachePath(dir), "utf8")).derivedAt, head);
396
- for (const d of readdirSync(r.tmp)) {
397
- assert.deepEqual(readdirSync(join(r.tmp, d)).filter((n) => n === "wt"), [], `${d} still holds the throwaway worktree`);
398
- }
458
+ assertNoWorktreeLeft(r.tmp);
399
459
  assert.equal(git(dir, "worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length, 1);
400
460
  });
401
461