SAFE in packages/seat/src/checkout.mjs adds maintenance.auto=false and gc.auto=0; probe, tests, mutants m1-m3 (all killed), targeted gate (seat 132/0, cli 184/0) and the leftover sweep. Candidate is build.patch against 2727dd3ec; manifest digests check out. Co-Authored-By: Claude Opus 5.5 <[email protected]>
121 lines
6.8 KiB
Diff
121 lines
6.8 KiB
Diff
diff --git a/packages/seat/README.md b/packages/seat/README.md
|
|
index ee7afbeb6..7796d8887 100644
|
|
--- a/packages/seat/README.md
|
|
+++ b/packages/seat/README.md
|
|
@@ -304,7 +304,13 @@ only the `file` transport. A submodule has its own config, which none of
|
|
those checks reads and which can name a filter that `git status` would run,
|
|
so the host never recurses into one: a clone with a submodule is kept, and
|
|
`status` runs with `--ignore-submodules=all` and every call with
|
|
-`submodule.recurse=false`. The root is read with `GIT_CEILING_DIRECTORIES`
|
|
+`submodule.recurse=false`. Every call also carries `maintenance.auto=false`
|
|
+and `gc.auto=0`. Without them a fetch or a fast-forward merge starts
|
|
+`git maintenance run --auto`, which detaches from the call's process group
|
|
+and can repack the clone after the call has returned, where the time limit
|
|
+below no longer reaches it. On git 2.55 either key alone stops that start;
|
|
+`maintenance.auto=false` is the documented switch, and `gc.auto=0` also
|
|
+stops a `gc --auto` that runs anyway. The root is read with `GIT_CEILING_DIRECTORIES`
|
|
at its parent, so a root that isn't a repository is never taken for the
|
|
one above it. Each git call gets SIGTERM after 100 s and SIGKILL 5 s
|
|
later, as below.
|
|
diff --git a/packages/seat/src/checkout.mjs b/packages/seat/src/checkout.mjs
|
|
index 03081f5b5..5b8fe60a1 100644
|
|
--- a/packages/seat/src/checkout.mjs
|
|
+++ b/packages/seat/src/checkout.mjs
|
|
@@ -67,6 +67,11 @@ const SAFE = [
|
|
"-c", "protocol.allow=never",
|
|
"-c", "protocol.file.allow=always",
|
|
"-c", "submodule.recurse=false",
|
|
+ // A fetch or a merge would start `git maintenance run --auto`, which
|
|
+ // detaches from the call's process group and repacks the clone after the
|
|
+ // call has returned. gc.auto=0 also stops a gc --auto that runs anyway.
|
|
+ "-c", "maintenance.auto=false",
|
|
+ "-c", "gc.auto=0",
|
|
];
|
|
|
|
// The environment git runs with: the host's PATH and nothing else of it.
|
|
diff --git a/packages/seat/tests/checkout.test.mjs b/packages/seat/tests/checkout.test.mjs
|
|
index a3751df01..d93788482 100644
|
|
--- a/packages/seat/tests/checkout.test.mjs
|
|
+++ b/packages/seat/tests/checkout.test.mjs
|
|
@@ -9,6 +9,7 @@ import { createHash } from "node:crypto";
|
|
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
+import { deflateSync } from "node:zlib";
|
|
import { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs";
|
|
|
|
function tmp(t) {
|
|
@@ -240,6 +241,72 @@ test("a dirty clone whose fetch fails is kept, fetch-failed, exactly as it was",
|
|
assert.ok(!l.inClone().includes("merge"));
|
|
});
|
|
|
|
+// Row 118 (#1612): every host git call carries maintenance.auto=false and
|
|
+// gc.auto=0, so a fetch or a merge in a clone starts no maintenance.
|
|
+
|
|
+// Whether a logged call has `-c <kv>` among its arguments.
|
|
+const carries = (call, kv) => call.split(" ").some((a, i, all) => a === "-c" && all[i + 1] === kv);
|
|
+
|
|
+test("every git call the host makes, for a new clone and an existing one, carries maintenance.auto=false and gc.auto=0", async (t) => {
|
|
+ const w = world(t);
|
|
+ const l = logged(w);
|
|
+ const ensure = () => ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: l.env });
|
|
+ assert.equal((await ensure())[0].outcome, "cloned");
|
|
+ const two = commit(w.root, "two\n");
|
|
+ assert.deepEqual(await ensure(), [{ project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: two }]);
|
|
+ const calls = readFileSync(join(w.dir, "calls"), "utf8").split("\n").filter(Boolean);
|
|
+ for (const sub of ["clone", "fetch", "merge"]) assert.ok(calls.some((c) => c.split(" ").includes(sub)), `a ${sub} ran`);
|
|
+ for (const c of calls) for (const kv of ["maintenance.auto=false", "gc.auto=0"]) assert.ok(carries(c, kv), `${kv} is on: ${c}`);
|
|
+});
|
|
+
|
|
+// A git on PATH that logs every git run, the ones git starts itself
|
|
+// included. git puts its exec path first on PATH for its children, so the
|
|
+// exec path here is a copy of the real one whose git is this logger.
|
|
+function loggedAll(w) {
|
|
+ const bin = join(w.dir, "exec");
|
|
+ mkdirSync(bin);
|
|
+ const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
|
|
+ const libexec = spawnSync(real, ["--exec-path"], { encoding: "utf8" }).stdout.trim();
|
|
+ for (const name of readdirSync(libexec)) if (name !== "git") symlinkSync(join(libexec, name), join(bin, name));
|
|
+ const calls = join(w.dir, "calls");
|
|
+ writeFileSync(join(bin, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nGIT_EXEC_PATH='${bin}' exec '${real}' "$@"\n`, { mode: 0o755 });
|
|
+ return { env: { PATH: `${bin}:${process.env.PATH}` }, calls: () => readFileSync(calls, "utf8").split("\n").filter(Boolean) };
|
|
+}
|
|
+
|
|
+// `n` loose blobs in the clone, each with an id starting 17. gc --auto
|
|
+// counts the loose objects in objects/17 only, and more than 27 there is
|
|
+// over its default limit of 6700.
|
|
+function loose(clone, n) {
|
|
+ const dir = join(clone, ".git", "objects", "17");
|
|
+ mkdirSync(dir, { recursive: true });
|
|
+ for (let i = 0, made = 0; made < n; i++) {
|
|
+ const body = Buffer.from(`loose ${i}\n`);
|
|
+ const raw = Buffer.concat([Buffer.from(`blob ${body.length}\0`), body]);
|
|
+ const id = createHash("sha1").update(raw).digest("hex");
|
|
+ if (!id.startsWith("17")) continue;
|
|
+ writeFileSync(join(dir, id.slice(2)), deflateSync(raw));
|
|
+ made++;
|
|
+ }
|
|
+ return readdirSync(dir).map((name) => join(dir, name));
|
|
+}
|
|
+
|
|
+// The clone's config can't turn maintenance on (allowed() keeps such a
|
|
+// clone), so the trigger is the default config plus loose objects enough
|
|
+// for gc --auto. Without both keys the fetch and the merge each start
|
|
+// `maintenance run --auto`, which repacks them.
|
|
+test("a clone with loose objects enough for gc --auto gets no maintenance or gc from the fetch and the merge", async (t) => {
|
|
+ const w = world(t);
|
|
+ await once(w);
|
|
+ const objects = loose(w.clone, 40);
|
|
+ const two = commit(w.root, "two\n");
|
|
+ const l = loggedAll(w);
|
|
+ assert.deepEqual(await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: l.env }), [{ project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: two }]);
|
|
+ const calls = l.calls();
|
|
+ assert.ok(calls.some((c) => !c.startsWith("-c core.hooksPath=")), "the log shows a git that git started");
|
|
+ assert.deepEqual(calls.filter((c) => ["maintenance", "gc", "repack"].some((sub) => c.split(" ").includes(sub))), [], "no maintenance, gc or repack ran");
|
|
+ for (const o of objects) assert.ok(existsSync(o), `${o} is still loose`);
|
|
+});
|
|
+
|
|
// Row 104 (#1598): a session that sets its own git identity in the clone's
|
|
// config gets the clone kept at every later launch. The host only reads the
|
|
// clone's config file and runs no git in it, so the kept result has no commit.
|