Row 93 follow-up: SIGTERM then SIGKILL for host git, rollback-failed, a total body deadline, fetch-aborted for a reviewer gone mid-fetch, working refs first in have, and tests for five untested refusals. Dewey approved in 28226 and Sage in 28225; Sage's full gate passed 16:57:32-17:01:32Z. The host side is live at the next bus restart. Co-Authored-By: Claude Opus 5.5 <[email protected]>
388 lines
18 KiB
JavaScript
388 lines
18 KiB
JavaScript
// The working repositories (src/working.mjs, #1587), against real git
|
|
// repositories in a temp directory. The bundles are made the way a
|
|
// session's runner makes them (packages/harness/src/git-tools.mjs). Every
|
|
// case also checks that the root's files and refs are byte for byte what
|
|
// they were, that no temp ref is left, and that only the task's own ref moved.
|
|
|
|
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, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { gitEnv } from "../src/checkout.mjs";
|
|
import { push, review, validBranch, workingRepo, workRef, WorkingError } from "../src/working.mjs";
|
|
|
|
const TASK = "vikunja:1/2";
|
|
const REF = "refs/heads/work/vikunja-1-2";
|
|
|
|
function tmp(t) {
|
|
const dir = mkdtempSync(join(tmpdir(), "mosaic-working-"));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
return dir;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function commit(dir, text) {
|
|
writeFileSync(join(dir, "README"), `${text}\n`);
|
|
git(dir, "add", "README");
|
|
git(dir, "commit", "-q", "-m", text);
|
|
return git(dir, "rev-parse", "HEAD");
|
|
}
|
|
|
|
function fingerprint(dir) {
|
|
const h = createHash("sha256");
|
|
const walk = (d) => {
|
|
for (const name of readdirSync(d).sort()) {
|
|
const p = join(d, name);
|
|
const st = lstatSync(p);
|
|
h.update(`${p}\0${st.mode}\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 on main with one commit, a coder's clone of it, and the data root.
|
|
function world(t) {
|
|
const dir = tmp(t);
|
|
const root = join(dir, "root");
|
|
mkdirSync(root);
|
|
git(root, "init", "-q", "-b", "main");
|
|
const first = commit(root, "one");
|
|
const clone = join(dir, "clone");
|
|
git(dir, "clone", "-q", root, clone);
|
|
const dataRoot = join(dir, "data");
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
const repo = workingRepo(dataRoot, "acme", "stack");
|
|
let n = 0;
|
|
// A bundle of `branch` the way the runner makes one.
|
|
const bundle = (branch = "feat", from = clone) => {
|
|
const file = join(dir, `b${n++}.bundle`);
|
|
git(from, "bundle", "create", "-q", file, `refs/heads/${branch}`, "--not", "--remotes=origin", "--remotes=working");
|
|
return file;
|
|
};
|
|
const refs = () => (existsSync(repo) && lstatSync(repo).isDirectory() ? git(dir, `--git-dir=${repo}`, "for-each-ref", "--format=%(refname) %(objectname)") : "");
|
|
const go = (file, extra = {}) => push({ dataRoot, business: "acme", project: "stack", root, taskRef: TASK, branch: "feat", bundle: file, ...extra });
|
|
return { dir, root, first, clone, dataRoot, repo, bundle, refs, go };
|
|
}
|
|
|
|
// A refusal with `code`, and the root, the working repository's refs and its
|
|
// temp refs as they were.
|
|
async function refuses(w, fn, code) {
|
|
const root = fingerprint(w.root);
|
|
const refs = w.refs();
|
|
await assert.rejects(async () => fn(), (e) => e instanceof WorkingError && e.code === code);
|
|
assert.equal(fingerprint(w.root), root, "the root is unchanged");
|
|
if (refs) assert.equal(w.refs(), refs, "no ref of the working repository moved");
|
|
assert.doesNotMatch(w.refs(), /refs\/mosaic\//, "no temp ref is left");
|
|
}
|
|
|
|
// The .lock files under a repository's refs.
|
|
function locks(repo) {
|
|
const found = [];
|
|
const walk = (d) => {
|
|
for (const name of readdirSync(d)) {
|
|
const p = join(d, name);
|
|
if (lstatSync(p).isDirectory()) walk(p);
|
|
else if (name.endsWith(".lock")) found.push(p.slice(repo.length + 1));
|
|
}
|
|
};
|
|
walk(repo);
|
|
return found;
|
|
}
|
|
|
|
// A `git` first in PATH that runs `body` (bash) with REAL as the real git,
|
|
// and the env that puts it there.
|
|
const REAL_GIT = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
|
|
function wrapGit(w, body) {
|
|
const bin = join(w.dir, "bin");
|
|
mkdirSync(bin);
|
|
writeFileSync(join(bin, "git"), `#!/bin/bash
|
|
REAL='${REAL_GIT}'
|
|
${body}
|
|
`);
|
|
chmodSync(join(bin, "git"), 0o755);
|
|
return { ...process.env, PATH: `${bin}:${process.env.PATH}` };
|
|
}
|
|
|
|
async function lands(w, fn) {
|
|
const root = fingerprint(w.root);
|
|
const r = await fn();
|
|
assert.equal(fingerprint(w.root), root, "the root is unchanged");
|
|
assert.doesNotMatch(w.refs(), /refs\/mosaic\//, "no temp ref is left");
|
|
return r;
|
|
}
|
|
|
|
test("workRef and validBranch take only what a ref name can hold", () => {
|
|
assert.equal(workRef("vikunja:12/345"), "work/vikunja-12-345");
|
|
for (const bad of ["vikunja:0/1", "vikunja:1", "gitea:1/2", "vikunja:1/2\n", null]) assert.throws(() => workRef(bad), WorkingError);
|
|
for (const ok of ["feat", "fix/thing-2", "a.b_c"]) assert.equal(validBranch(ok), true, ok);
|
|
for (const bad of ["", "-x", "a..b", "a//b", "a/.b", "a@{1}", "x.lock", "a/", "a.", "a b", "a:b", "a~1", "a".repeat(201)]) assert.equal(validBranch(bad), false, bad);
|
|
});
|
|
|
|
test("a push lands as the task's work ref, a fast-forward lands after it, and main is the root's", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
const c1 = commit(w.clone, "two");
|
|
const seen = [];
|
|
const r1 = await lands(w, () => w.go(w.bundle(), { record: async (r) => seen.push(r) }));
|
|
assert.deepEqual(r1, { ref: "work/vikunja-1-2", commit: c1, previous: null });
|
|
assert.deepEqual(seen, [r1]);
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
|
|
|
// The runner's clone remembers the push, so the next bundle carries only c2.
|
|
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
|
|
const c2 = commit(w.clone, "three");
|
|
const file = w.bundle();
|
|
assert.match(git(w.dir, "bundle", "list-heads", file), new RegExp(c2));
|
|
const r2 = await lands(w, () => w.go(file));
|
|
assert.deepEqual(r2, { ref: "work/vikunja-1-2", commit: c2, previous: c1 });
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c2}`);
|
|
});
|
|
|
|
test("a rewritten branch refuses non-fast-forward and the work ref stays", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
await w.go(w.bundle());
|
|
git(w.clone, "reset", "-q", "--hard", w.first);
|
|
commit(w.clone, "other two");
|
|
await refuses(w, () => w.go(w.bundle()), "non-fast-forward");
|
|
});
|
|
|
|
test("a branch with no history in common with main refuses unrelated-history", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "--orphan", "feat");
|
|
git(w.clone, "rm", "-q", "-rf", ".");
|
|
commit(w.clone, "alone");
|
|
await refuses(w, () => w.go(w.bundle()), "unrelated-history");
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}`);
|
|
});
|
|
|
|
test("a bundle that needs commits the working repository lacks refuses missing-prerequisite", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
const local = commit(w.clone, "never pushed");
|
|
commit(w.clone, "on top");
|
|
const file = join(w.dir, "partial.bundle");
|
|
git(w.clone, "bundle", "create", "-q", file, "refs/heads/feat", "--not", local);
|
|
await refuses(w, () => w.go(file), "missing-prerequisite");
|
|
});
|
|
|
|
test("main is fetched from the root again before each push, so a clone of a newer root pushes", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
await w.go(w.bundle());
|
|
// The root moves on; a second coder clones it and branches from there.
|
|
const newer = commit(w.root, "root two");
|
|
const other = join(w.dir, "other");
|
|
git(w.dir, "clone", "-q", w.root, other);
|
|
git(other, "checkout", "-q", "-b", "feat");
|
|
const c = commit(other, "from newer");
|
|
const r = await lands(w, () => push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: "vikunja:1/3", branch: "feat", bundle: w.bundle("feat", other) }));
|
|
assert.equal(r.commit, c);
|
|
assert.match(w.refs(), new RegExp(`refs/heads/main ${newer}`));
|
|
});
|
|
|
|
test("a root moved back after the clone leaves the push's base unknown: missing-prerequisite", async (t) => {
|
|
const w = world(t);
|
|
commit(w.root, "root two");
|
|
git(w.clone, "pull", "-q", "--ff-only");
|
|
git(w.root, "reset", "-q", "--hard", w.first);
|
|
git(w.root, "reflog", "expire", "--expire=now", "--all");
|
|
git(w.root, "gc", "-q", "--prune=now");
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "three");
|
|
await refuses(w, () => w.go(w.bundle()), "missing-prerequisite");
|
|
});
|
|
|
|
test("a commit with a broken object refuses invalid-bundle, and only because fsck is on", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
const tree = git(w.clone, "rev-parse", "HEAD^{tree}");
|
|
const raw = `tree ${tree}\nparent ${w.first}\nauthor nobody\ncommitter t <[email protected]> 1700000000 +0000\n\nbad\n`;
|
|
const r = spawnSync("git", ["hash-object", "-t", "commit", "-w", "--literally", "--stdin"], { cwd: w.clone, input: raw, encoding: "utf8", env: gitEnv() });
|
|
assert.equal(r.status, 0, r.stderr);
|
|
git(w.clone, "update-ref", "refs/heads/feat", r.stdout.trim());
|
|
const file = w.bundle();
|
|
await refuses(w, () => w.go(file), "invalid-bundle");
|
|
assert.doesNotMatch(w.refs(), /work\//);
|
|
// The same bundle with no fsck lands: the refusal above is fsck's.
|
|
const landed = await w.go(file, { fsck: [] });
|
|
assert.equal(landed.commit, r.stdout.trim());
|
|
});
|
|
|
|
test("only a v2 bundle with the named branch is read", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
const v2 = w.bundle();
|
|
const v3 = join(w.dir, "v3.bundle");
|
|
writeFileSync(v3, readFileSync(v2, "latin1").replace("# v2 git bundle\n", "# v3 git bundle\n@object-format=sha1\n"), "latin1");
|
|
await refuses(w, () => w.go(v3), "invalid-bundle");
|
|
const junk = join(w.dir, "junk.bundle");
|
|
writeFileSync(junk, "# v2 git bundle\nnot a bundle\n");
|
|
await refuses(w, () => w.go(junk), "invalid-bundle");
|
|
await refuses(w, () => push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch: "other", bundle: v2 }), "branch-not-in-bundle");
|
|
});
|
|
|
|
test("a branch named main, or named for another task, moves only this task's work ref", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "work/vikunja-9-9");
|
|
const c = commit(w.clone, "two");
|
|
git(w.clone, "branch", "-f", "main", c);
|
|
for (const branch of ["main", "work/vikunja-9-9"]) {
|
|
const r = await lands(w, () => push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch, bundle: w.bundle(branch) }));
|
|
assert.equal(r.ref, "work/vikunja-1-2");
|
|
}
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c}`);
|
|
});
|
|
|
|
test("a record that throws moves the work ref back, or removes a new one", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
const boom = async () => {
|
|
throw new Error("not recorded");
|
|
};
|
|
await assert.rejects(w.go(w.bundle(), { record: boom }), /not recorded/);
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}`);
|
|
const c1 = (await w.go(w.bundle())).commit;
|
|
commit(w.clone, "three");
|
|
await assert.rejects(w.go(w.bundle(), { record: boom }), /not recorded/);
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
|
});
|
|
|
|
test("a work ref that can't go back refuses rollback-failed, naming the ref and the commit", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
const c1 = commit(w.clone, "two");
|
|
// record() fails after taking the ref's lock, so moving the ref back fails.
|
|
const lock = join(w.repo, `${REF}.lock`);
|
|
const stuck = async () => {
|
|
writeFileSync(lock, "");
|
|
throw new Error("not recorded");
|
|
};
|
|
const message = (c) => (e) => e instanceof WorkingError && e.code === "rollback-failed" && e.message.includes(`${REF} still names ${c}`) && /not recorded/.test(e.message);
|
|
// A new ref that can't be removed.
|
|
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c1));
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
|
rmSync(lock);
|
|
// A ref that can't go back to the one before.
|
|
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
|
|
const c2 = commit(w.clone, "three");
|
|
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c2));
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c2}`);
|
|
rmSync(lock);
|
|
assert.deepEqual(locks(w.repo), []);
|
|
});
|
|
|
|
test("a root that can't be read refuses root-unreadable", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
await refuses(w, () => w.go(w.bundle(), { root: join(w.dir, "no-root") }), "root-unreadable");
|
|
});
|
|
|
|
test("a git past its time gets SIGTERM, leaves no ref lock, and the next push lands", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
const c1 = commit(w.clone, "two");
|
|
await w.go(w.bundle());
|
|
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
|
|
// The move of the work ref holds the ref's lock in a reference-transaction
|
|
// hook until the lock is gone, so the git is still holding it when its time
|
|
// is up. Only that one fetch gets the hook.
|
|
const hooks = join(w.dir, "hooks");
|
|
mkdirSync(hooks);
|
|
const lock = join(w.repo, `${REF}.lock`);
|
|
writeFileSync(join(hooks, "reference-transaction"), `#!/bin/bash\n[ "$1" = prepared ] || exit 0\nfor i in $(seq 200); do [ -e '${lock}' ] || exit 0; sleep 0.05; done\n`);
|
|
chmodSync(join(hooks, "reference-transaction"), 0o755);
|
|
const env = wrapGit(
|
|
w,
|
|
`self=0
|
|
for a in "$@"; do case "$a" in refs/mosaic/incoming/*:refs/heads/work/*) self=1;; esac; done
|
|
args=()
|
|
for a in "$@"; do
|
|
[ $self = 1 ] && [ "$a" = core.hooksPath=/dev/null ] && a='core.hooksPath=${hooks}'
|
|
args+=("$a")
|
|
done
|
|
exec "$REAL" "\${args[@]}"`,
|
|
);
|
|
commit(w.clone, "three");
|
|
const limits = { timeoutMs: 2000, graceMs: 5000 };
|
|
await assert.rejects(w.go(w.bundle(), { env, limits }), (e) => e instanceof WorkingError && e.code === "push-failed" && /killed by SIGTERM/.test(e.message));
|
|
assert.deepEqual(locks(w.repo), []);
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
|
|
const c3 = commit(w.clone, "four");
|
|
const r = await lands(w, () => w.go(w.bundle()));
|
|
assert.deepEqual(r, { ref: "work/vikunja-1-2", commit: c3, previous: c1 });
|
|
});
|
|
|
|
test("a git that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
// The fetch of main from the root never ends and ignores SIGTERM.
|
|
const env = wrapGit(
|
|
w,
|
|
`for a in "$@"; do [ "$a" = +HEAD:refs/heads/main ] && { trap '' TERM; while :; do sleep 0.05; done; }; done
|
|
exec "$REAL" "$@"`,
|
|
);
|
|
await assert.rejects(w.go(w.bundle(), { env, limits: { timeoutMs: 500, graceMs: 500 } }), (e) => e instanceof WorkingError && e.code === "root-unreadable" && /killed by SIGKILL/.test(e.message));
|
|
});
|
|
|
|
test("a working repository path that isn't a directory refuses", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
commit(w.clone, "two");
|
|
mkdirSync(join(w.dataRoot, "working", "acme"), { recursive: true });
|
|
writeFileSync(w.repo, "");
|
|
await refuses(w, () => w.go(w.bundle()), "working-repo-invalid");
|
|
});
|
|
|
|
test("review bundles a pushed commit as refs/mosaic/review, leaving out what the reviewer has", async (t) => {
|
|
const w = world(t);
|
|
git(w.clone, "checkout", "-q", "-b", "feat");
|
|
const c1 = commit(w.clone, "two");
|
|
await w.go(w.bundle());
|
|
const out = join(w.dir, "review.bundle");
|
|
const args = { dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, commit: c1, out };
|
|
assert.deepEqual(await lands(w, () => review({ ...args, have: [w.first, "f".repeat(40)] })), { ref: "work/vikunja-1-2", commit: c1 });
|
|
assert.equal(git(w.dir, "bundle", "list-heads", out), `${c1} refs/mosaic/review`);
|
|
// Only the one commit: the reviewer has main.
|
|
assert.match(git(w.dir, `--git-dir=${w.repo}`, "bundle", "verify", out), /requires this ref/);
|
|
rmSync(out);
|
|
await refuses(w, () => review({ ...args, commit: "e".repeat(40) }), "unknown-commit");
|
|
await refuses(w, () => review({ ...args, have: ["x"] }), "invalid-request");
|
|
await refuses(w, () => review({ ...args, have: Array(33).fill(w.first) }), "invalid-request");
|
|
await refuses(w, () => review({ ...args, have: [c1] }), "nothing-to-fetch");
|
|
assert.equal(existsSync(out), false);
|
|
assert.doesNotMatch(w.refs(), /refs\/mosaic/);
|
|
});
|
|
|
|
test("review of a project with no working repository refuses unknown-commit", async (t) => {
|
|
const w = world(t);
|
|
await refuses(w, () => review({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, commit: w.first, out: join(w.dir, "x.bundle") }), "unknown-commit");
|
|
assert.equal(existsSync(w.repo), false);
|
|
});
|
|
|
|
test("the runner's git tools hold the host's limits and branch rule", async () => {
|
|
const tools = await import("../../harness/src/git-tools.mjs");
|
|
const working = await import("../src/working.mjs");
|
|
assert.equal(tools.BUNDLE_MAX, working.BUNDLE_MAX);
|
|
assert.equal(tools.HAVE_MAX, working.HAVE_MAX);
|
|
for (const b of ["feat", "a/b", "-x", "a..b", "a.lock", "a/", "x".repeat(200), "x".repeat(201)]) assert.equal(tools.validBranch(b), working.validBranch(b), b);
|
|
});
|