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

+33 -12
View File
@@ -53,6 +53,7 @@ import { existsSync } from 'node:fs';
import { lstat, readdir, rename, rm } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { probePathKind } from './utils/bounded-path-probe.js';
// ─── Tunables ────────────────────────────────────────────────────────────────
@@ -852,6 +853,16 @@ export async function probeGitRemote(
};
}
/** Destinations with a direct (`intoEmptyDir`) clone in flight. */
const directClones = new Set<string>();
function destinationExists(): CloneResult {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
}
/** True for a real (not symlinked) directory with nothing in it. Any read error counts as not empty. */
async function isEmptyDirectory(path: string): Promise<boolean> {
try {
@@ -896,19 +907,29 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>
};
}
if (existsSync(opts.destination)) {
if (!opts.intoEmptyDir || !(await isEmptyDirectory(opts.destination))) {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
// One direct clone per folder at a time: once two gits have written into the same folder their
// files cannot be told apart, so the second caller is refused before it spawns anything. Taken
// before the emptiness check (which can precede a long wait for a git slot).
if (!opts.intoEmptyDir || directClones.has(opts.destination)) return destinationExists();
directClones.add(opts.destination);
try {
if (!(await isEmptyDirectory(opts.destination))) return destinationExists();
// The folder is the user's own, so there is no temporary sibling to rename into place: git fills it.
const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) };
const failure = classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError);
// The folder was empty and is ours alone, so what is in it is this attempt's partial tree, but only
// if git wrote anything: not when it refused the folder or never ran (slot timeout, full queue).
// A mount that stopped answering is left alone too, so a libuv worker is not held on it.
if (failure.code !== 'DESTINATION_EXISTS' && !direct.spawnError) {
if ((await probePathKind(opts.destination, { pastCap: true })) === 'directory') {
await emptyDirectory(opts.destination);
}
}
return { ok: false, failure };
} finally {
directClones.delete(opts.destination);
}
// The folder is the user's own, so there is no temporary sibling to rename into place: git fills it.
const direct = await runGit(buildCloneArgs(opts), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
if (direct.code === 0 && !direct.spawnError) return { ok: true, stderr: sanitizeGitOutput(direct.stderr) };
// It was empty when the clone began, so whatever is in it now is this attempt's partial tree. The
// folder itself stays.
await emptyDirectory(opts.destination);
return { ok: false, failure: classifyGitFailure(direct.stderr, direct.timedOut, direct.spawnError) };
}
// Sibling of the destination (same filesystem), so the rename is atomic.
+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.
*/