// The git operations pull requests and agents rely on, against real repositories. Run with `bun test`. import assert from "node:assert/strict"; import { execFile, execFileSync } from "node:child_process"; import { promisify } from "node:util"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import { after, before, test } from "node:test"; const tmp = mkdtempSync(path.join(os.tmpdir(), "vectrum-agent-test-")); process.env.DATA_ROOT = path.join(tmp, "data"); const sh = (cwd: string, ...args: string[]) => execFileSync("git", ["-c", "user.name=a", "-c", "user.email=a@x", ...args], { cwd, encoding: "utf8", stdio: "pipe" }); let git: typeof import("./git"); let write: typeof import("./git-write"); let repo: string; let work: string; before(async () => { git = await import("./git"); write = await import("./git-write"); repo = path.join(tmp, "srv.git"); await git.initBareRepo(repo); work = path.join(tmp, "work"); sh(tmp, "init", "-q", "-b", "main", work); writeFileSync(path.join(work, "a.txt"), "one\ntwo\nthree\n"); writeFileSync(path.join(work, "b.txt"), "hello world\n"); sh(work, "add", "."); sh(work, "commit", "-qm", "init"); sh(work, "checkout", "-qb", "feature"); writeFileSync(path.join(work, "a.txt"), "one\nTWO\nthree\n"); sh(work, "commit", "-qam", "feature"); sh(work, "checkout", "-qb", "conflict", "main"); writeFileSync(path.join(work, "a.txt"), "one\n2\nthree\n"); sh(work, "commit", "-qam", "conflict"); sh(work, "push", "-q", repo, "main", "feature", "conflict"); }); after(() => rmSync(tmp, { recursive: true, force: true })); test("merging in memory: a clean tree, or the files that conflict", async () => { const clean = await git.mergeTrees(repo, "main", "feature"); assert.equal(clean.clean, true); const conflict = await git.mergeTrees(repo, "feature", "conflict"); assert.deepEqual(conflict, { clean: false, conflicts: ["a.txt"] }); assert.equal(await git.isAncestor(repo, "main", "feature"), true); assert.equal(await git.isAncestor(repo, "feature", "main"), false); assert.equal(await git.countBetween(repo, "main", "feature"), 1); assert.equal((await git.commitsBetween(repo, "main", "feature"))[0].subject, "feature"); }); test("grep: plain text and patterns, case-insensitive by default", async () => { const sha = (await git.revParse(repo, "feature"))!; assert.deepEqual((await git.grep(repo, sha, "two")).matches, [{ path: "a.txt", line: 2, text: "TWO" }]); assert.equal((await git.grep(repo, sha, "two", { caseSensitive: true })).matches.length, 0); assert.equal((await git.grep(repo, sha, "^h.*d$", { regex: true })).matches[0].path, "b.txt"); assert.equal((await git.grep(repo, sha, "error")).matches.length, 0); assert.deepEqual((await git.grep(repo, sha, "o", { limit: 1 })).truncated, true); }); test("patches apply to a tree without a working tree, and undo", async () => { const head = (await git.revParse(repo, "main"))!; const patch = "--- a/b.txt\n+++ b/b.txt\n@@ -1,1 +1,2 @@\n hello world\n+again\n"; const tree = await write.applyPatchToTree(repo, head, patch); const commit = await write.writeCommit(repo, { tree, parents: [head], message: "patch\n", author: { name: "A", email: "a@x" }, committer: { name: "Agent", email: "agent+tok_abcdefghijklmno@noreply.vectrum" } }); await write.updateBranch(repo, "main", commit, head); assert.equal((await git.readBlob(repo, "main", "b.txt")).content.toString(), "hello world\nagain\n"); const info = (await git.getCommit(repo, commit))!; assert.equal(info.committerName, "Agent"); assert.equal(info.authorName, "A"); await assert.rejects(write.applyPatchToTree(repo, head, "--- a/b.txt\n+++ b/b.txt\n@@ -1 +1 @@\n-nope\n+x\n"), write.PatchError); // Undoing the change on top of the branch as it is now. const reverse = await git.diffRangeText(repo, head, commit, [], { binary: true }); const undone = await write.applyPatchToTree(repo, commit, reverse, { reverse: true }); assert.equal(undone, await write.treeOf(repo, head)); await assert.rejects(write.updateBranch(repo, "main", commit, head), /updated by someone else/); }); test("the pre-receive hook asks the server, and refuses what it refuses", { timeout: 30_000 }, async () => { let asked: { token: string; updates: { ref: string; ff: boolean }[] } | null = null; const socket = path.join(tmp, "hook.sock"); const server = net.createServer((conn) => { let input = ""; conn.on("data", (d) => { input += d; if (!input.includes("\n")) return; asked = JSON.parse(input); const refused = asked!.updates.some((u) => u.ref === "refs/heads/main"); conn.end(`${JSON.stringify({ ok: !refused, messages: refused ? ["main is protected"] : [] })}\n`); }); }); await new Promise((resolve) => server.listen(socket, resolve)); const wrapper = path.join(tmp, "receive-pack.ts"); writeFileSync( wrapper, `const { spawnService } = await import(${JSON.stringify(path.join(import.meta.dirname, "git.ts"))}); const child = spawnService("git-receive-pack", process.argv[2], { stateless: false, env: { VECTRUM_HOOK_SOCKET: ${JSON.stringify(socket)}, VECTRUM_PUSH_TOKEN: "t0ken" } }); process.stdin.pipe(child.stdin); child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr); child.on("close", (code) => process.exit(code ?? 1));`, ); // Async, so this process's socket server can answer the hook while git pushes. const push = (refspec: string) => promisify(execFile)("git", ["push", "--porcelain", "--force", `--receive-pack=${process.execPath} ${wrapper}`, repo, refspec], { cwd: work }); try { sh(work, "checkout", "-q", "feature"); await assert.rejects(push("HEAD:refs/heads/main"), (e: { stderr?: string; stdout?: string }) => /main is protected/.test(`${e.stderr}${e.stdout}`)); await push("HEAD:refs/heads/other"); assert.equal(asked!.token, "t0ken"); // A force push is reported as not fast-forward. await push("conflict:refs/heads/other"); assert.equal(asked!.updates[0].ff, false); } finally { server.close(); } });