From c2525dd33579a8fb3659e54379d09bde392d6f61 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 14 Oct 2026 14:52:07 -0500 Subject: [PATCH] seat: the host's git runs no background maintenance in a role clone (row 118, #1612) Candidate manifest eeb67779c919b7c1f25b4449df7207d85634e5175b16eef315346cf5a284c63c from Darkwing's round 1 packet (5a68df727), on BASE 2727dd3ec. No manifest path changed between BASE and the landing target. Dewey approved in 28479, with no blocking or doc-only findings. Sage reran the full gate at the landing target with the candidate applied. Co-Authored-By: Claude Opus 5.5 --- packages/seat/README.md | 8 +++- packages/seat/src/checkout.mjs | 5 ++ packages/seat/tests/checkout.test.mjs | 67 +++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) 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 ` 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.