Search sibling storage roots for attachments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-03-20 16:12:10 -05:00
parent fb63d61ae5
commit 54b99d5096
2 changed files with 68 additions and 15 deletions

View file

@ -196,13 +196,37 @@ describe("worktree helpers", () => {
expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'");
}); });
it("treats missing source attachment objects as a non-fatal skip", async () => { it("falls back across storage roots before skipping a missing attachment object", async () => {
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
const expected = Buffer.from("image-bytes");
await expect(
readSourceAttachmentBody(
[
{
getObject: vi.fn().mockRejectedValue(missingErr),
},
{
getObject: vi.fn().mockResolvedValue(expected),
},
],
"company-1",
"company-1/issues/issue-1/missing.png",
),
).resolves.toEqual(expected);
});
it("returns null when an attachment object is missing from every lookup storage", async () => {
const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" }); const missingErr = Object.assign(new Error("missing"), { code: "ENOENT" });
await expect( await expect(
readSourceAttachmentBody( readSourceAttachmentBody(
{ [
getObject: vi.fn().mockRejectedValue(missingErr), {
}, getObject: vi.fn().mockRejectedValue(missingErr),
},
{
getObject: vi.fn().mockRejectedValue(Object.assign(new Error("missing"), { status: 404 })),
},
],
"company-1", "company-1",
"company-1/issues/issue-1/missing.png", "company-1/issues/issue-1/missing.png",
), ),

View file

@ -360,18 +360,21 @@ export function isMissingStorageObjectError(error: unknown): boolean {
} }
export async function readSourceAttachmentBody( export async function readSourceAttachmentBody(
sourceStorage: Pick<ConfiguredStorage, "getObject">, sourceStorages: Array<Pick<ConfiguredStorage, "getObject">>,
companyId: string, companyId: string,
objectKey: string, objectKey: string,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
try { for (const sourceStorage of sourceStorages) {
return await sourceStorage.getObject(companyId, objectKey); try {
} catch (error) { return await sourceStorage.getObject(companyId, objectKey);
if (isMissingStorageObjectError(error)) { } catch (error) {
return null; if (isMissingStorageObjectError(error)) {
continue;
}
throw error;
} }
throw error;
} }
return null;
} }
export function resolveWorktreeMakeTargetPath(name: string): string { export function resolveWorktreeMakeTargetPath(name: string): string {
@ -1350,6 +1353,29 @@ function resolveCurrentEndpoint(): ResolvedWorktreeEndpoint {
}; };
} }
function resolveAttachmentLookupStorages(input: {
sourceEndpoint: ResolvedWorktreeEndpoint;
targetEndpoint: ResolvedWorktreeEndpoint;
}): ConfiguredStorage[] {
const orderedConfigPaths = [
input.sourceEndpoint.configPath,
resolveCurrentEndpoint().configPath,
input.targetEndpoint.configPath,
...toMergeSourceChoices(process.cwd())
.filter((choice) => choice.hasPaperclipConfig)
.map((choice) => path.resolve(choice.worktree, ".paperclip", "config.json")),
];
const seen = new Set<string>();
const storages: ConfiguredStorage[] = [];
for (const configPath of orderedConfigPaths) {
const resolved = path.resolve(configPath);
if (seen.has(resolved) || !existsSync(resolved)) continue;
seen.add(resolved);
storages.push(openConfiguredStorage(resolved));
}
return storages;
}
async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> { async function openConfiguredDb(configPath: string): Promise<OpenDbHandle> {
const config = readConfig(configPath); const config = readConfig(configPath);
if (!config) { if (!config) {
@ -1930,7 +1956,7 @@ async function promptForSourceEndpoint(excludeWorktreePath?: string): Promise<Re
} }
async function applyMergePlan(input: { async function applyMergePlan(input: {
sourceStorage: ConfiguredStorage; sourceStorages: ConfiguredStorage[];
targetStorage: ConfiguredStorage; targetStorage: ConfiguredStorage;
targetDb: ClosableDb; targetDb: ClosableDb;
company: ResolvedMergeCompany; company: ResolvedMergeCompany;
@ -2194,7 +2220,7 @@ async function applyMergePlan(input: {
if (!parentExists) continue; if (!parentExists) continue;
const body = await readSourceAttachmentBody( const body = await readSourceAttachmentBody(
input.sourceStorage, input.sourceStorages,
companyId, companyId,
attachment.source.objectKey, attachment.source.objectKey,
); );
@ -2274,7 +2300,10 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
const scopes = parseWorktreeMergeScopes(opts.scope); const scopes = parseWorktreeMergeScopes(opts.scope);
const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath); const sourceHandle = await openConfiguredDb(sourceEndpoint.configPath);
const targetHandle = await openConfiguredDb(targetEndpoint.configPath); const targetHandle = await openConfiguredDb(targetEndpoint.configPath);
const sourceStorage = openConfiguredStorage(sourceEndpoint.configPath); const sourceStorages = resolveAttachmentLookupStorages({
sourceEndpoint,
targetEndpoint,
});
const targetStorage = openConfiguredStorage(targetEndpoint.configPath); const targetStorage = openConfiguredStorage(targetEndpoint.configPath);
try { try {
@ -2328,7 +2357,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
} }
const applied = await applyMergePlan({ const applied = await applyMergePlan({
sourceStorage, sourceStorages,
targetStorage, targetStorage,
targetDb: targetHandle.db, targetDb: targetHandle.db,
company, company,