Files
stack/packages/seat/tests/checkout.test.mjs
T
jason.woltjeandClaude Opus 5.5 e62712e7aa seat: fetch a role clone that is on another branch or dirty (#1611)
Row 117. At a launch, update() kept a clone that was on another branch
or dirty and ran no git in it, so its refs/remotes/origin/* stayed as
old as the last clean launch. Now those two reasons get the clean
path's fetch, with the same arguments and environment, and no merge.
The result is a new outcome, fetched, with the reason, the clone's
branch and HEAD, and origin { branch, commit } after the fetch. Every
earlier check still keeps the clone with no fetch. The launcher writes
a checkout-fetched launch-log line, and the bundle prompt names a
fetched clone's branch, commit and origin. Nothing in unsafe(),
allowed(), gitEnv() or SAFE changed.

Candidate manifest 9fa839edfb2bd04771bbac1913b142cb16aef227a1265120e0d318fb64d6a502
from Darkwing's round 1 packet (17d801f48), on BASE 559ef2287. Dewey
approved in 28443. Sage reran the full gate at the landing target with
the candidate applied.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 12:42:29 -05:00

546 lines
30 KiB
JavaScript

// The project checkouts in a role workspace (src/checkout.mjs), against
// real git repositories in a temp directory. Every case also checks that
// the root's files and refs are byte for byte what they were.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
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 { CONFIG_MAX_BYTES, CheckoutError, ensureCheckouts, gitEnv } from "../src/checkout.mjs";
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-checkout-"));
t.after(() => {
spawnSync("chmod", ["-R", "u+rwx", dir]);
rmSync(dir, { recursive: true, force: true });
});
return dir;
}
// git for the test's own setup, with no user config.
function git(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
// A repository on `main` with one commit.
function repo(dir, name = "root") {
const root = join(dir, name);
mkdirSync(root);
git(root, "init", "-q", "-b", "main");
writeFileSync(join(root, "README"), "one\n");
git(root, "add", "README");
git(root, "commit", "-q", "-m", "one");
return root;
}
function commit(root, text) {
writeFileSync(join(root, "README"), text);
git(root, "commit", "-q", "-am", text.trim());
return git(root, "rev-parse", "HEAD");
}
// Every path under `dir` with its type, mode, link count and contents or
// link target, but for any `skip` names at its top.
function fingerprint(dir, skip = []) {
const h = createHash("sha256");
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
if (d === dir && skip.includes(name)) continue;
const p = join(d, name);
const st = lstatSync(p);
h.update(`${p}\0${st.mode}\0${st.nlink}\0`);
if (st.isDirectory()) walk(p);
else if (st.isSymbolicLink()) h.update(readlinkSync(p));
else if (st.isFile()) h.update(readFileSync(p));
}
};
walk(dir);
return h.digest("hex");
}
// A root, an empty workspace, and `ensure()` for the one project `stack`.
function world(t) {
const dir = tmp(t);
const root = repo(dir);
const workspace = join(dir, "ws");
mkdirSync(workspace, { mode: 0o700 });
const projects = { stack: { root } };
return { dir, root, workspace, clone: join(workspace, "stack"), ensure: () => ensureCheckouts({ workspace, projects }) };
}
// ensure() returning its one checkout, with the root unchanged by it.
async function once(w) {
const before = fingerprint(w.root);
const list = await w.ensure();
assert.equal(list.length, 1);
assert.equal(fingerprint(w.root), before, "the root is unchanged");
return list[0];
}
test("a first launch clones the root at its branch, with the root's path as origin", async (t) => {
const w = world(t);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "cloned", branch: "main", commit: git(w.root, "rev-parse", "HEAD") });
assert.equal(git(w.clone, "symbolic-ref", "--short", "HEAD"), "main");
assert.equal(git(w.clone, "rev-parse", "HEAD"), git(w.root, "rev-parse", "HEAD"));
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "one\n");
assert.deepEqual(readdirSync(w.workspace), ["stack"], "no temp directory is left");
// --no-hardlinks: no file under .git is shared with the root, and no hook came with it.
const files = readdirSync(join(w.clone, ".git"), { recursive: true }).map((p) => join(w.clone, ".git", p)).filter((p) => lstatSync(p).isFile());
assert.ok(files.some((p) => p.includes("/objects/")));
for (const p of files) assert.equal(lstatSync(p).nlink, 1, p);
assert.equal(existsSync(join(w.clone, ".git", "hooks")), false);
});
// A host stopped mid-clone must not leave a half clone where the next
// launch takes it for the session's. A git on PATH runs the real one, then
// notes whether <workspace>/stack exists yet.
test("the clone is not at its place until git clone has finished", async (t) => {
const w = world(t);
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const seen = join(w.dir, "seen");
writeFileSync(join(bin, "git"), `#!/bin/sh\n'${real}' "$@"; s=$?\ncase " $* " in *" clone "*) if [ -e '${w.clone}' ]; then echo there; else echo absent; fi >> '${seen}';; esac\nexit $s\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.equal(list[0].outcome, "cloned");
assert.equal(readFileSync(seen, "utf8"), "absent\n");
assert.deepEqual(readdirSync(w.workspace), ["stack"]);
});
test("a later launch fast-forwards a clean clone after a commit to the root, then finds it current", async (t) => {
const w = world(t);
await once(w);
const head = commit(w.root, "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: head });
assert.equal(git(w.clone, "rev-parse", "HEAD"), head);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main", commit: head });
assert.equal(git(w.clone, "status", "--porcelain"), "");
});
test("a clone ahead of the root keeps its commits and counts as current", async (t) => {
const w = world(t);
await once(w);
const mine = commit(w.clone, "mine\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "current", branch: "main", commit: mine });
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
});
// HEAD, every local branch, the index and every path outside .git. A fetch
// may write objects, refs/remotes, FETCH_HEAD and packed-refs, none of these.
function work(clone) {
const h = createHash("sha256");
h.update(readFileSync(join(clone, ".git", "HEAD")));
h.update(git(clone, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads"));
h.update(readFileSync(join(clone, ".git", "index")));
h.update(fingerprint(clone, [".git"]));
return h.digest("hex");
}
// Row 117 (#1611): a clone on another branch, or dirty, is fetched and not
// merged. Only its refs/remotes moves.
test("a dirty clone and one on another branch are fetched, and nothing but refs/remotes moves", async (t) => {
for (const [name, edit, reason, branch] of [
["a modified file", (c) => writeFileSync(join(c, "README"), "mine\n"), "dirty", "main"],
["an untracked file", (c) => writeFileSync(join(c, "NEW"), "mine\n"), "dirty", "main"],
["a staged file", (c) => (writeFileSync(join(c, "NEW"), "x"), git(c, "add", "NEW")), "dirty", "main"],
["another branch", (c) => git(c, "checkout", "-q", "-b", "work"), "other-branch", "work"],
["a detached HEAD", (c) => git(c, "checkout", "-q", "--detach"), "other-branch", null],
]) {
await t.test(name, async (t) => {
const w = world(t);
await once(w);
edit(w.clone);
const two = commit(w.root, "two\n");
const head = git(w.clone, "rev-parse", "HEAD");
const before = work(w.clone);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "fetched", reason, branch, commit: head, origin: { branch: "main", commit: two } });
assert.equal(git(w.clone, "rev-parse", "refs/remotes/origin/main"), two, "the fetch brought the root's commit");
assert.equal(git(w.clone, "rev-parse", "HEAD"), head, "HEAD didn't move");
assert.equal(work(w.clone), before, "HEAD, the branches, the index and the files are exactly as they were");
});
}
});
test("a diverged clone gets the fetch into refs/remotes and is kept, not-fast-forward", async (t) => {
const w = world(t);
await once(w);
const mine = commit(w.clone, "mine\n");
const two = commit(w.root, "two\n");
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason: "not-fast-forward" });
assert.equal(git(w.clone, "rev-parse", "HEAD"), mine);
assert.equal(git(w.clone, "rev-parse", "refs/remotes/origin/main"), two);
assert.equal(readFileSync(join(w.clone, "README"), "utf8"), "mine\n");
});
// A git on PATH that logs every call to <dir>/calls, and fails a call
// whose subcommand is in <dir>/fail.
function logged(w) {
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const calls = join(w.dir, "calls");
const fail = join(w.dir, "fail");
writeFileSync(join(bin, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nif [ -f '${fail}' ]; then case " $* " in *" $(cat '${fail}') "*) exit 1;; esac; fi\nexec '${real}' "$@"\n`, { mode: 0o755 });
return {
env: { PATH: `${bin}:${process.env.PATH}` },
fail: (sub) => writeFileSync(fail, sub),
// The subcommand of each call that ran git in the clone.
inClone: () => (existsSync(calls) ? readFileSync(calls, "utf8").split("\n").filter((l) => l.includes(`--git-dir=${join(w.clone, ".git")}`)).map((l) => l.split(" ").find((a, i, all) => i > 0 && all[i - 1].startsWith("--work-tree="))) : []),
};
}
// Every reason found before the branch check keeps the clone with no fetch,
// and so does a status that fails. The root moves on; the clone's
// refs/remotes doesn't.
test("a clone kept before the branch check, or for a failed status, gets no fetch", async (t) => {
for (const [name, edit, reason, ran] of [
["not-a-clone", (w) => rmSync(join(w.clone, ".git"), { recursive: true }), "not-a-clone", []],
["hooks-present", (w) => (mkdirSync(join(w.clone, ".git", "hooks")), writeFileSync(join(w.clone, ".git", "hooks", "post-merge"), "#!/bin/sh\n")), "hooks-present", []],
["submodule", (w) => git(w.clone, "update-index", "--add", "--cacheinfo", `160000,${git(w.root, "rev-parse", "HEAD")},sub`), "submodule", ["ls-files"]],
["config-changed", (w) => git(w.clone, "config", "user.name", "someone"), "config-changed", []],
["root-unreadable", (w) => rmSync(join(w.root, ".git"), { recursive: true }), "root-unreadable", ["ls-files"]],
["root-detached", (w) => git(w.root, "checkout", "-q", "--detach"), "root-detached", ["ls-files"]],
["status-failed, on the root's branch and clean", (w, l) => l.fail("status"), "status-failed", ["ls-files", "symbolic-ref", "status"]],
["status-failed, dirty", (w, l) => (writeFileSync(join(w.clone, "README"), "mine\n"), l.fail("status")), "status-failed", ["ls-files", "symbolic-ref", "status"]],
]) {
await t.test(name, async (t) => {
const w = world(t);
await once(w);
const one = git(w.clone, "rev-parse", "refs/remotes/origin/main");
// Every case but root-unreadable has a newer root commit to fetch.
if (reason !== "root-unreadable") commit(w.root, "two\n");
const l = logged(w);
edit(w, l);
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: l.env });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason }]);
assert.deepEqual(l.inClone(), ran, "no fetch, and no git past the check that kept it");
if (existsSync(join(w.clone, ".git", "refs"))) assert.equal(git(w.clone, "rev-parse", "refs/remotes/origin/main"), one, "refs/remotes didn't move");
});
}
});
test("a dirty clone whose fetch fails is kept, fetch-failed, exactly as it was", async (t) => {
const w = world(t);
await once(w);
writeFileSync(join(w.clone, "README"), "mine\n");
commit(w.root, "two\n");
const l = logged(w);
l.fail("fetch");
const before = fingerprint(w.clone);
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: l.env });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason: "fetch-failed" }]);
assert.equal(fingerprint(w.clone), before);
assert.ok(!l.inClone().includes("merge"));
});
// 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.
test("a clone with user.name or user.email in its config is kept, and git runs nothing in it", async (t) => {
for (const key of ["user.name", "user.email"]) {
await t.test(key, async (t) => {
const w = world(t);
await once(w);
git(w.clone, "config", key, "someone");
commit(w.root, "two\n");
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const calls = join(w.dir, "calls");
writeFileSync(join(bin, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${calls}'\nexec '${real}' "$@"\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "kept", reason: "config-changed" }]);
// The one call naming the clone reads its config file, as unsafe() does for every clone.
const named = readFileSync(calls, "utf8").split("\n").filter((l) => l.includes(w.clone));
assert.deepEqual(named.map((l) => l.slice(l.indexOf(" config "))), [` config --file ${join(w.clone, ".git", "config")} --list --null`], "no git ran in the kept clone");
});
}
});
// When git can't read the clone's HEAD after the fast-forward, the commit is
// null, not an empty string. A git on PATH fails the second rev-parse HEAD.
test("a commit git can't read after the fast-forward is null", async (t) => {
const w = world(t);
await once(w);
commit(w.root, "two\n");
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
const count = join(w.dir, "count");
writeFileSync(join(bin, "git"), `#!/bin/sh\ncase " $* " in *" rev-parse HEAD ") echo x >> '${count}'; [ "$(wc -l < '${count}')" -ge 2 ] && exit 1;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
const list = await ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } });
assert.deepEqual(list, [{ project: "stack", path: w.clone, outcome: "updated", branch: "main", commit: null }]);
});
// A new clone whose HEAD git can't read refuses like a failed clone, and its
// temp directory goes. A git on PATH fails rev-parse --verify HEAD.
test("a new clone whose commit can't be read refuses the launch and leaves nothing behind", async (t) => {
const w = world(t);
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
writeFileSync(join(bin, "git"), `#!/bin/sh\ncase " $* " in *" rev-parse --verify HEAD ") echo 'fatal: no HEAD' >&2; exit 128;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env: { PATH: `${bin}:${process.env.PATH}` } }), (e) => e instanceof CheckoutError && e.project === "stack" && /^the new clone of .* has no readable HEAD: /.test(e.message));
assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
});
// A repository outside the workspace whose config and hooks run a command
// that touches `marker`, for the cases that point git there.
function bait(dir, marker) {
const evil = repo(dir, "evil");
const script = join(dir, "run.sh");
writeFileSync(script, `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
git(evil, "config", "core.fsmonitor", script);
git(evil, "config", "core.hooksPath", join(evil, ".git", "hooks"));
mkdirSync(join(evil, ".git", "hooks"), { recursive: true });
for (const h of ["post-merge", "post-checkout", "reference-transaction", "pre-auto-gc", "post-index-change"]) writeFileSync(join(evil, ".git", "hooks", h), `#!/bin/sh\ntouch '${marker}'\n`, { mode: 0o755 });
return { evil, script };
}
// A repository at <dir>/sub whose own config names a clean filter that
// runs the bait script, applied to a tracked file left modified at the same
// size, so any git status inside it must hash the file and run the filter.
// Returns its commit.
function nested(dir, b) {
const sub = join(dir, "sub");
mkdirSync(sub);
git(sub, "init", "-q", "-b", "main");
writeFileSync(join(sub, "data"), "one\n");
writeFileSync(join(sub, ".gitattributes"), "data filter=x\n");
git(sub, "add", "data", ".gitattributes");
git(sub, "commit", "-q", "-m", "sub");
git(sub, "config", "filter.x.clean", b.script);
writeFileSync(join(sub, "data"), "ONE\n");
return git(sub, "rev-parse", "HEAD");
}
const gitlink = (c, b) => git(c, "update-index", "--add", "--cacheinfo", `160000,${nested(c, b)},sub`);
// <clone>/sub carries its own gitlink to <clone>/sub/sub, which alone names the filter.
const gitlinkNested = (c, b) => {
const outer = join(c, "sub");
mkdirSync(outer);
git(outer, "init", "-q", "-b", "main");
writeFileSync(join(outer, "f"), "x\n");
git(outer, "add", "f");
gitlink(outer, b);
git(outer, "commit", "-q", "-m", "outer");
git(c, "update-index", "--add", "--cacheinfo", `160000,${git(outer, "rev-parse", "HEAD")},sub`);
};
const gitmodules = (c) => (writeFileSync(join(c, ".gitmodules"), '[submodule "sub"]\n\tpath = sub\n\turl = ./sub\n'), git(c, "add", ".gitmodules"));
// Each way a session could make the host's git run a command or write
// outside the clone. Every one keeps the clone, nothing runs, and neither
// the root nor the bait repository changes.
test("a clone the session rigged is kept, and git runs nothing and writes nothing outside it", async (t) => {
const egit = (b) => join(b.evil, ".git");
for (const [name, edit, reason] of [
["core.fsmonitor", (c, b) => git(c, "config", "core.fsmonitor", b.script), "config-changed"],
["core.hooksPath", (c, b) => git(c, "config", "core.hooksPath", join(egit(b), "hooks")), "config-changed"],
["a hook file", (c, b) => {
mkdirSync(join(c, ".git", "hooks"));
writeFileSync(join(c, ".git", "hooks", "post-merge"), readFileSync(join(egit(b), "hooks", "post-merge")), { mode: 0o755 });
}, "hooks-present"],
["include.path", (c, b) => git(c, "config", "include.path", join(egit(b), "config")), "config-changed"],
["includeIf", (c, b) => git(c, "config", "includeIf.gitdir:/.path", join(egit(b), "config")), "config-changed"],
["a filter driver", (c, b) => {
git(c, "config", "filter.x.smudge", `${b.script}; cat`);
mkdirSync(join(c, ".git", "info"), { recursive: true });
writeFileSync(join(c, ".git", "info", "attributes"), "* filter=x\n");
}, "config-changed"],
["origin moved", (c, b) => git(c, "remote", "set-url", "origin", b.evil), "config-changed"],
["a second origin url", (c, b) => git(c, "config", "--add", "remote.origin.url", b.evil), "config-changed"],
["the origin url twice, both the root", (c) => git(c, "config", "--add", "remote.origin.url", git(c, "config", "--get", "remote.origin.url")), "config-changed"],
["an ext:: origin", (c, b) => git(c, "remote", "set-url", "origin", `ext::${b.script}`), "config-changed"],
["a branch tracking another remote", (c) => git(c, "config", "branch.main.remote", "elsewhere"), "config-changed"],
["a branch merging a tag", (c) => git(c, "config", "branch.main.merge", "refs/tags/x"), "config-changed"],
["a fetch line onto local branches", (c) => git(c, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*"), "config-changed"],
["no origin", (c) => git(c, "remote", "remove", "origin"), "config-changed"],
["a config file over the size cap", (c) => writeFileSync(join(c, ".git", "config"), `${readFileSync(join(c, ".git", "config"), "utf8")}#${"x".repeat(CONFIG_MAX_BYTES)}\n`), "config-changed"],
["config a symlink to /dev/zero", (c) => (rmSync(join(c, ".git", "config")), symlinkSync("/dev/zero", join(c, ".git", "config"))), "not-a-clone"],
["the clone a symlink to the bait", (c, b) => (rmSync(c, { recursive: true }), symlinkSync(b.evil, c)), "not-a-clone"],
["the clone a file", (c) => (rmSync(c, { recursive: true }), writeFileSync(c, "x")), "not-a-clone"],
["the clone a plain directory", (c) => rmSync(join(c, ".git"), { recursive: true }), "not-a-clone"],
[".git a gitdir: file", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), writeFileSync(join(c, ".git"), `gitdir: ${egit(b)}\n`)), "not-a-clone"],
[".git a symlink", (c, b) => (rmSync(join(c, ".git"), { recursive: true }), symlinkSync(egit(b), join(c, ".git"))), "not-a-clone"],
[".git/logs a symlink", (c, b) => (rmSync(join(c, ".git", "logs"), { recursive: true }), symlinkSync(join(egit(b), "logs"), join(c, ".git", "logs"))), "not-a-clone"],
[".git/refs/heads a symlink", (c, b) => (rmSync(join(c, ".git", "refs", "heads"), { recursive: true }), symlinkSync(join(egit(b), "refs", "heads"), join(c, ".git", "refs", "heads"))), "not-a-clone"],
[".git/objects a symlink", (c, b) => (rmSync(join(c, ".git", "objects"), { recursive: true }), symlinkSync(join(egit(b), "objects"), join(c, ".git", "objects"))), "not-a-clone"],
["a reflog hard-linked to a file outside", (c, b) => (rmSync(join(c, ".git", "logs", "HEAD")), linkSync(join(egit(b), "logs", "HEAD"), join(c, ".git", "logs", "HEAD"))), "not-a-clone"],
["HEAD a fifo", (c) => (rmSync(join(c, ".git", "HEAD")), spawnSync("mkfifo", [join(c, ".git", "HEAD")])), "not-a-clone"],
["a commondir file", (c, b) => writeFileSync(join(c, ".git", "commondir"), `${egit(b)}\n`), "not-a-clone"],
["an alternates file", (c, b) => (mkdirSync(join(c, ".git", "objects", "info"), { recursive: true }), writeFileSync(join(c, ".git", "objects", "info", "alternates"), `${join(egit(b), "objects")}\n`)), "not-a-clone"],
// A submodule's own config is outside every check above, and git status
// runs a second git inside a populated submodule.
["a gitlink staged, no .gitmodules", gitlink, "submodule"],
["a gitlink and .gitmodules staged", (c, b) => (gitlink(c, b), gitmodules(c)), "submodule"],
["a gitlink to a repository with its own gitlink", gitlinkNested, "submodule"],
["a gitlink and .gitmodules committed", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "commit", "-q", "-m", "sub")), "submodule"],
["a gitlink and submodule.active in config", (c, b) => (gitlink(c, b), gitmodules(c), git(c, "config", "submodule.active", ".")), "config-changed"],
["a .git/modules with no gitlink", (c, b) => {
const m = join(c, ".git", "modules", "sub");
git(c, "init", "-q", "--bare", "--template=", m);
git(m, "config", "filter.x.clean", b.script);
}, "submodule"],
]) {
await t.test(name, async (t) => {
const w = world(t);
const marker = join(w.dir, "RAN");
const b = bait(w.dir, marker);
await once(w);
edit(w.clone, b);
commit(w.root, "two\n");
const evilBefore = fingerprint(b.evil);
assert.deepEqual(await once(w), { project: "stack", path: w.clone, outcome: "kept", reason });
assert.equal(existsSync(marker), false, "nothing the session wrote ran");
assert.equal(fingerprint(b.evil), evilBefore, "nothing outside the clone was written");
});
}
});
test("a root that can't be cloned refuses with the project id and leaves nothing behind", async (t) => {
for (const [name, make, why] of [
["missing", (w) => rmSync(w.root, { recursive: true }), /can't be read/],
["not a repository", (w) => rmSync(join(w.root, ".git"), { recursive: true }), /can't be read/],
["a plain directory inside another repository", (w) => {
rmSync(join(w.root, ".git"), { recursive: true });
git(w.dir, "init", "-q", "-b", "main");
git(w.dir, "commit", "-q", "--allow-empty", "-m", "outer");
}, /can't be read/],
["unreadable", (w) => chmodSync(w.root, 0o000), /can't be read/],
["on no branch", (w) => git(w.root, "checkout", "-q", "--detach"), /no branch checked out/],
["missing an object", (w) => {
// git starts writing the clone, then can't check the file out.
const blob = git(w.root, "rev-parse", "HEAD:README");
rmSync(join(w.root, ".git", "objects", blob.slice(0, 2), blob.slice(2)));
}, /git clone of .* failed/],
]) {
await t.test(name, async (t) => {
const w = world(t);
make(w);
await assert.rejects(w.ensure, (e) => e instanceof CheckoutError && e.project === "stack" && why.test(e.message));
assert.deepEqual(readdirSync(w.workspace), [], "no clone and no temp directory");
});
}
});
test("a clone whose root is on no branch, or gone, is kept and named", async (t) => {
const w = world(t);
await once(w);
git(w.root, "checkout", "-q", "--detach");
assert.equal((await once(w)).reason, "root-detached");
rmSync(join(w.root, ".git"), { recursive: true });
// The clone's config still names this path, so its check passes and the root's read fails.
assert.deepEqual(await w.ensure(), [{ project: "stack", path: w.clone, outcome: "kept", reason: "root-unreadable" }]);
});
test("no credential or forge remote of the root reaches a clone", async (t) => {
const w = world(t);
git(w.root, "remote", "add", "origin", "https://bot:[email protected]/acme/stack.git");
git(w.root, "remote", "add", "gitea", "ssh://[email protected]/acme/stack.git");
git(w.root, "config", "http.extraHeader", "Authorization: token s3cr3t-token");
const secret = () => spawnSync("grep", ["-rlE", "s3cr3t|git\\.example", join(w.clone, ".git")], { encoding: "utf8" }).stdout;
await once(w);
assert.equal(git(w.clone, "remote"), "origin");
assert.equal(git(w.clone, "remote", "get-url", "origin"), w.root);
assert.equal(secret(), "");
// And the fast-forward keeps it that way.
commit(w.root, "two\n");
assert.equal((await once(w)).outcome, "updated");
assert.equal(secret(), "");
});
test("every project in the business file gets its own clone at its own branch", async (t) => {
const dir = tmp(t);
const a = repo(dir, "a");
const b = repo(dir, "b");
git(b, "checkout", "-q", "-b", "dev");
const workspace = join(dir, "ws");
mkdirSync(workspace);
const list = await ensureCheckouts({ workspace, projects: { alpha: { root: a }, beta: { root: b } } });
assert.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch, c.commit]), [["alpha", join(workspace, "alpha"), "cloned", "main", git(a, "rev-parse", "HEAD")], ["beta", join(workspace, "beta"), "cloned", "dev", git(b, "rev-parse", "HEAD")]]);
assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev");
});
// A git on PATH that, for `git clone`, runs `body` in place of the real one.
// Every other git is the real one.
function slowClone(w, body) {
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
writeFileSync(join(bin, "git"), `#!/bin/bash\ncase " $* " in *" clone "*) ${body} ;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
return { PATH: `${bin}:${process.env.PATH}` };
}
// True once `pid` has gone, or is a zombie waiting for init to reap it.
async function gone(pid, ms = 3000) {
const end = Date.now() + ms;
for (;;) {
let stat = null;
try {
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
} catch {}
if (stat === null || stat.slice(stat.lastIndexOf(")") + 2)[0] === "Z") return true;
if (Date.now() > end) return false;
await new Promise((r) => setTimeout(r, 50));
}
}
// SIGKILL to a sleep a test left behind, if it still runs.
const kill = (pid) => {
try {
process.kill(pid, "SIGKILL");
} catch {}
};
// The pid a wrapper wrote to `file`, once it is there.
async function pidIn(file) {
for (let i = 0; i < 100 && !existsSync(file); i++) await new Promise((r) => setTimeout(r, 20));
return Number(readFileSync(file, "utf8").trim());
}
test("a clone past its time gets SIGTERM and refuses the launch, with nothing left in the workspace", async (t) => {
const w = world(t);
const env = slowClone(w, "exec sleep 30");
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), (e) => e instanceof CheckoutError && /killed by SIGTERM/.test(e.message));
assert.deepEqual(readdirSync(w.workspace), []);
});
test("a clone that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => {
const w = world(t);
const env = slowClone(w, "trap '' TERM; sleep 30");
const started = Date.now();
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 300, graceMs: 700 } }), (e) => e instanceof CheckoutError && /killed by SIGKILL/.test(e.message));
assert.ok(Date.now() - started >= 1000, "SIGKILL waited out the grace period");
});
// git is the leader of its own process group, and the group gets the signal:
// a process git started (here a sleep) doesn't outlive the kill.
test("a timeout signals git's process group, so a process git started dies with it", { timeout: 20000 }, async (t) => {
const w = world(t);
const file = join(w.dir, "child.pid");
const env = slowClone(w, `sleep 30 </dev/null >/dev/null 2>&1 & echo $! > '${file}'; wait`);
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/);
const pid = await pidIn(file);
t.after(() => kill(pid));
assert.equal(await gone(pid), true, "the sleep git started is gone");
});
// git dies of SIGTERM, but a process it started ignores SIGTERM and holds
// none of git's output, so git's close comes at once. The group gets
// SIGKILL then, not after the grace period.
test("once a signalled git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too", { timeout: 20000 }, async (t) => {
const w = world(t);
const file = join(w.dir, "child.pid");
const env = slowClone(w, `(trap '' TERM; exec sleep 30) </dev/null >/dev/null 2>&1 & echo $! > '${file}'; exec sleep 30`);
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/);
const pid = await pidIn(file);
t.after(() => kill(pid));
assert.equal(await gone(pid, 1500), true, "the sleep that ignored SIGTERM is gone well inside the grace period");
});