fix(cases): clone into the chosen folder itself, not a subfolder of it

The custom folder was treated as a parent, so the repository landed in <folder>/<case name>. The chosen folder is now the clone destination: an empty existing folder is cloned into directly (CloneOptions.intoEmptyDir, no temporary sibling, never removed on failure), a new folder is created, and a folder with files is refused.
This commit is contained in:
Devvyn committed 2026-10-31 16:43:11 +08:00
1 parent 0bd15ddc84
commit 68bd7e4d15
8 files changed
+93 -43

No files matched your search

+39 -6
View File
@@ -50,7 +50,7 @@
import { spawn, execFileSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { rename, rm } from 'node:fs/promises';
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';
@@ -188,8 +188,13 @@ export interface GitFailure {
export interface CloneOptions {
/** Pre-validated operand from `parseGitRepositoryUrl`. */
repository: string;
/** Absolute destination directory. Must NOT exist; created by git. */
/** Absolute destination directory. Must NOT exist (created by git), unless `intoEmptyDir` is set. */
destination: string;
/**
* Clone straight into `destination` when it already exists as an EMPTY directory (the folder a user
* picked), instead of refusing it. A folder with anything in it, or a symlink, is still refused.
*/
intoEmptyDir?: boolean;
/** Optional branch or tag (`--branch <ref> --single-branch`). */
ref?: string;
/** `--depth 1`: history-less but much faster on large repos. */
@@ -847,6 +852,25 @@ export async function probeGitRemote(
};
}
/** 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 {
const st = await lstat(path);
return st.isDirectory() && (await readdir(path)).length === 0;
} catch {
return false;
}
}
/** Remove everything inside `path`, keeping `path` itself. Best effort. */
async function emptyDirectory(path: string): Promise<void> {
try {
for (const entry of await readdir(path)) await rm(join(path, entry), { recursive: true, force: true });
} catch {
/* nothing to clean, or it vanished */
}
}
/**
* Clone `repository` into `destination`.
*
@@ -872,10 +896,19 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>
};
}
if (existsSync(opts.destination)) {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
if (!opts.intoEmptyDir || !(await isEmptyDirectory(opts.destination))) {
return {
ok: false,
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
};
}
// 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.
+3 -3
View File
@@ -3658,15 +3658,15 @@
</div>
<div class="form-row" id="cloneCustomPathToggleRow">
<label class="checkbox-row"><input type="checkbox" id="cloneCustomPathToggle" onchange="app.toggleCloneCustomPath()"> 📁 Clone into a custom folder</label>
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose another folder and it is cloned there instead; it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
<span class="form-hint">By default the clone goes under ~/codeman-cases. Choose a folder and the repository is cloned into that folder itself (it must be empty); it is listed like any other case, and deleting the case only unlinks it (your files stay).</span>
</div>
<div class="form-row" id="cloneCustomPathRow" style="display:none">
<label>Parent Folder</label>
<label>Folder</label>
<div class="path-input-group">
<input type="text" id="cloneCasePath" placeholder="~/projects" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" oninput="app.updateCloneCasePathPreview()">
<button type="button" class="btn path-input-browse" onclick="app.openCloneCasePathPicker()">Browse&hellip;</button>
</div>
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the clone should be created inside.</span>
<span class="form-hint" id="cloneCasePathPreview">Pick the empty folder to clone into.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
+5 -10
View File
@@ -4142,26 +4142,24 @@ Object.assign(CodemanApp.prototype, {
const hint = document.getElementById('cloneCaseNameHint');
if (hint) {
hint.textContent = custom.checked
? 'Filled in from the URL. Cloned into a folder of this name inside the parent folder below.'
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
}
this.updateCloneCasePathPreview();
},
/** The folder the clone would be created in: the parent field plus the case name. */
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
_cloneTargetPath() {
const rawParent = (document.getElementById('cloneCasePath')?.value || '').trim();
const name = (document.getElementById('cloneCaseName')?.value || '').trim();
if (!rawParent || !name) return '';
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
return `${rawParent.replace(/\/+$/, '')}/${name}`;
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
},
updateCloneCasePathPreview() {
const hint = document.getElementById('cloneCasePathPreview');
if (!hint) return;
const target = this._cloneTargetPath();
hint.textContent = target ? `Will clone into: ${target}` : 'Pick the folder the clone should be created inside.';
hint.textContent = target ? `Will clone into: ${target}` : 'Pick the empty folder to clone into.';
},
openCloneCasePathPicker() {
@@ -4214,14 +4212,12 @@ Object.assign(CodemanApp.prototype, {
onCloneNameEdited() {
// Once the user types a name, autofill stops fighting them.
this._cloneNameEdited = !!document.getElementById('cloneCaseName')?.value.trim();
this.updateCloneCasePathPreview();
},
onCloneUrlInput() {
const url = document.getElementById('cloneRepoUrl')?.value.trim() || '';
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited) nameInput.value = this._repoNameFromUrl(url);
this.updateCloneCasePathPreview();
clearTimeout(this._clonePreflightTimer);
this._clonePreflightAbort?.abort();
this._clonePreflightAbort = null;
@@ -4277,7 +4273,6 @@ Object.assign(CodemanApp.prototype, {
// the case name is validated against), but never over a name the user typed.
const nameInput = document.getElementById('cloneCaseName');
if (nameInput && !this._cloneNameEdited && parse.suggestedName) nameInput.value = parse.suggestedName;
this.updateCloneCasePathPreview();
const where = parse.owner ? `${parse.provider} ${parse.owner}/${parse.repo}` : `${parse.provider} ${parse.repo}`;
if (data.gitAvailable === false) {
+8 -11
View File
@@ -714,6 +714,7 @@ export function registerCaseRoutes(
const casesDir = resolveCasesDir(user);
let casePath: string | null;
let intoEmptyDir = false;
if (customPath !== undefined) {
const prepared = await prepareNewCasePath(customPath, {
home: homedir(),
@@ -738,18 +739,12 @@ export function registerCaseRoutes(
: prepared.code === 'EXISTS'
? ApiErrorCode.ALREADY_EXISTS
: ApiErrorCode.INVALID_INPUT,
prepared.reason
);
}
// git clones into a fresh folder it creates itself; an empty existing one is refused rather than
// removed and re-made (the removal could race another writer).
if (prepared.existedEmpty) {
reply.code(409);
return createErrorResponse(
ApiErrorCode.ALREADY_EXISTS,
`${prepared.path} already exists. Pick a parent folder where the case folder does not exist yet.`
prepared.code === 'EXISTS'
? `${customPath} already has files in it. Pick an empty folder, or a folder that does not exist yet.`
: prepared.reason
);
}
intoEmptyDir = prepared.existedEmpty;
casePath = prepared.path;
// The same folder listed as a local case in the cases dir would make deleting it remove files.
if (existsSync(join(casesDir, name))) {
@@ -776,7 +771,8 @@ export function registerCaseRoutes(
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `That folder is already the case "${linkedAs}"`);
}
if (
existsSync(casePath) ||
// A custom folder was judged by prepareNewCasePath (an existing empty one is fine).
(customPath === undefined && existsSync(casePath)) ||
linkedCases[name] ||
dockerCases.some((item) => item.name === name) ||
remoteCases.some((item) => item.name === name)
@@ -797,6 +793,7 @@ export function registerCaseRoutes(
const clone = await cloneRepository({
repository: parsed.repository,
destination: casePath,
...(intoEmptyDir ? { intoEmptyDir: true } : {}),
withoutCredentialHelpers: cloneWithoutCredentialHelpers(req),
...(ref ? { ref } : {}),
...(shallow ? { shallow: true } : {}),