import { execFile as execFileCallback } from "node:child_process"; import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { buildRemoteGitDeltaBundleScript, createImportedGitRef, createRemoteGitExportRef, deleteLocalGitRef, fetchGitBundleIntoLocalRef, isMissingGitPrerequisiteError, readGitWorkspaceSnapshot, runLocalGit, withShallowGitWorkspaceClone, } from "./git-workspace-sync.js"; const execFile = promisify(execFileCallback); async function git(cwd: string, args: string[]): Promise { return (await runLocalGit(cwd, args)).stdout.trim(); } describe("git workspace sync", () => { const cleanupDirs: string[] = []; afterEach(async () => { while (cleanupDirs.length > 0) { const dir = cleanupDirs.pop(); if (!dir) continue; await rm(dir, { recursive: true, force: true }).catch(() => undefined); } }); async function createRepo(rootDir: string): Promise { const repo = path.join(rootDir, "repo"); await mkdir(repo, { recursive: true }); await git(repo, ["init"]); await git(repo, ["checkout", "-b", "main"]); await git(repo, ["config", "user.name", "Paperclip Test"]); await git(repo, ["config", "user.email", "test@paperclip.dev"]); await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8"); await git(repo, ["add", "tracked.txt"]); await git(repo, ["commit", "-m", "base"]); return repo; } it("creates a shallow standalone clone from the local HEAD snapshot", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-sync-")); cleanupDirs.push(rootDir); const repo = await createRepo(rootDir); const baseHead = await git(repo, ["rev-parse", "HEAD"]); await rm(path.join(repo, "tracked.txt")); const snapshot = await readGitWorkspaceSnapshot(repo); expect(snapshot).toMatchObject({ headCommit: baseHead, branchName: "main", deletedPaths: ["tracked.txt"], }); await withShallowGitWorkspaceClone({ localDir: repo, snapshot: snapshot!, }, async (cloneDir) => { expect((await lstat(path.join(cloneDir, ".git"))).isDirectory()).toBe(true); await expect(readFile(path.join(cloneDir, ".git", "shallow"), "utf8")).resolves.toContain(baseHead); expect(await git(cloneDir, ["rev-list", "--count", "HEAD"])).toBe("1"); expect(await git(cloneDir, ["branch", "--show-current"])).toBe("main"); await expect(readFile(path.join(cloneDir, "tracked.txt"), "utf8")).resolves.toBe("base\n"); }); }); it("builds thin git delta bundles relative to the imported base", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-delta-")); cleanupDirs.push(rootDir); const repo = await createRepo(rootDir); const baseHead = await git(repo, ["rev-parse", "HEAD"]); const snapshot = await readGitWorkspaceSnapshot(repo); expect(snapshot).not.toBeNull(); await withShallowGitWorkspaceClone({ localDir: repo, snapshot: snapshot!, }, async (remoteDir) => { const emptyBundle = path.join(rootDir, "empty.bundle"); await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({ remoteDir, baseSha: baseHead, exportRef: createRemoteGitExportRef("test"), bundlePath: emptyBundle, })]); expect((await stat(emptyBundle)).size).toBe(0); await git(remoteDir, ["config", "user.name", "Paperclip Remote"]); await git(remoteDir, ["config", "user.email", "remote@paperclip.dev"]); await writeFile(path.join(remoteDir, "tracked.txt"), "remote\n", "utf8"); await git(remoteDir, ["commit", "-am", "remote update"]); const remoteHead = await git(remoteDir, ["rev-parse", "HEAD"]); const deltaBundle = path.join(rootDir, "delta.bundle"); const importedRef = createImportedGitRef("test"); const exportRef = createRemoteGitExportRef("test"); try { await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({ remoteDir, baseSha: baseHead, exportRef, bundlePath: deltaBundle, })]); expect((await stat(deltaBundle)).size).toBeGreaterThan(0); const importedHead = await fetchGitBundleIntoLocalRef({ localDir: repo, bundlePath: deltaBundle, exportRef, importedRef, baseSha: baseHead, }); expect(importedHead).toBe(remoteHead); expect(await git(repo, ["rev-list", "--count", importedRef, "--not", baseHead])).toBe("1"); } finally { await deleteLocalGitRef({ localDir: repo, ref: importedRef }); } }); }); it("imports a diverged sandbox HEAD even when the host no longer holds baseSha", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-diverge-")); cleanupDirs.push(rootDir); // Host holds only the shared ancestor B (the eventual merge-base), not the // recorded base H — the state a shared workspace lands in when it is reset // between export and import. const host = await createRepo(rootDir); const mergeBase = await git(host, ["rev-parse", "HEAD"]); // Sandbox holds B, an advanced commit H (the recorded baseSha), and a // local-only commit S that forked from B and diverges from H. const sandbox = path.join(rootDir, "sandbox"); await git(rootDir, ["clone", host, sandbox]); await git(sandbox, ["config", "user.name", "Paperclip Remote"]); await git(sandbox, ["config", "user.email", "remote@paperclip.dev"]); await writeFile(path.join(sandbox, "advance.txt"), "advance\n", "utf8"); await git(sandbox, ["add", "-A"]); await git(sandbox, ["commit", "-m", "advance"]); const baseSha = await git(sandbox, ["rev-parse", "HEAD"]); await git(sandbox, ["reset", "--hard", mergeBase]); await writeFile(path.join(sandbox, "local.txt"), "local\n", "utf8"); await git(sandbox, ["add", "-A"]); await git(sandbox, ["commit", "-m", "local-only"]); const sandboxHead = await git(sandbox, ["rev-parse", "HEAD"]); // The host genuinely lacks baseSha; the old thin bundle would name it as an // unsatisfiable prerequisite. await expect(git(host, ["cat-file", "-e", `${baseSha}^{commit}`])).rejects.toThrow(); const bundle = path.join(rootDir, "diverge.bundle"); const exportRef = createRemoteGitExportRef("test"); const importedRef = createImportedGitRef("test"); try { await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({ remoteDir: sandbox, baseSha, exportRef, bundlePath: bundle, })]); expect((await stat(bundle)).size).toBeGreaterThan(0); const importedHead = await fetchGitBundleIntoLocalRef({ localDir: host, bundlePath: bundle, exportRef, importedRef, baseSha, }); expect(importedHead).toBe(sandboxHead); // The host received the local-only commit and its parent (the merge-base). expect(await git(host, ["cat-file", "-e", `${sandboxHead}^{commit}`])).toBe(""); } finally { await deleteLocalGitRef({ localDir: host, ref: importedRef }); } }); it("re-exports a full bundle that imports when the host holds neither baseSha nor the merge-base", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-ancestor-")); cleanupDirs.push(rootDir); // Host was reset to a strict ancestor of the eventual merge-base: it holds // only the very first commit, not baseSha and not the fork point. const host = await createRepo(rootDir); const ancestor = await git(host, ["rev-parse", "HEAD"]); const sandbox = path.join(rootDir, "sandbox"); await git(rootDir, ["clone", host, sandbox]); await git(sandbox, ["config", "user.name", "Paperclip Remote"]); await git(sandbox, ["config", "user.email", "remote@paperclip.dev"]); // Advance the merge-base past the host, then baseSha past that, then a // divergent local commit — so merge-base(baseSha, HEAD) is itself a commit // the host does not hold. await writeFile(path.join(sandbox, "fork.txt"), "fork\n", "utf8"); await git(sandbox, ["add", "-A"]); await git(sandbox, ["commit", "-m", "fork point"]); const forkPoint = await git(sandbox, ["rev-parse", "HEAD"]); await writeFile(path.join(sandbox, "advance.txt"), "advance\n", "utf8"); await git(sandbox, ["add", "-A"]); await git(sandbox, ["commit", "-m", "advance"]); const baseSha = await git(sandbox, ["rev-parse", "HEAD"]); await git(sandbox, ["reset", "--hard", forkPoint]); await writeFile(path.join(sandbox, "local.txt"), "local\n", "utf8"); await git(sandbox, ["add", "-A"]); await git(sandbox, ["commit", "-m", "local-only"]); const sandboxHead = await git(sandbox, ["rev-parse", "HEAD"]); // Host holds only the initial commit; it lacks both baseSha and the fork point. expect(await git(host, ["rev-parse", "HEAD"])).toBe(ancestor); await expect(git(host, ["cat-file", "-e", `${forkPoint}^{commit}`])).rejects.toThrow(); const exportRef = createRemoteGitExportRef("test"); const importedRef = createImportedGitRef("test"); // The delta bundle (relative to the merge-base = fork point) names a // prerequisite the host lacks, so its import fails and is detected. const deltaBundle = path.join(rootDir, "delta.bundle"); await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({ remoteDir: sandbox, baseSha, exportRef, bundlePath: deltaBundle, })]); let deltaError: unknown; try { await fetchGitBundleIntoLocalRef({ localDir: host, bundlePath: deltaBundle, exportRef, importedRef, baseSha }); } catch (error) { deltaError = error; } expect(deltaError).toBeDefined(); expect(isMissingGitPrerequisiteError(deltaError)).toBe(true); // The forced full bundle is self-contained and imports into the same host. const fullBundle = path.join(rootDir, "full.bundle"); try { await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({ remoteDir: sandbox, baseSha, exportRef, bundlePath: fullBundle, forceFullBundle: true, })]); const importedHead = await fetchGitBundleIntoLocalRef({ localDir: host, bundlePath: fullBundle, exportRef, importedRef, baseSha, }); expect(importedHead).toBe(sandboxHead); } finally { await deleteLocalGitRef({ localDir: host, ref: importedRef }); } }); it("falls back to a full self-contained bundle when the sandbox lacks baseSha", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-full-")); cleanupDirs.push(rootDir); const sandbox = await createRepo(rootDir); await writeFile(path.join(sandbox, "more.txt"), "more\n", "utf8"); await git(sandbox, ["add", "-A"]); await git(sandbox, ["commit", "-m", "more"]); const sandboxHead = await git(sandbox, ["rev-parse", "HEAD"]); // A fresh, unrelated host that shares no history with the sandbox. const host = path.join(rootDir, "fresh-host"); await mkdir(host, { recursive: true }); await git(host, ["init"]); const bundle = path.join(rootDir, "full.bundle"); const exportRef = createRemoteGitExportRef("test"); const importedRef = createImportedGitRef("test"); try { await execFile("sh", ["-c", buildRemoteGitDeltaBundleScript({ remoteDir: sandbox, // A base the sandbox does not have forces the full-bundle fallback. baseSha: "0000000000000000000000000000000000000000", exportRef, bundlePath: bundle, })]); expect((await stat(bundle)).size).toBeGreaterThan(0); const importedHead = await fetchGitBundleIntoLocalRef({ localDir: host, bundlePath: bundle, exportRef, importedRef, baseSha: "0000000000000000000000000000000000000000", }); expect(importedHead).toBe(sandboxHead); } finally { await deleteLocalGitRef({ localDir: host, ref: importedRef }); } }); });