// 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<void>((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();
}
});