diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 7a7b8c646a..596cd70981 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -11,6 +11,7 @@ import { createRemoteGitExportRef, deleteLocalGitRef, fetchGitBundleIntoLocalRef, + isMissingGitPrerequisiteError, readGitWorkspaceSnapshot, runLocalGit, withShallowGitWorkspaceClone, @@ -125,4 +126,177 @@ describe("git workspace sync", () => { } }); }); + + 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 }); + } + }); }); diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index 1e0a37d8a1..0e36dee982 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -197,6 +197,24 @@ export async function fetchGitBundleIntoLocalRef(input: { return importedHead.stdout.trim(); } +/** Substrings git emits when a bundle names a prerequisite the importer lacks. */ +const GIT_MISSING_PREREQUISITE_MARKERS = [ + "did not send all necessary objects", + "lacks these prerequisite commits", + "revision walk setup failed", +]; + +/** + * True when a bundle import failed because the host repository does not hold a + * commit the (delta) bundle assumes as a prerequisite. Such a failure is + * recoverable by re-exporting a full, self-contained bundle from the still-live + * sandbox rather than discarding the run. + */ +export function isMissingGitPrerequisiteError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return GIT_MISSING_PREREQUISITE_MARKERS.some((marker) => message.includes(marker)); +} + export function buildRemoteGitDeltaBundleScript(input: { remoteDir: string; baseSha: string; @@ -205,6 +223,12 @@ export function buildRemoteGitDeltaBundleScript(input: { statusPath?: string; catBundle?: boolean; cleanupBundle?: boolean; + /** + * Skip the delta boundary entirely and always emit a full, self-contained + * bundle (no prerequisites). Used as the recovery path when a delta import + * failed because the host lacked the bundle's prerequisite. + */ + forceFullBundle?: boolean; }): string { const remoteDir = shellQuote(input.remoteDir); const bundlePath = shellQuote(input.bundlePath); @@ -222,11 +246,44 @@ export function buildRemoteGitDeltaBundleScript(input: { input.cleanupBundle ? "trap cleanup EXIT" : "", `mkdir -p ${shellQuote(path.posix.dirname(input.bundlePath))}`, `rm -f ${bundlePath}`, - `git -C ${remoteDir} cat-file -e ${baseSha}^{commit}`, - `commit_count=$(git -C ${remoteDir} rev-list --count HEAD --not ${baseSha})`, + // Choose the bundle boundary. A thin bundle `HEAD --not ` records + // baseSha as a prerequisite the importer (host) must already hold. That + // assumption breaks in two real cases, and then `git fetch` on the host + // hard-fails with "did not send all necessary objects" and the run's work + // is lost: + // 1. The sandbox HEAD has diverged from baseSha (e.g. a local-only branch + // that forked from an older commit) — the host may still hold baseSha, + // but a repo whose history is inconsistent cannot satisfy the walk. + // 2. The host workspace no longer holds baseSha at import time (a shared + // workspace that was reset/re-realized between export and import). + // Bundle relative to the merge-base of baseSha and HEAD instead: that + // merge-base is an ancestor of baseSha, so any host that holds baseSha (or + // an ancestor of it) can satisfy the prerequisite, while the bundle stays a + // delta. When baseSha is absent from the sandbox — or no merge-base exists, + // or the caller forces it after a delta import failed on a missing + // prerequisite — fall back to a full, self-contained bundle with no + // prerequisites. + ...(input.forceFullBundle + ? [`bundle_base=""`] + : [ + `if git -C ${remoteDir} cat-file -e ${baseSha}^{commit} 2>/dev/null; then`, + ` bundle_base=$(git -C ${remoteDir} merge-base ${baseSha} HEAD 2>/dev/null || true)`, + "else", + ` bundle_base=""`, + "fi", + ]), + `if [ -n "$bundle_base" ]; then`, + ` commit_count=$(git -C ${remoteDir} rev-list --count HEAD --not "$bundle_base")`, + "else", + ` commit_count=$(git -C ${remoteDir} rev-list --count HEAD)`, + "fi", 'if [ "$commit_count" -gt 0 ]; then', ` git -C ${remoteDir} update-ref ${exportRef} HEAD`, - ` git -C ${remoteDir} bundle create ${bundlePath} ${exportRef} --not ${baseSha} >/dev/null`, + ` if [ -n "$bundle_base" ]; then`, + ` git -C ${remoteDir} bundle create ${bundlePath} ${exportRef} --not "$bundle_base" >/dev/null`, + " else", + ` git -C ${remoteDir} bundle create ${bundlePath} ${exportRef} >/dev/null`, + " fi", "else", ` : > ${bundlePath}`, "fi", diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 74b1f4520c..2f9db88dc8 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { buildRemoteGitDeltaBundleScript, + isMissingGitPrerequisiteError, createImportedGitRef, createRemoteGitExportRef, deleteLocalGitRef, @@ -1063,41 +1064,60 @@ export async function prepareSandboxManagedRuntime(input: { const remoteGitBundle = path.posix.join(runtimeRootDir, "git-delta.bundle"); const remoteWorkspaceStatusPath = path.posix.join(runtimeRootDir, "workspace-status.txt"); const exportRef = createRemoteGitExportRef("sandbox"); - await input.client.run( - `sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({ - remoteDir: workspaceRemoteDir, - baseSha: gitSnapshot.headCommit, + const localBundlePath = path.join(tempDir, "git-delta.bundle"); + + // Export the sandbox history and import it into the host workspace. + // The delta bundle assumes the host holds the bundle's boundary + // commit; when the host has been reset far enough that it does not, + // the import fails on a missing prerequisite. In that case re-export + // a full, self-contained bundle from the still-live sandbox rather + // than discard the completed run. + const exportAndImport = async (forceFullBundle: boolean): Promise => { + await input.client.run( + `sh -c ${shellQuote(buildRemoteGitDeltaBundleScript({ + remoteDir: workspaceRemoteDir, + baseSha: gitSnapshot.headCommit, + exportRef, + bundlePath: remoteGitBundle, + statusPath: forceFullBundle ? undefined : remoteWorkspaceStatusPath, + forceFullBundle, + }))}`, + { timeoutMs: input.spec.timeoutMs }, + ); + const gitExport = makeTransferProgress( + restoreSink, + "Exporting git history", + "from", + undefined, + { sink: input.onRuntimeProgress, phase: "export" }, + ); + const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); + const bundleBuffer = toBuffer(bundleBytes); + await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength); + await input.client.remove(remoteGitBundle).catch(() => undefined); + if (!forceFullBundle) { + remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath) + .then((bytes) => toBuffer(bytes).toString("utf8").trim()) + .catch(() => "dirty"); + remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty"; + await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined); + } + await fs.writeFile(localBundlePath, bundleBuffer); + return fetchGitBundleIntoLocalRef({ + localDir: input.workspaceLocalDir, + bundlePath: localBundlePath, exportRef, - bundlePath: remoteGitBundle, - statusPath: remoteWorkspaceStatusPath, - }))}`, - { timeoutMs: input.spec.timeoutMs }, - ); - const gitExport = makeTransferProgress( - restoreSink, - "Exporting git history", - "from", - undefined, - { sink: input.onRuntimeProgress, phase: "export" }, - ); - const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options); - const bundleBuffer = toBuffer(bundleBytes); - await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength); - await input.client.remove(remoteGitBundle).catch(() => undefined); - remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath) - .then((bytes) => toBuffer(bytes).toString("utf8").trim()) - .catch(() => "dirty"); - remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty"; - await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined); - const bundlePath = path.join(tempDir, "git-delta.bundle"); - await fs.writeFile(bundlePath, bundleBuffer); - importedHead = await fetchGitBundleIntoLocalRef({ - localDir: input.workspaceLocalDir, - bundlePath, - exportRef, - importedRef, - baseSha: gitSnapshot.headCommit, - }); + importedRef: importedRef!, + baseSha: gitSnapshot.headCommit, + }); + }; + + try { + importedHead = await exportAndImport(false); + } catch (error) { + if (!isMissingGitPrerequisiteError(error)) throw error; + importedHead = await exportAndImport(true); + } } await emitRuntimeStatus(input.onRuntimeProgress, "restore", "Restoring workspace from sandbox");