diff --git a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md index f382f2fd1f9..c8f1883d462 100644 --- a/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md +++ b/doc/plans/2026-11-03-existing-workspace-ephemeral-migration.md @@ -294,3 +294,12 @@ so a missing file cannot create a replacement during publication. Its database connections have five-second lock waits and 30-second statement and idle transaction limits. Cancellation closes the command's clients and has a ten-second process-exit fallback for a dependency that does not settle. + +Workspace archives preserve a resolved Git merge that still awaits its merge +commit. The archive retains the merge heads, original head, message, mode, +automatic merge tree and selected index, and verifies their objects and unchanged +metadata before publication. A restored copy can continue the merge after the +source disappears. Git still protects later unstaged edits during abort. +Conflicted indexes, merge autostash, rerere state, unfinished rebase or sequencer +operations, invalid metadata and concurrent changes block capture and retain the +original workspace. Migration never commits or aborts a user's Git operation. diff --git a/server/src/services/remote-workspace-snapshot-archive.test.ts b/server/src/services/remote-workspace-snapshot-archive.test.ts index 4908abc9070..078cee2580c 100644 --- a/server/src/services/remote-workspace-snapshot-archive.test.ts +++ b/server/src/services/remote-workspace-snapshot-archive.test.ts @@ -324,6 +324,112 @@ describe("portable remote workspace archives", () => { await git(restored, "fsck", "--full", "--no-dangling"); }, ); + it.each(["ordinary", "linked", "borrowed", "shallow", "partial", "sha256"].flatMap(kind => ["local", "serialized"].map(mode => ({ kind, mode }))))( + "resumes and aborts a resolved pending merge after $mode $kind sources disappear", async ({ kind, mode }) => { + const base = await source(); await git(base, "init", "-q", "-b", "main", ...(kind === "sha256" ? ["--object-format=sha256"] : [])); + await fs.writeFile(path.join(base, "work.txt"), "base\n"); await git(base, "add", "."); await git(base, "commit", "-qm", "base"); + await git(base, "checkout", "-qb", "feature"); + await fs.writeFile(path.join(base, "work.txt"), "feature\n"); await git(base, "commit", "-qam", "feature"); + const feature = await git(base, "rev-parse", "HEAD"); + await git(base, "checkout", "-q", "main"); + await fs.writeFile(path.join(base, "work.txt"), "main\n"); await git(base, "commit", "-qam", "main"); + const original = await git(base, "rev-parse", "HEAD"); + let cwd = base; + if (kind === "linked") { cwd = path.join(root, "linked"); await git(base, "worktree", "add", "-qb", "selected", cwd); } + if (kind === "borrowed") { cwd = path.join(root, "borrowed"); await git(root, "clone", "-q", "--shared", base, cwd); } + if (kind === "shallow" || kind === "partial") { + cwd = path.join(root, "selected"); + if (kind === "partial") await git(base, "config", "uploadpack.allowFilter", "true"); + await git(root, "clone", "-q", "--no-single-branch", ...(kind === "shallow" ? ["--depth=2"] : ["--filter=blob:none"]), `file://${base}`, cwd); + if (kind === "shallow") expect(await git(cwd, "rev-parse", "--is-shallow-repository")).toBe("true"); + if (kind === "partial") { + expect(await git(cwd, "config", "remote.origin.promisor")).toBe("true"); + await expect(git(cwd, "merge", "--no-ff", "--no-commit", feature)).rejects.toThrow(); + await git(cwd, "merge", "--abort"); + await git(cwd, "config", "remote.origin.url", "https://example.invalid/workspace.git"); + } + } + await expect(git(cwd, "merge", "--no-ff", "--no-commit", feature)).rejects.toThrow(); + await fs.writeFile(path.join(cwd, "work.txt"), "resolved\n"); await git(cwd, "add", "work.txt"); + await fs.writeFile(path.join(cwd, "work.txt"), "later unstaged work\n"); + await fs.writeFile(path.join(cwd, "untracked.txt"), "untracked work\n"); + expect(await git(cwd, "ls-files", "--unmerged")).toBe(""); + for (const row of (await git(cwd, "for-each-ref", "--format=%(objectname) %(refname)")).split("\n")) { + const [object, ref] = row.split(" "); if (object === feature) await git(cwd, "update-ref", "-d", ref!); + } + const gitDir = await git(cwd, "rev-parse", "--absolute-git-dir"); + const names = ["MERGE_HEAD", "MERGE_MODE", "MERGE_MSG", "ORIG_HEAD", "AUTO_MERGE"]; + const metadata = new Map(await Promise.all(names.map(async name => [name, await fs.readFile(path.join(gitDir, name))] as const))); + const autoMerge = await git(cwd, "rev-parse", "AUTO_MERGE"); + const status = await git(cwd, "status", "--porcelain=v2"), bytes = await sourceBytes(cwd), administration = await sourceBytes(gitDir); + let snapshot: Awaited>; + if (mode === "serialized") { + const helper = path.join(root, "helper.cjs"), archivePath = path.join(root, "snapshot.pcws"); + await fs.writeFile(helper, remoteWorkspaceArchiveScript()); + snapshot = { archivePath, ...JSON.parse((await execute(process.execPath, [helper, "capture", cwd, archivePath], { env })).stdout) }; + } else snapshot = await capture(cwd); + expect(await sourceBytes(cwd)).toBe(bytes); expect(await sourceBytes(gitDir)).toBe(administration); + await fs.rm(base, { recursive: true, force: true }); if (cwd !== base) await fs.rm(cwd, { recursive: true, force: true }); + const restored = await restore(snapshot); + expect(await git(restored, "status", "--porcelain=v2")).toBe(status); + for (const [name, content] of metadata) expect(await fs.readFile(path.join(restored, ".git", name))).toEqual(content); + expect(await git(restored, "cat-file", "-t", autoMerge)).toBe("tree"); + expect(await git(restored, "show", ":work.txt")).toBe("resolved"); + expect(await fs.readFile(path.join(restored, "work.txt"), "utf8")).toBe("later unstaged work\n"); + await expect(fs.access(path.join(restored, ".git", "objects", "info", "alternates"))).rejects.toMatchObject({ code: "ENOENT" }); + await git(restored, "-c", "core.editor=true", "merge", "--continue"); + expect(await git(restored, "rev-parse", "HEAD^1")).toBe(original); expect(await git(restored, "rev-parse", "HEAD^2")).toBe(feature); + expect(await git(restored, "show", "HEAD:work.txt")).toBe("resolved"); + expect(await fs.readFile(path.join(restored, "work.txt"), "utf8")).toBe("later unstaged work\n"); + const abortTarget = path.join(root, "abort-target"); + await restoreRemoteWorkspaceArchive({ archivePath: snapshot.archivePath, targetDir: abortTarget, expectedManifestDigest: snapshot.manifestDigest }); + const abortClean = abortTarget; + await expect(git(abortClean, "merge", "--abort")).rejects.toThrow(); + await git(abortClean, "restore", "--worktree", "--", "work.txt"); + await git(abortClean, "merge", "--abort"); + expect(await git(abortClean, "rev-parse", "HEAD")).toBe(original); expect(await fs.readFile(path.join(abortClean, "work.txt"), "utf8")).toBe("main\n"); + expect(await fs.readFile(path.join(abortClean, "untracked.txt"), "utf8")).toBe("untracked work\n"); + }, + ); + async function pendingMerge() { + const cwd = await source(); await git(cwd, "init", "-q", "-b", "main"); + await fs.writeFile(path.join(cwd, "base.txt"), "base\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base"); + await git(cwd, "checkout", "-qb", "feature"); + await fs.writeFile(path.join(cwd, "feature.txt"), "feature\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "feature"); + await git(cwd, "checkout", "-q", "main"); await git(cwd, "merge", "--no-commit", "--no-ff", "feature"); + return cwd; + } + it.each(["invalid_head", "missing_commit", "invalid_mode", "missing_original", "symlink_message", "rerere", "mixed_rebase", "autostash", "invalid_auto"])( + "rejects a pending merge with %s metadata without changing its source", async kind => { + const cwd = await pendingMerge(), gitDir = path.join(cwd, ".git"); + if (kind === "invalid_head" || kind === "missing_commit") await fs.writeFile(path.join(gitDir, "MERGE_HEAD"), kind === "invalid_head" ? "invalid\n" : `${"a".repeat(40)}\n`); + if (kind === "invalid_mode") await fs.writeFile(path.join(gitDir, "MERGE_MODE"), "unknown"); + if (kind === "missing_original") await fs.rm(path.join(gitDir, "ORIG_HEAD")); + if (kind === "symlink_message") { await fs.rm(path.join(gitDir, "MERGE_MSG")); await fs.symlink("HEAD", path.join(gitDir, "MERGE_MSG")); } + if (kind === "rerere") await fs.writeFile(path.join(gitDir, "MERGE_RR"), ""); + if (kind === "mixed_rebase") await fs.writeFile(path.join(gitDir, "REBASE_HEAD"), `${await git(cwd, "rev-parse", "HEAD")}\n`); + if (kind === "autostash") await fs.writeFile(path.join(gitDir, "MERGE_AUTOSTASH"), `${await git(cwd, "rev-parse", "HEAD")}\n`); + if (kind === "invalid_auto") await fs.writeFile(path.join(gitDir, "AUTO_MERGE"), `${await git(cwd, "rev-parse", "HEAD")}\n`); + const bytes = await sourceBytes(cwd); + await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_git_operation_in_progress"); + expect(await sourceBytes(cwd)).toBe(bytes); + await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); + it.each(["MERGE_MODE", "AUTO_MERGE"])("refuses a linked %s even without a pending merge", async name => { + const cwd = await source(); await git(cwd, "init", "-q", "-b", "main"); + await fs.writeFile(path.join(cwd, "work.txt"), "base\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base"); + await fs.symlink("HEAD", path.join(cwd, ".git", name)); + await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_unsafe_git_link"); + expect(await fs.readlink(path.join(cwd, ".git", name))).toBe("HEAD"); + await expect(fs.access(path.join(root, "snapshot.pcws"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it.each(["MERGE_MODE", "MERGE_MSG"])("rejects %s changed after capture", async name => { + const cwd = await pendingMerge(), archivePath = path.join(root, "snapshot.pcws"); + archiveIo.beforeOpen = async target => { if (target === archivePath) await fs.writeFile(path.join(cwd, ".git", name), name === "MERGE_MODE" ? "" : "changed message\n"); }; + await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_changed"); + await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" }); + }); it.each(["invalid", "missing_commit", "symlink", "directory", "unmerged"])( "rejects an unsafe inactive rebase bookmark with %s state", async kind => { const cwd = await source(); await git(cwd, "init", "-q", "-b", "main"); diff --git a/server/src/services/remote-workspace-snapshot-archive.ts b/server/src/services/remote-workspace-snapshot-archive.ts index 5a56bb32bad..dc499c9e26e 100644 --- a/server/src/services/remote-workspace-snapshot-archive.ts +++ b/server/src/services/remote-workspace-snapshot-archive.ts @@ -180,7 +180,7 @@ async function workspaceArchive(input: { // unfinished Git operation. Never acknowledge a copy that drops its todo, // scripts, autostash, or operation-only object references. Inspect the // selected worktree's administration directory without following links. - for (const name of ["rebase-merge", "rebase-apply", "sequencer", "MERGE_HEAD", "CHERRY_PICK_HEAD", + for (const name of ["rebase-merge", "rebase-apply", "sequencer", "CHERRY_PICK_HEAD", "REVERT_HEAD", "MERGE_AUTOSTASH", "BISECT_START"]) { const state = await fs.lstat(path.join(gitDir, name)).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; @@ -188,6 +188,43 @@ async function workspaceArchive(input: { }); if (state) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); } + const readState = async (name: string, limit: number) => { + const file = path.join(gitDir, name), entry = await fs.lstat(file).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (!entry) return null; + if (!entry.isFile() || entry.isSymbolicLink() || entry.size > limit) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + const chunks: Buffer[] = []; let size = 0; + const saved = await readStableFile(file, async chunk => { + size += chunk.length; + if (size > limit) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + chunks.push(chunk); + }); + return { name, sha256: saved.sha256, text: Buffer.concat(chunks).toString("utf8") }; + }; + const mergeHead = await readState("MERGE_HEAD", 8192); + if (mergeHead) { + for (const name of ["MERGE_RR", "REBASE_HEAD"]) { + const exists = await fs.lstat(path.join(gitDir, name)).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return false; + }); + if (exists) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + } + if (await git(repo, ["ls-files", "--unmerged", "-z"])) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + const mode = await readState("MERGE_MODE", 16), message = await readState("MERGE_MSG", 4 * 1024 * 1024); + const original = await readState("ORIG_HEAD", 128), automatic = await readState("AUTO_MERGE", 128); + if (!original || !message || mode && !["", "no-ff"].includes(mode.text.trim())) return fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + const objects = [...mergeHead.text.trim().split("\n").map(object => ({ object, type: "commit" })), + { object: original.text.trim(), type: "commit" }, ...(automatic ? [{ object: automatic.text.trim(), type: "tree" }] : [])]; + for (const { object, type } of objects) { + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(object) + || await git(repo, ["rev-parse", "--verify", `${object}^{${type}}`]).then(value => value.trim()).catch(() => null) !== object) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + } + return { objects, metadata: ([["MERGE_HEAD", mergeHead], ["MERGE_MODE", mode], ["MERGE_MSG", message], ["ORIG_HEAD", original], ["AUTO_MERGE", automatic]] as const) + .map(([name, entry]) => ({ name, sha256: entry?.sha256 ?? null })) }; + } const bookmark = path.join(gitDir, "REBASE_HEAD"); const state = await fs.lstat(bookmark).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; @@ -205,7 +242,7 @@ async function workspaceArchive(input: { if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u.test(object) || await git(repo, ["rev-parse", "--verify", "REBASE_HEAD^{commit}"]).then(value => value.trim()).catch(() => null) !== object || await git(repo, ["ls-files", "--unmerged", "-z"])) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); - return { object, sha256: entry.sha256 }; + return { objects: [{ object, type: "commit" }], metadata: [{ name: "REBASE_HEAD", sha256: entry.sha256 }] }; }; const readStableFile = async (source: string, consume?: (bytes: Buffer) => Promise) => { check(); @@ -341,13 +378,13 @@ async function workspaceArchive(input: { const repositories = [...new Set(directories)]; const gitSignatures = new Map(); const signature = async (repo: string, settings?: Awaited>) => { - const rebaseBookmark = await assertGitOperationComplete(repo); + const operation = await assertGitOperationComplete(repo); const index = (await git(repo, ["rev-parse", "--path-format=absolute", "--git-path", "index"])).trim(); const indexHash = await hashFile(index).catch((error: NodeJS.ErrnoException) => error.code === "ENOENT" ? null : Promise.reject(error)); const config = settings ?? await portableGitSettings(repo), shallow = await gitStateHash(repo, "shallow"); const partial = config.some(setting => /^remote\..*\.promisor$/u.test(setting.key) && setting.value === "true"); return JSON.stringify([await git(repo, ["status", "--porcelain=v2", "-z", "--untracked-files=no"]), await git(repo, ["for-each-ref", "--format=%(objectname) %(refname)"]), await git(repo, ["symbolic-ref", "-q", "HEAD"]).catch(() => git(repo, ["rev-parse", "HEAD"])), indexHash, config, - shallow, await gitStateHash(repo, "info/sparse-checkout"), shallow || partial ? (await gitObjectFiles(repo)).files : null, rebaseBookmark]); + shallow, await gitStateHash(repo, "info/sparse-checkout"), shallow || partial ? (await gitObjectFiles(repo)).files : null, operation]); }; for (let index = 0; index < repositories.length; index++) { const relative = repositories[index]!, repo = path.join(root, relative), normalized = path.join(scratch, `repo-${index}.git`); @@ -377,7 +414,7 @@ async function workspaceArchive(input: { for (const name of ["HEAD", "index", "ORIG_HEAD", "MERGE_HEAD", "MERGE_MSG", "CHERRY_PICK_HEAD", "REVERT_HEAD"]) { await fs.copyFile(path.join(sourceGit, name), path.join(destination, name)).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); } - await copyGitFile(path.join(sourceGit, "REBASE_HEAD"), path.join(destination, "REBASE_HEAD")); + for (const name of ["REBASE_HEAD", "MERGE_MODE", "AUTO_MERGE"]) await copyGitFile(path.join(sourceGit, name), path.join(destination, name)); for (const directory of new Set([sourceGit, sourceCommon])) { for (const name of await fs.readdir(directory)) if (/^sharedindex\.[a-f0-9]+$/.test(name)) await fs.copyFile(path.join(directory, name), path.join(destination, name)); } @@ -399,12 +436,15 @@ async function workspaceArchive(input: { const [object, ref] = row.split(" "); return `update ${ref} ${object}\n`; }).join(""); if (updates) await git(restoredRepo, ["update-ref", "--stdin"], updates); - const rebaseBookmark = await assertGitOperationComplete(repo); - if (rebaseBookmark) { - const present = await git(restoredRepo, ["cat-file", "-e", `${rebaseBookmark.object}^{commit}`]).then(() => true).catch(() => false); + const operation = await assertGitOperationComplete(repo); + for (const entry of operation?.metadata ?? []) { + if (await gitStateHash(restoredRepo, entry.name) !== entry.sha256) fail("changed"); + } + for (const { object, type } of operation?.objects ?? []) { + const present = await git(restoredRepo, ["cat-file", "-e", `${object}^{${type}}`]).then(() => true).catch(() => false); if (!present && !offline) await git(restoredRepo, ["-c", "protocol.file.allow=always", "fetch", "--no-tags", "--no-recurse-submodules", - "--no-write-fetch-head", "--no-auto-maintenance", "--", repo, rebaseBookmark.object]); - await git(restoredRepo, ["cat-file", "-e", `${rebaseBookmark.object}^{commit}`]); + "--no-write-fetch-head", "--no-auto-maintenance", "--", repo, object]); + await git(restoredRepo, ["cat-file", "-e", `${object}^{${type}}`]); } await fs.rm(path.join(destination, "objects", "info", "alternates"), { force: true }); await git(restoredRepo, ["fsck", "--full", "--no-dangling"]);