mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
1 parent
68bd7e4d15
commit
086249a386
7 files changed
+78
-23
No files matched your search
+33
-12
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user