Unify halt and fire cancellation by verified run identity with durable first-owner receipts and shared deadlines. Integrate the instance control with the current host, library and tester-socket paths. Focused nine-file evidence is 62 passing tests; broad suites, mutants and review remain pending.
595 lines
28 KiB
JavaScript
595 lines
28 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 { dropLand, finishLand, prepareLand, push, review, validBranch, workingRepo, workRef, WorkingError } from "../src/working.mjs";
|
|
import { rootHits } from "../src/landing.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}\nrefs/heads/root ${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}\nrefs/heads/root ${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}\nrefs/heads/root ${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("the root is fetched again before each push and main follows it, 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}\nrefs/heads/root ${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}\nrefs/heads/root ${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}\nrefs/heads/root ${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}\nrefs/heads/root ${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}\nrefs/heads/root ${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}\nrefs/heads/root ${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 the root never ends and ignores SIGTERM.
|
|
const env = wrapGit(
|
|
w,
|
|
`for a in "$@"; do [ "$a" = +HEAD:refs/heads/root ] && { 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);
|
|
});
|
|
|
|
// Row 137 (#1631): the seed keeps a landed main, and a landing squashes the
|
|
// reviewed diff onto main.
|
|
const main = (w) => git(w.dir, `--git-dir=${w.repo}`, "rev-parse", "refs/heads/main");
|
|
const message = ({ old, base, patch_id }) => `Land it\n\nBase: main ${old}\nReviewed-base: ${base}\nPatch-id: ${patch_id}\n`;
|
|
// The repository's worktrees other than itself.
|
|
const trees = (w) =>
|
|
git(w.dir, `--git-dir=${w.repo}`, "worktree", "list", "--porcelain")
|
|
.split("\n")
|
|
.filter((l) => l.startsWith("worktree ") && l !== `worktree ${w.repo}`);
|
|
// A commit on `branch` of the clone, from `from`, that writes `file`.
|
|
function change(w, branch, from, file, text) {
|
|
git(w.clone, "checkout", "-q", "-B", branch, from);
|
|
writeFileSync(join(w.clone, file), `${text}\n`);
|
|
git(w.clone, "add", file);
|
|
git(w.clone, "commit", "-q", "-m", text);
|
|
return git(w.clone, "rev-parse", "HEAD");
|
|
}
|
|
const pushAs = (w, taskRef, branch) => push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef, branch, bundle: w.bundle(branch) });
|
|
function landing(w, extra = {}) {
|
|
const dir = mkdtempSync(join(w.dir, "land-"));
|
|
const args = { dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, dir, name: "land-0a1b2c", rootHits, message, ...extra };
|
|
return { dir, prepare: () => prepareLand(args), finish: (land, record) => finishLand({ ...args, land, record }), drop: (worktree) => dropLand({ ...args, worktree }) };
|
|
}
|
|
// The task's change on feat, pushed and landed.
|
|
async function landed(w) {
|
|
const c = change(w, "feat", w.first, "README", "two");
|
|
await w.go(w.bundle());
|
|
const l = landing(w, { commit: c });
|
|
const land = await l.prepare();
|
|
await l.finish(land);
|
|
await l.drop(land.worktree);
|
|
return { c, land };
|
|
}
|
|
|
|
test("a reseed moves root but leaves a landed main, and main forwards once root contains it", async (t) => {
|
|
const w = world(t);
|
|
const { land } = await landed(w);
|
|
assert.equal(main(w), land.commit);
|
|
// The root moves on without the landing. main isn't behind root, so the seed leaves it.
|
|
writeFileSync(join(w.root, "NOTES"), "owner\n");
|
|
git(w.root, "add", "NOTES");
|
|
git(w.root, "commit", "-q", "-m", "owner");
|
|
const newer = git(w.root, "rev-parse", "HEAD");
|
|
const c2 = commit(w.clone, "three");
|
|
await w.go(w.bundle());
|
|
assert.equal(w.refs(), `refs/heads/main ${land.commit}\nrefs/heads/root ${newer}\n${REF} ${c2}`);
|
|
// The owner merges main into the root; the next seed forwards main to it.
|
|
git(w.root, "fetch", "-q", w.repo, "refs/heads/main");
|
|
git(w.root, "merge", "-q", "--no-edit", "FETCH_HEAD");
|
|
const merged = git(w.root, "rev-parse", "HEAD");
|
|
commit(w.clone, "four");
|
|
await w.go(w.bundle());
|
|
assert.equal(main(w), merged);
|
|
});
|
|
|
|
test("after a landing, a push built on the root still lands and main stays where the landing put it", async (t) => {
|
|
const w = world(t);
|
|
const { land } = await landed(w);
|
|
const c = change(w, "other", w.first, "LATER", "on the root");
|
|
const r = await pushAs(w, "vikunja:1/3", "other");
|
|
assert.equal(r.commit, c);
|
|
assert.equal(main(w), land.commit);
|
|
});
|
|
|
|
test("once the root shares no history with a landed main, a push is checked against main, not the root", async (t) => {
|
|
const w = world(t);
|
|
const { land } = await landed(w);
|
|
// The owner starts the root over on an orphan branch, so root and main share nothing.
|
|
git(w.root, "checkout", "-q", "--orphan", "fresh");
|
|
git(w.root, "rm", "-q", "-rf", ".");
|
|
const orphan = commit(w.root, "fresh start");
|
|
// Built on main's history: it lands, though the root shares none of it.
|
|
const c = change(w, "other", w.first, "LATER", "on main");
|
|
const r = await pushAs(w, "vikunja:1/3", "other");
|
|
assert.equal(r.commit, c);
|
|
assert.equal(main(w), land.commit);
|
|
assert.equal(git(w.dir, `--git-dir=${w.repo}`, "rev-parse", "refs/heads/root"), orphan);
|
|
// Built on the new root: it shares history with the root only, and refuses.
|
|
git(w.clone, "fetch", "-q", "origin");
|
|
git(w.clone, "checkout", "-q", "-B", "fresh", "origin/fresh");
|
|
commit(w.clone, "on the new root");
|
|
await refuses(w, () => pushAs(w, "vikunja:1/4", "fresh"), "unrelated-history");
|
|
});
|
|
|
|
test("a landing squashes the reviewed diff onto main as the bus host and moves main only at finish", async (t) => {
|
|
const w = world(t);
|
|
change(w, "feat", w.first, "README", "two");
|
|
const c2 = commit(w.clone, "three");
|
|
await w.go(w.bundle());
|
|
const l = landing(w, { commit: c2 });
|
|
const land = await lands(w, () => l.prepare());
|
|
assert.equal(land.old, w.first);
|
|
assert.equal(land.base, w.first);
|
|
assert.equal(land.reviewed, c2);
|
|
assert.match(land.patch_id, /^[0-9a-f]{40}$/);
|
|
assert.equal(land.worktree, join(l.dir, "land-0a1b2c"));
|
|
assert.equal(main(w), w.first, "prepare moves nothing");
|
|
const at = `--git-dir=${w.repo}`;
|
|
assert.equal(git(w.dir, at, "rev-parse", `${land.commit}^@`), w.first, "one parent, the old main");
|
|
assert.equal(git(w.dir, at, "rev-parse", `${land.commit}^{tree}`), git(w.dir, at, "rev-parse", `${c2}^{tree}`));
|
|
assert.equal(git(w.dir, at, "log", "-1", "--format=%an <%ae>|%cn <%ce>", land.commit), "Mosaic bus host <[email protected]>|Mosaic bus host <[email protected]>");
|
|
assert.equal(git(w.dir, at, "log", "-1", "--format=%B", land.commit), message({ old: w.first, base: w.first, patch_id: land.patch_id }).trim());
|
|
assert.equal(readFileSync(join(land.worktree, "README"), "utf8"), "three\n");
|
|
assert.equal(git(land.worktree, "rev-parse", "HEAD"), land.commit);
|
|
assert.equal(git(land.worktree, "status", "--porcelain"), "");
|
|
|
|
const seen = [];
|
|
const done = await lands(w, () => l.finish(land, async (m) => seen.push(m)));
|
|
assert.deepEqual(done, { previous: w.first, commit: land.commit });
|
|
assert.deepEqual(seen, [done]);
|
|
assert.equal(main(w), land.commit);
|
|
await l.drop(land.worktree);
|
|
assert.equal(existsSync(land.worktree), false);
|
|
assert.deepEqual(trees(w), []);
|
|
});
|
|
|
|
test("a landing refuses ref-moved, base-conflict and root-file before it makes a commit", async (t) => {
|
|
const w = world(t);
|
|
const { c } = await landed(w);
|
|
// The verdict approved another commit than the work ref's tip, or a task never pushed.
|
|
await refuses(w, () => landing(w, { commit: w.first }).prepare(), "ref-moved");
|
|
await refuses(w, () => landing(w, { commit: c, taskRef: "vikunja:1/9" }).prepare(), "ref-moved");
|
|
// The same change again no longer applies to main.
|
|
await refuses(w, () => landing(w, { commit: c }).prepare(), "base-conflict");
|
|
// A change that conflicts with what landed.
|
|
const clash = change(w, "clash", w.first, "README", "clash");
|
|
await pushAs(w, "vikunja:1/3", "clash");
|
|
await refuses(w, () => landing(w, { commit: clash, taskRef: "vikunja:1/3" }).prepare(), "base-conflict");
|
|
// An empty diff.
|
|
git(w.clone, "checkout", "-q", "-B", "empty", w.first);
|
|
git(w.clone, "commit", "-q", "--allow-empty", "-m", "nothing");
|
|
const empty = git(w.clone, "rev-parse", "HEAD");
|
|
await pushAs(w, "vikunja:1/4", "empty");
|
|
await refuses(w, () => landing(w, { commit: empty, taskRef: "vikunja:1/4" }).prepare(), "base-conflict");
|
|
// A new file at the root.
|
|
const rooty = change(w, "rooty", w.first, "NEW", "root file");
|
|
await pushAs(w, "vikunja:1/5", "rooty");
|
|
await assert.rejects(landing(w, { commit: rooty, taskRef: "vikunja:1/5" }).prepare(), (e) => e instanceof WorkingError && e.code === "root-file" && /NEW/.test(e.message));
|
|
assert.deepEqual(trees(w), [], "no worktree is left");
|
|
});
|
|
|
|
test("a landing whose applied diff isn't the reviewed one refuses patch-drift and leaves no worktree", async (t) => {
|
|
const w = world(t);
|
|
const c = change(w, "feat", w.first, "README", "two");
|
|
await w.go(w.bundle());
|
|
// A git that adds a line to what `apply --index` staged, as land-work's test does.
|
|
const env = wrapGit(
|
|
w,
|
|
`"$REAL" "$@"; rc=$?
|
|
dir=; apply=0; prev=
|
|
for a in "$@"; do [ "$prev" = -C ] && dir=$a; [ "$a" = apply ] && apply=1; prev=$a; done
|
|
if [ $apply = 1 ] && [ $rc -eq 0 ]; then printf 'tampered\\n' >> "$dir/README" && "$REAL" -C "$dir" add README; fi
|
|
exit $rc`,
|
|
);
|
|
await assert.rejects(landing(w, { commit: c, env }).prepare(), (e) => e instanceof WorkingError && e.code === "patch-drift" && /^applied [0-9a-f]{40}, reviewed [0-9a-f]{40}$/.test(e.message));
|
|
assert.equal(main(w), w.first);
|
|
assert.deepEqual(trees(w), [], "no worktree is left");
|
|
// The same landing with the real git prepares.
|
|
const l = landing(w, { commit: c });
|
|
const land = await l.prepare();
|
|
assert.equal(land.old, w.first);
|
|
await l.drop(land.worktree);
|
|
assert.deepEqual(trees(w), []);
|
|
});
|
|
|
|
test("finish refuses base-moved when main moved, and a record that throws moves main back", async (t) => {
|
|
const w = world(t);
|
|
const c = change(w, "feat", w.first, "README", "two");
|
|
await w.go(w.bundle());
|
|
const l = landing(w, { commit: c });
|
|
const land = await l.prepare();
|
|
await assert.rejects(l.finish(land, async () => {
|
|
throw new Error("not recorded");
|
|
}), /not recorded/);
|
|
assert.equal(main(w), w.first);
|
|
const at = `--git-dir=${w.repo}`;
|
|
git(w.dir, at, "update-ref", "refs/heads/main", c, w.first);
|
|
await assert.rejects(l.finish(land), (e) => e instanceof WorkingError && e.code === "base-moved");
|
|
assert.equal(main(w), c);
|
|
// A lock on main when record() fails, so main can't go back.
|
|
git(w.dir, at, "update-ref", "refs/heads/main", w.first, c);
|
|
const lock = join(w.repo, "refs/heads/main.lock");
|
|
await assert.rejects(l.finish(land, async () => {
|
|
writeFileSync(lock, "");
|
|
throw new Error("not recorded");
|
|
}), (e) => e instanceof WorkingError && e.code === "rollback-failed" && e.message.includes(`main still names ${land.commit}`) && e.message.includes(`back to ${w.first}`));
|
|
rmSync(lock);
|
|
assert.equal(main(w), land.commit);
|
|
await l.drop(land.worktree);
|
|
assert.deepEqual(locks(w.repo), []);
|
|
assert.deepEqual(trees(w), []);
|
|
});
|
|
|
|
// Row 191: a halt at receipt time must not prevent the existing compare-and-swap rollback.
|
|
test('halted push receipt rolls back only its unrecorded ref and cleans temporary refs', async t => {
|
|
const { initializeControl, transition } = await import('../../processing-control/src/state.mjs');
|
|
const { withProcessing } = await import('../../processing-control/src/effects.mjs');
|
|
const w = world(t); initializeControl(w.dataRoot);
|
|
git(w.clone, 'checkout', '-q', '-b', 'feat'); commit(w.clone, 'two');
|
|
await assert.rejects(withProcessing(w.dataRoot, () => w.go(w.bundle(), { record: async () => {
|
|
transition(w.dataRoot, { business: 'acme', human: 'jason', halted: true });
|
|
throw Error('not recorded after halt');
|
|
} })), /not recorded after halt/);
|
|
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}`);
|
|
});
|