mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
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:
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"]);
|
||||
|
||||
Reference in new issue
Block a user