fix(cases): never delete another clone's files after a failed direct clone

A direct clone into an empty folder emptied the folder after any failure, which
deleted a concurrent attempt's finished clone when git had written nothing.
Clones into one folder are now mutually exclusive in-process, the cleanup is
skipped when git refused the folder, never ran, or the mount is unresponsive,
and a name linked while the clone ran is a 409 that keeps the folder.
Documents path for POST /api/cases/clone.
This commit is contained in:
Devvyn committed 2026-10-31 17:57:51 +08:00
1 parent 68bd7e4d15
commit 086249a386
7 files changed
+78 -23

No files matched your search

+5 -3
View File
@@ -859,14 +859,16 @@ export function registerCaseRoutes(
// POST /api/cases/link the write is not serialized, so this only narrows the window.
try {
const fresh = await readLinkedCases();
if (fresh[name]) throw new Error(`Case "${name}" was linked while the clone ran`);
if (fresh[name])
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
fresh[name] = casePath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
} catch (err) {
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
reply.code(500);
const conflict = (err as { conflict?: boolean }).conflict;
reply.code(conflict ? 409 : 500);
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
);
}
+1 -1
View File
@@ -786,7 +786,7 @@ export const CloneCaseSchema = z.object({
shallow: z.boolean().optional(),
description: z.string().max(1000).optional(),
/**
* Clone into this exact folder (parent + case name) instead of under the cases directory, and
* Clone into this exact folder (the folder itself, no case-name subfolder is added) instead of under the cases directory, and
* register it as a linked case. Absolute or starting with `~`; only length-bounded here, since
* `prepareNewCasePath()` (web/case-path.ts) judges it and produces the user-facing reason.
*/