fix(workspaces): preserve resolved pending merges in snapshots (#19429)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task workspaces must survive replacement of the web server.
> - Workspace snapshots therefore preserve both files and portable Git
state.
> - A resolved merge can still await its merge commit.
> - The archive rejected this state, so an existing workspace could not
migrate.
> - This pull request saves and checks the complete supported merge
state.
> - The benefit is that migration preserves the user's choice to
continue or abort the merge.

## Linked Issues or Issue Description

**What happened?**

A legacy workspace contained a resolved merge with `MERGE_HEAD`,
`MERGE_MODE`, `MERGE_MSG`, `ORIG_HEAD`, and `AUTO_MERGE`. Its index had
no unresolved entries. The snapshot owner refused the operation. This
kept the original safe but blocked migration.

**Expected behavior**

Save this supported state without changing the source. Restore it
without the original repository, borrowed object store, or linked
worktree. Retain the merge parents, selected index, later edits, and
Git's normal safety checks.

**Steps to reproduce**

1. Merge two branches that conflict.
2. Resolve and stage the conflict. Leave the merge uncommitted.
3. Capture the workspace. The base implementation rejects the merge
marker.

Related prior work: #19310 preserves an inactive rebase bookmark. No
duplicate open merge-preservation PR was found. This is a workspace
preservation fix, not a new roadmap feature.

## What Changed

- Admit only resolved pending merges with valid, bounded metadata and
available objects.
- Retain and verify the merge mode, message, parents, original head,
automatic merge tree, and index.
- Verify copied metadata and object closure before publication. Keep the
before/after source checks.
- Keep unresolved conflicts, autostash, rerere, mixed operations,
malformed state, and unsafe links blocked.
- Test real ordinary, linked, and borrowed repositories through local
and serialized capture. Delete all original sources before continuing or
aborting the restored merge.
- Document the supported state and the remaining refusal cases.

## Verification

- Six new continuation cases fail against the unchanged base at the
pending-operation guard.
- The first candidate restored and continued the merges, but six abort
assertions failed. The fixture rewrote the worktree without updating
Git's index cache. It now first verifies that abort protects later
edits, uses normal `git restore` to discard those fixture edits, then
aborts. No archive assertion was removed.
- The final archive and environment suites pass all 98 tests at
`3c87d13d1b`, including linked-metadata protections and pending merges
in ordinary, linked, borrowed, shallow, partial, and SHA-256
repositories. All 12 local/serialized continuation cases pass after
source removal.
- Full repository build and typecheck pass at `1544cc4a90`. The build
first found a nullability error; an explicit rejection return fixed it.
- The first full local test attempt stopped before tests ran because
omitted package postinstall steps left PostgreSQL library links absent.
Direct `initdb --version` reproduced the missing-library failure.
Restoring the package links made that probe pass; the full test rerun is
active.
- The initial typecheck also found missing excluded-plugin SDK links
after the same install. Running the repository postinstall link helper
corrected this, and the full typecheck now passes.
- All current-head CI gates pass at `3c87d13d1b`: 54 success, 2 skipped,
1 neutral. Fresh Greptile is 5/5 and no review threads remain open.
- Review requested shallow and SHA-256 coverage. The current commit adds
both plus partial-clone coverage; all 98 archive tests and the full
server typecheck pass.
- All six local chat shards pass. The monolithic local general-suite run
reports one viewer-permission test failure and remains active. That
exact test passes alone, and its full 505-test file also passes alone.
The original failure cause is unresolved; this is not a full local-suite
PASS. The complete current-head CI suite is green.

## Risks

- This widens capture eligibility only for the checked resolved-merge
state. No migration commits or aborts the source operation.
- A missing object or changed metadata still blocks publication and
retains the source.
- Unresolved merges and other operation formats remain unsupported.
Their preservation guard is unchanged.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, and code
execution. The runtime does not expose a more specific serving model ID
or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used with the available version and
capability details
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [x] My branch name describes the change and contains no internal task
id
- [x] I have run the affected tests locally and they pass; full-suite
limitations are recorded above
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented the risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-06 04:20:32 -06:00
1 parent 8c578668b8
commit 253bf071f0
3 files changed
+165 -10

No files matched your search

@@ -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.
@@ -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<ReturnType<typeof capture>>;
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");
@@ -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<void>) => {
check();
@@ -341,13 +378,13 @@ async function workspaceArchive(input: {
const repositories = [...new Set(directories)];
const gitSignatures = new Map<string, string>();
const signature = async (repo: string, settings?: Awaited<ReturnType<typeof portableGitSettings>>) => {
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"]);