/* RFC-1223 regression tests for commitAndPushBordbuch divergence gating. Verifies that the push-preceding rebase only runs on real divergence: an ancestor remote must not trigger history-flattening rebase, a missing remote ref must push directly, and true divergence must integrate via rebase --rebase-merges so merge-commit topology is preserved. Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only. Initial: RFC-1223 push-topology regression suite (ancestor/missing/diverged/conflict fixtures). */ import { test, expect, beforeEach, afterEach } from "vitest"; import { mkdtemp, rm, mkdir, writeFile, access } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { commitAndPushBordbuch } from "./bordbuch-io.ts"; const execFileAsync = promisify(execFile); let root: string; let remote: string; let local: string; async function git(cwd: string, ...args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd }); return stdout.trim(); } async function gitMayFail(cwd: string, ...args: string[]): Promise { try { await execFileAsync("git", args, { cwd }); return 0; } catch { return 1; } } async function exists(p: string): Promise { try { await access(p); return true; } catch { return false; } } async function commitFile( repo: string, file: string, content: string, message: string, ): Promise { await writeFile(path.join(repo, file), content, "utf8"); await git(repo, "add", file); await git(repo, "commit", "-m", message); return git(repo, "rev-parse", "HEAD"); } async function configureUser(repo: string): Promise { await git(repo, "config", "user.email", "test@warpgogol.dev"); await git(repo, "config", "user.name", "test"); await git(repo, "config", "commit.gpgsign", "false"); } /** Seed a bare `remote` and a non-bare `local` clone at the same commit. */ async function initCloneFixture(): Promise { remote = path.join(root, "remote.git"); await git(root, "init", "--bare", remote); const seed = path.join(root, "seed"); await git(root, "init", "-b", "main", seed); await configureUser(seed); await commitFile(seed, "f.txt", "base\n", "seed"); await git(seed, "push", remote, "HEAD:main"); local = path.join(root, "local"); await git(root, "clone", "-b", "main", remote, local); await configureUser(local); await mkdir(path.join(local, "bordbuch"), { recursive: true }); } /** * Add a merge commit ahead of origin/main on `local`: a side branch with one * commit folded back via `git merge --no-ff`, plus a bookkeeping commit on * main first so both parents carry unique history. */ async function addLocalMerge(): Promise { await git(local, "checkout", "-b", "work"); await commitFile(local, "w.txt", "work\n", "W1"); await git(local, "checkout", "main"); await commitFile(local, "b.txt", "bookkeeping\n", "B1"); await git(local, "merge", "--no-ff", "-m", "merge work", "work"); return git(local, "rev-parse", "HEAD"); } async function pushBordbuch(): Promise<{ commitSha: string | null; pushed: boolean; error: string | null; }> { await writeFile( path.join(local, "bordbuch", "events.ndjson"), `${JSON.stringify({ id: "event-000001", kind: "mission-reconcile", summary: "test" })}\n`, "utf8", ); return commitAndPushBordbuch(local, "Bordbuch: test"); } async function remoteLog(format: string, ref = "main"): Promise { return git(remote, "log", `--format=${format}`, ref); } async function remoteMergeShas(ref = "main"): Promise { const out = await git(remote, "log", "--merges", "--format=%H", ref); return out.split("\n").filter((l) => l.trim()); } async function localReflogSubjects(): Promise { return git(local, "reflog", "--format=%gs"); } beforeEach(async () => { root = await mkdtemp(path.join(tmpdir(), "rfc1223-push-")); }); afterEach(async () => { await rm(root, { recursive: true, force: true }); }); test("ancestor remote skips pull and preserves merge topology", async () => { await initCloneFixture(); await addLocalMerge(); const result = await pushBordbuch(); expect(result.error).toBeNull(); expect(result.pushed).toBe(true); // No pull/rebase step ran: reflog carries no rebase or pull entries. expect(await localReflogSubjects()).not.toMatch(/rebase|pull/); }); test("reconcile merge commit stays reachable in pushed history", async () => { await initCloneFixture(); const mergeSha = await addLocalMerge(); const result = await pushBordbuch(); expect(result.pushed).toBe(true); const remoteMerges = await remoteMergeShas(); expect(remoteMerges).toContain(mergeSha); expect(remoteMerges.length).toBeGreaterThan(0); }); test("diverged remote rebases with merges preserved", async () => { await initCloneFixture(); await addLocalMerge(); // A second clone advances origin/main — real divergence. const other = path.join(root, "other"); await git(root, "clone", "-b", "main", remote, other); await configureUser(other); await commitFile(other, "remote.txt", "remote side\n", "R1"); await git(other, "push", "origin", "main"); const result = await pushBordbuch(); expect(result.error).toBeNull(); expect(result.pushed).toBe(true); // A merge node survives in the pushed history (recreated by --rebase-merges), // and both sides' commits are present. const remoteMerges = await remoteMergeShas(); expect(remoteMerges.length).toBeGreaterThan(0); const remoteSubjects = await remoteLog("%s", "main"); expect(remoteSubjects).toContain("R1"); expect(remoteSubjects).toContain("W1"); expect(remoteSubjects).toContain("B1"); }); test("rebase conflict on divergence returns pushed false with error", async () => { await initCloneFixture(); await commitFile(local, "f.txt", "local change\n", "local edit"); const other = path.join(root, "other"); await git(root, "clone", "-b", "main", remote, other); await configureUser(other); await commitFile(other, "f.txt", "remote change\n", "remote edit"); await git(other, "push", "origin", "main"); const result = await pushBordbuch(); expect(result.pushed).toBe(false); expect(result.error).toBeTruthy(); }); test("rebase conflict leaves no rebase state directories", async () => { await initCloneFixture(); await commitFile(local, "f.txt", "local change\n", "local edit"); const other = path.join(root, "other"); await git(root, "clone", "-b", "main", remote, other); await configureUser(other); await commitFile(other, "f.txt", "remote change\n", "remote edit"); await git(other, "push", "origin", "main"); await pushBordbuch(); expect(await exists(path.join(local, ".git", "rebase-merge"))).toBe(false); expect(await exists(path.join(local, ".git", "rebase-apply"))).toBe(false); }); test("rebase conflict preserves local commit as head", async () => { await initCloneFixture(); await commitFile(local, "f.txt", "local change\n", "local edit"); const other = path.join(root, "other"); await git(root, "clone", "-b", "main", remote, other); await configureUser(other); await commitFile(other, "f.txt", "remote change\n", "remote edit"); await git(other, "push", "origin", "main"); const result = await pushBordbuch(); expect(result.pushed).toBe(false); // rebase --abort restores HEAD to the pre-call bordbuch commit. expect(await git(local, "rev-parse", "HEAD")).toBe(result.commitSha); }); test("recorded commitSha is an ancestor of pushed origin head", async () => { await initCloneFixture(); await addLocalMerge(); const result = await pushBordbuch(); expect(result.pushed).toBe(true); expect(result.commitSha).toBeTruthy(); expect( await gitMayFail(local, "merge-base", "--is-ancestor", result.commitSha!, "origin/main"), ).toBe(0); }); test("missing remote ref skips rebase and pushes directly", async () => { remote = path.join(root, "remote.git"); await git(root, "init", "--bare", remote); local = path.join(root, "local"); await git(root, "init", "-b", "main", local); await configureUser(local); await git(local, "remote", "add", "origin", remote); await commitFile(local, "f.txt", "first\n", "initial"); await mkdir(path.join(local, "bordbuch"), { recursive: true }); const result = await pushBordbuch(); expect(result.error).toBeNull(); expect(result.pushed).toBe(true); expect(await localReflogSubjects()).not.toMatch(/rebase|pull/); const remoteHead = await git(remote, "rev-parse", "main"); expect(remoteHead).toBe(result.commitSha); });