diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 538c675cd66..e35fff596b8 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -931,6 +931,12 @@ restore does not recreate runtime IPC. Named pipes and device entries remain unsupported, while regular files, directories and confined symlinks retain their existing validation. +Portable archives also preserve a standalone `REBASE_HEAD` bookmark and its commit +when no Git operation state or unmerged index remains. Capture does not remove or +change the source bookmark. The archive checks its identity throughout capture +and retains its objects even when no branch references them. An unfinished +rebase, merge, revert, cherry-pick, sequencer or bisect still blocks export. + Disk-backed native sandbox workspace snapshots run in at most two worker threads per controller process. Each capture retains the same file hashes, modes, symlinks, exclusions and ignored-path manifest as an inline capture. A worker seals its diff --git a/server/src/services/remote-workspace-snapshot-archive.test.ts b/server/src/services/remote-workspace-snapshot-archive.test.ts index e436873ce36..8b8dd83d1dd 100644 --- a/server/src/services/remote-workspace-snapshot-archive.test.ts +++ b/server/src/services/remote-workspace-snapshot-archive.test.ts @@ -275,6 +275,89 @@ describe("portable remote workspace archives", () => { await expect(fs.access(path.join(restored, ".git", "objects", "info", "alternates"))).rejects.toMatchObject({ code: "ENOENT" }); await git(restored, "fsck", "--full", "--no-dangling"); }); + it.each(["ordinary", "linked", "borrowed", "shallow", "sha256"].flatMap(kind => + ["local", "serialized"].map(mode => ({ kind, mode }))))( + "preserves an inactive rebase bookmark and its unreferenced commit in $mode $kind capture", 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"); + const bookmark = async (repo: string) => { + await git(repo, "checkout", "-qb", "old-rebase-tip"); + await fs.writeFile(path.join(repo, "bookmark.txt"), "preserved rebase commit\n"); + await git(repo, "add", "."); await git(repo, "commit", "-qm", "old rebase tip"); + const commit = await git(repo, "rev-parse", "HEAD"); + await git(repo, "checkout", "-q", "main"); await git(repo, "branch", "-D", "old-rebase-tip"); + return commit; + }; + let marker = await bookmark(base), cwd = base; + if (kind === "linked") { + cwd = path.join(root, "linked"); await git(base, "worktree", "add", "-qb", "selected", cwd); + } else if (kind === "borrowed" || kind === "shallow") { + cwd = path.join(root, "selected"); + await git(root, "clone", "-q", ...(kind === "borrowed" ? ["--shared", base] : ["--depth=1", `file://${base}`]), cwd); + if (kind === "shallow") marker = await bookmark(cwd); + } + await git(cwd, "update-ref", "REBASE_HEAD", marker); + await fs.writeFile(path.join(cwd, "work.txt"), "staged\n"); await git(cwd, "add", "work.txt"); + await fs.writeFile(path.join(cwd, "work.txt"), "unstaged\n"); + const head = await git(cwd, "rev-parse", "HEAD"), status = await git(cwd, "status", "--porcelain=v2"); + const gitDir = await git(cwd, "rev-parse", "--absolute-git-dir"); + const 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()); + const result = await execute(process.execPath, [helper, "capture", cwd, archivePath], { env }); + snapshot = { archivePath, ...JSON.parse(result.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, "rev-parse", "HEAD")).toBe(head); + expect(await git(restored, "status", "--porcelain=v2")).toBe(status); + expect(await git(restored, "rev-parse", "REBASE_HEAD")).toBe(marker); + expect(await git(restored, "show", "REBASE_HEAD:bookmark.txt")).toBe("preserved rebase commit"); + expect(await git(restored, "show", ":work.txt")).toBe("staged"); + expect(await fs.readFile(path.join(restored, "work.txt"), "utf8")).toBe("unstaged\n"); + await expect(fs.access(path.join(restored, ".git", "FETCH_HEAD"))).rejects.toMatchObject({ code: "ENOENT" }); + await git(restored, "fsck", "--full", "--no-dangling"); + }, + ); + 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"); + await fs.writeFile(path.join(cwd, "work.txt"), "base\n"); await git(cwd, "add", "."); await git(cwd, "commit", "-qm", "base"); + const marker = path.join(cwd, ".git", "REBASE_HEAD"), head = await git(cwd, "rev-parse", "HEAD"); + if (kind === "symlink") await fs.symlink("HEAD", marker); + else if (kind === "directory") await fs.mkdir(marker); + else await fs.writeFile(marker, `${kind === "invalid" ? "not-a-commit" : kind === "missing_commit" ? "a".repeat(40) : head}\n`); + if (kind === "unmerged") { + const object = await git(cwd, "rev-parse", "HEAD:work.txt"); + await git(cwd, "update-index", "--force-remove", "work.txt"); + const command = execFile("git", ["-C", cwd, "update-index", "--index-info"], { env }); + command.stdin!.end(`100644 ${object} 1\twork.txt\n100644 ${object} 2\twork.txt\n`); + await new Promise((resolve, reject) => { command.once("error", reject); command.once("exit", code => code === 0 ? resolve() : reject(new Error("index fixture failed"))); }); + } + 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("rejects a bookmark changed after the normalized repository was captured", async () => { + 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"); + const first = await git(cwd, "rev-parse", "HEAD"); + await fs.writeFile(path.join(cwd, "work.txt"), "next\n"); await git(cwd, "commit", "-qam", "next"); + const second = await git(cwd, "rev-parse", "HEAD"), marker = path.join(cwd, ".git", "REBASE_HEAD"); + await fs.writeFile(marker, `${first}\n`); + const archivePath = path.join(root, "snapshot.pcws"); + archiveIo.beforeOpen = async target => { if (target === archivePath) await fs.writeFile(marker, `${second}\n`); }; + await expect(capture(cwd)).rejects.toThrow("remote_workspace_archive_changed"); + expect(await fs.readFile(marker, "utf8")).toBe(`${second}\n`); + await expect(fs.access(archivePath)).rejects.toMatchObject({ code: "ENOENT" }); + }); it.each(["rebase-merge", "rebase-apply", "linked-rebase", "sequencer", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"])( "rejects a real unfinished %s operation without changing its source or publishing an archive", async operation => { let 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 5e6ebc37ad5..b09fbdc2d4b 100644 --- a/server/src/services/remote-workspace-snapshot-archive.ts +++ b/server/src/services/remote-workspace-snapshot-archive.ts @@ -132,13 +132,31 @@ async function workspaceArchive(input: { // 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", - "REVERT_HEAD", "REBASE_HEAD", "MERGE_AUTOSTASH", "BISECT_START"]) { + "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; return null; }); if (state) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); } + const bookmark = path.join(gitDir, "REBASE_HEAD"); + const state = await fs.lstat(bookmark).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (!state) return null; + if (!state.isFile() || state.isSymbolicLink() || state.size > 128) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + const bytes: Buffer[] = []; let size = 0; + const entry = await readStableFile(bookmark, async chunk => { + size += chunk.length; + if (size > 128) fail("git_operation_in_progress: finish or abort the Git operation before saving the workspace"); + bytes.push(chunk); + }); + const object = Buffer.concat(bytes).toString("utf8").trim(); + 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 }; }; const readStableFile = async (source: string, consume?: (bytes: Buffer) => Promise) => { check(); @@ -266,13 +284,13 @@ async function workspaceArchive(input: { const repositories = [...new Set(directories)]; const gitSignatures = new Map(); const signature = async (repo: string, settings?: Awaited>) => { - await assertGitOperationComplete(repo); + const rebaseBookmark = 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]); + shallow, await gitStateHash(repo, "info/sparse-checkout"), shallow || partial ? (await gitObjectFiles(repo)).files : null, rebaseBookmark]); }; for (let index = 0; index < repositories.length; index++) { const relative = repositories[index]!, repo = path.join(root, relative), normalized = path.join(scratch, `repo-${index}.git`); @@ -302,6 +320,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 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)); } @@ -323,6 +342,13 @@ 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); + 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}`]); + } await fs.rm(path.join(destination, "objects", "info", "alternates"), { force: true }); await git(restoredRepo, ["fsck", "--full", "--no-dangling"]); if (await signature(repo) !== gitSignatures.get(relative)) fail("changed");