From fb63d61ae50ab0961aea89e5ae0ce795013d2760 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 16:06:41 -0500 Subject: [PATCH] Skip missing worktree attachment objects Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 14 ++++++++++ cli/src/commands/worktree.ts | 42 +++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index a8333ba5..7a5f49d7 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, + readSourceAttachmentBody, rebindWorkspaceCwd, resolveSourceConfigPath, resolveGitWorktreeAddArgs, @@ -195,6 +196,19 @@ describe("worktree helpers", () => { expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); }); + it("treats missing source attachment objects as a non-fatal skip", async () => { + const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); + await expect( + readSourceAttachmentBody( + { + getObject: vi.fn().mockRejectedValue(missingErr), + }, + "company-1", + "company-1/issues/issue-1/missing.png", + ), + ).resolves.toBeNull(); + }); + it("generates vivid worktree colors as hex", () => { expect(generateWorktreeColor()).toMatch(/^#[0-9a-f]{6}$/); }); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index f167ea43..28cc85c6 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -349,6 +349,31 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise { return Buffer.concat(chunks); } +export function isMissingStorageObjectError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const candidate = error as { code?: unknown; status?: unknown; name?: unknown; message?: unknown }; + return candidate.code === "ENOENT" + || candidate.status === 404 + || candidate.name === "NoSuchKey" + || candidate.name === "NotFound" + || candidate.message === "Object not found."; +} + +export async function readSourceAttachmentBody( + sourceStorage: Pick, + companyId: string, + objectKey: string, +): Promise { + try { + return await sourceStorage.getObject(companyId, objectKey); + } catch (error) { + if (isMissingStorageObjectError(error)) { + return null; + } + throw error; + } +} + export function resolveWorktreeMakeTargetPath(name: string): string { return path.resolve(os.homedir(), resolveWorktreeMakeName(name)); } @@ -2158,6 +2183,7 @@ async function applyMergePlan(input: { ).map((row) => row.id), ); let insertedAttachments = 0; + let skippedMissingAttachmentObjects = 0; for (const attachment of attachmentCandidates) { if (existingAttachmentIds.has(attachment.source.id)) continue; const parentExists = await tx @@ -2167,7 +2193,15 @@ async function applyMergePlan(input: { .then((rows) => rows[0] ?? null); if (!parentExists) continue; - const body = await input.sourceStorage.getObject(companyId, attachment.source.objectKey); + const body = await readSourceAttachmentBody( + input.sourceStorage, + companyId, + attachment.source.objectKey, + ); + if (!body) { + skippedMissingAttachmentObjects += 1; + continue; + } await input.targetStorage.putObject( companyId, attachment.source.objectKey, @@ -2209,6 +2243,7 @@ async function applyMergePlan(input: { mergedDocuments, insertedDocumentRevisions, insertedAttachments, + skippedMissingAttachmentObjects, insertedIssueIdentifiers, }; }); @@ -2299,6 +2334,11 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined, company, plan: collected.plan, }); + if (applied.skippedMissingAttachmentObjects > 0) { + p.log.warn( + `Skipped ${applied.skippedMissingAttachmentObjects} attachments whose source files were missing from storage.`, + ); + } p.outro( pc.green( `Imported ${applied.insertedIssues} issues, ${applied.insertedComments} comments, ${applied.insertedDocuments} documents (${applied.insertedDocumentRevisions} revisions, ${applied.mergedDocuments} merged), and ${applied.insertedAttachments} attachments into ${company.issuePrefix}.`,