diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 5bc79af33a..e7f1a6b9a5 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -477,6 +477,63 @@ describe("git workspace sync", () => { expect(mergedTree).toContain("local.txt"); expect(mergedTree).toContain("imported.txt"); }); + + it("grafts an imported head onto the current head when histories share no ancestor", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-graft-")); + cleanupDirs.push(rootDir); + const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; + const repo = path.join(rootDir, "repo"); + await mkdir(repo, { recursive: true }); + await git(repo, ["init"]); + await git(repo, ["checkout", "-b", "main"]); + await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8"); + await git(repo, ["add", "tracked.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "base"]); + const baseHead = await git(repo, ["rev-parse", "HEAD"]); + + await writeFile(path.join(repo, "local.txt"), "local\n", "utf8"); + await git(repo, ["add", "local.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "local advance"]); + const currentHead = await git(repo, ["rev-parse", "HEAD"]); + + // The shape a depth-1 shallow clone produces after `git commit --amend`: + // a parentless root commit that shares no ancestor with the host history. + const importedTree = await git(repo, ["rev-parse", `${baseHead}^{tree}`]); + const importedHead = await git(repo, [...setupIdentity, "commit-tree", importedTree, "-m", "sandbox rewrite"]); + + await integrateImportedGitHead({ localDir: repo, importedHead }); + + const parents = (await git(repo, ["rev-list", "--parents", "-1", "HEAD"])).split(" "); + expect(parents.slice(1)).toEqual([currentHead]); + // The imported tree is taken wholesale: no base exists to merge against. + expect(await git(repo, ["rev-parse", "HEAD^{tree}"])).toBe(importedTree); + expect(await git(repo, ["log", "-1", "--format=%s"])).toBe("sandbox rewrite"); + const body = await git(repo, ["log", "-1", "--format=%B"]); + expect(body).toContain(`Paperclip remote git sync graft ${importedHead.slice(0, 12)}`); + expect(body).toContain("shares no ancestor"); + }); + + it("does not graft when merge-base fails for a reason other than missing ancestry", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-no-graft-")); + cleanupDirs.push(rootDir); + const setupIdentity = ["-c", "user.name=Setup", "-c", "user.email=setup@paperclip.dev"]; + const repo = path.join(rootDir, "repo"); + await mkdir(repo, { recursive: true }); + await git(repo, ["init"]); + await git(repo, ["checkout", "-b", "main"]); + await writeFile(path.join(repo, "tracked.txt"), "base\n", "utf8"); + await git(repo, ["add", "tracked.txt"]); + await git(repo, [...setupIdentity, "commit", "-m", "base"]); + const currentHead = await git(repo, ["rev-parse", "HEAD"]); + + // A well-formed sha the repository does not hold: merge-base fails with an + // object error (exit 128), not the no-ancestor signal (exit 1). The graft + // must not fire, and the integration keeps its loud failure. + const missingHead = "0123456789abcdef0123456789abcdef01234567"; + await expect(integrateImportedGitHead({ localDir: repo, importedHead: missingHead })) + .rejects.toThrow(/Failed to merge concurrent remote git histories/); + expect(await git(repo, ["rev-parse", "HEAD"])).toBe(currentHead); + }); }); describe("sanitizeGitRemoteUrl", () => { diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index eb2973b116..6cae5e4724 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -433,6 +433,51 @@ export function buildRemoteGitDeltaBundleScript(input: { ].filter(Boolean).join("\n"); } +/** + * Preserve imported work whose history does not connect to the local one. + * + * The dominant real-world cause is a history rewrite inside a transported + * workspace: transported clones are depth-1 shallow, so the boundary commit + * reads as parentless there and `git commit --amend` rewrites it into a root + * commit that shares no ancestor with the host history. A tree merge is + * impossible without a common ancestor, and failing the integration would + * discard the run's work. Instead, squash-graft the imported tree onto the + * current head as a single commit that reuses the imported head's message, + * with a trailer recording the graft. Concurrent local-only commits keep + * their place in history as the graft's ancestry; the imported tree is taken + * wholesale because no base exists to merge against. The caller advances the + * branch ref to the returned commit. + */ +export async function createUnrelatedHistoryGraftCommit(input: { + localDir: string; + currentHead: string; + importedHead: string; + syncLabel: string; +}): Promise { + const importedTree = (await runLocalGit(input.localDir, ["rev-parse", `${input.importedHead}^{tree}`], { + timeout: 10_000, + maxBuffer: 16 * 1024, + })).stdout.trim(); + const importedMessage = (await runLocalGit(input.localDir, ["log", "-1", "--format=%B", input.importedHead], { + timeout: 10_000, + maxBuffer: 256 * 1024, + })).stdout; + const message = [ + importedMessage.trim(), + "", + `(${input.syncLabel} graft ${input.importedHead.slice(0, 12)}: imported history shares no ancestor with ${input.currentHead.slice(0, 12)})`, + ].join("\n"); + const graftCommit = await runLocalGit( + input.localDir, + [...GIT_SYNC_COMMIT_IDENTITY_ARGS, "commit-tree", importedTree, "-p", input.currentHead, "-m", message], + { + timeout: 60_000, + maxBuffer: 64 * 1024, + }, + ); + return graftCommit.stdout.trim(); +} + export async function integrateImportedGitHead(input: { localDir: string; importedHead: string; @@ -450,10 +495,18 @@ export async function integrateImportedGitHead(input: { if (!currentHead || currentHead === input.importedHead) return; const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; + // `git merge-base` exits 1 when the commits share no ancestor — the only + // outcome that authorizes the graft fallback below. Every other failure + // (timeout, missing object, repository error) must keep failing the + // integration instead of silently rewriting the tip. + let noCommonAncestor = false; const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { timeout: 10_000, maxBuffer: 16 * 1024, - }).catch(() => null); + }).catch((error: unknown) => { + noCommonAncestor = (error as { code?: unknown } | null)?.code === 1; + return null; + }); const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; if (mergeBaseHead === input.importedHead) { @@ -473,6 +526,28 @@ export async function integrateImportedGitHead(input: { } } + if (noCommonAncestor) { + // No common ancestor — merging is impossible and failing here would + // discard the imported work. Graft it onto the current head instead; + // see createUnrelatedHistoryGraftCommit. + const graftCommit = await createUnrelatedHistoryGraftCommit({ + localDir: input.localDir, + currentHead, + importedHead: input.importedHead, + syncLabel: "Paperclip remote git sync", + }); + try { + await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } catch (error) { + if (isConcurrentRefUpdateError(error) && attempt < 4) continue; + throw error; + } + } + let mergedTree; try { mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], { diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 5cda8c60eb..301d85cf58 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -6,7 +6,11 @@ import os from "node:os"; import path from "node:path"; import { Transform } from "node:stream"; import type { CommandManagedRuntimeRunner } from "./command-managed-runtime.js"; -import { GIT_SYNC_COMMIT_IDENTITY_ARGS, readSanitizedOriginRemoteUrl } from "./git-workspace-sync.js"; +import { + createUnrelatedHistoryGraftCommit, + GIT_SYNC_COMMIT_IDENTITY_ARGS, + readSanitizedOriginRemoteUrl, +} from "./git-workspace-sync.js"; import type { RunProcessResult } from "./server-utils.js"; import type { DirectorySnapshot } from "./workspace-restore-merge.js"; import { mergeDirectoryWithBaseline } from "./workspace-restore-merge.js"; @@ -931,10 +935,18 @@ async function integrateImportedGitHead(input: { if (!currentHead || currentHead === input.importedHead) return; const headRef = snapshot.branchName ? `refs/heads/${snapshot.branchName}` : "HEAD"; + // `git merge-base` exits 1 when the commits share no ancestor — the only + // outcome that authorizes the graft fallback below. Every other failure + // (timeout, missing object, repository error) must keep failing the + // integration instead of silently rewriting the tip. + let noCommonAncestor = false; const mergeBase = await runLocalGit(input.localDir, ["merge-base", currentHead, input.importedHead], { timeout: 10_000, maxBuffer: 16 * 1024, - }).catch(() => null); + }).catch((error: unknown) => { + noCommonAncestor = (error as { code?: unknown } | null)?.code === 1; + return null; + }); const mergeBaseHead = mergeBase?.stdout.trim() ?? ""; if (mergeBaseHead === input.importedHead) { @@ -954,6 +966,28 @@ async function integrateImportedGitHead(input: { } } + if (noCommonAncestor) { + // No common ancestor — merging is impossible and failing here would + // discard the imported work. Graft it onto the current head instead; + // see createUnrelatedHistoryGraftCommit. + const graftCommit = await createUnrelatedHistoryGraftCommit({ + localDir: input.localDir, + currentHead, + importedHead: input.importedHead, + syncLabel: "Paperclip SSH sync", + }); + try { + await runLocalGit(input.localDir, ["update-ref", headRef, graftCommit, currentHead], { + timeout: 10_000, + maxBuffer: 16 * 1024, + }); + return; + } catch (error) { + if (isConcurrentRefUpdateError(error) && attempt < 4) continue; + throw error; + } + } + let mergedTree; try { mergedTree = await runLocalGit(input.localDir, ["merge-tree", "--write-tree", currentHead, input.importedHead], {