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]>
546 lines
30 KiB
JavaScript
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");
|
|
});
|