// 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", "user.email=t@example.invalid", ...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 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); });