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

+31 -1
View File
@@ -24,7 +24,7 @@ import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { isGitAvailable } from '../../src/git-clone.js';
import { cloneRepository, isGitAvailable } from '../../src/git-clone.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
@@ -142,6 +142,36 @@ describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder',
expect(linked()).toEqual({});
});
it('two clones into one empty folder at once: exactly one wins and its files survive', async () => {
const target = join(workDir, 'raced');
mkdirSync(target);
const results = await Promise.all([
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
const loser = results.find((r) => !r.ok);
expect(loser && !loser.ok && loser.failure.code).toBe('DESTINATION_EXISTS');
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
});
it('a name linked while the clone ran is a 409 that keeps the folder and names it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-a') }),
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-b') }),
]);
const loserDir = a.json().success ? join(workDir, 'twin-b') : join(workDir, 'twin-a');
const loser = a.json().success ? b : a;
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
expect(loser.statusCode).toBe(409);
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
expect(loser.json().error).toContain(loserDir);
expect(existsSync(join(loserDir, 'README.md'))).toBe(true);
expect(Object.keys(linked())).toEqual(['twin']);
});
it('refuses a blocked tree such as ~/.ssh', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(homedir(), '.ssh'), { recursive: true });