Merge #774 fix(clone): treat Clone Repo's custom folder as a parent, like Create New

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:37:14 +01:00
commit cd0bbc3e3a
6 files changed
+180 -16

No files matched your search

+1 -1
View File
@@ -252,7 +252,7 @@ The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLin
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode-and-workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (no case-name subfolder) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Clone a repository as a case** (issue #236, Add Case → **Clone Repo**): `POST /api/cases/clone` clones synchronously into the caller's case space (bounded by `GIT_CLONE_TIMEOUT_MS`, no job store); `POST /api/cases/clone-preflight` checks anonymous cloneability and lists refs. Core in `src/git-clone.ts`. ⚠️ **The URL is a code-execution surface**: refuse every `::` form and a leading `-`, spawn only argv arrays with `--` before operands. ⚠️ Stay non-interactive (`gitNonInteractiveEnv()`) or the open request hangs; never collect credentials, refuse `user:password@` URLs. ⚠️ Timeout kills the process GROUP, remove the destination only if this attempt created it, and repo contents win over scaffolding (existing `CLAUDE.md` kept, hooks merged, repo `.claude/settings*` warned about). ⚠️ A `path` is the folder cloned INTO (the API adds no case-name subfolder; the Clone Repo tab's custom folder is a PARENT, so the page sends `<folder>/<case name>` through `_cloneTargetPath()`, as Create New does through `_newCaseTargetPath()`) and registers a linked case: admin-only in multi-user mode, judged by `prepareNewCasePath()`, empty or new only; an empty one is cloned into directly and never removed, and emptied after a failure only when git really ran and did not refuse it. ⚠️ A destination at or inside a Docker case workspace is refused `400` on both branches: git would run the new repository's hooks and filters on the host. → [architecture-invariants#clone-a-repository-as-a-case](docs/architecture-invariants.md#clone-a-repository-as-a-case)
**Git worktrees of a case** (case picker → **Git worktrees…**; `src/git-worktrees.ts`, `routes/worktree-routes.ts`): a local repository case keeps worktrees at `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
+3 -3
View File
@@ -492,7 +492,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
### Clone a repository as a case
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` is the folder the repository is cloned INTO, exactly as given (the API adds no case-name subfolder; the **Clone into a custom folder** field on the page is a parent, so the tab sends `<folder>/<case name>`, see UI below), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
@@ -503,9 +503,9 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
- **Failure leaves nothing behind.** The destination is removed only when it did not exist before the attempt, and a pre-existing directory is refused rather than cloned into, so a failed clone never squats on a case name and never touches an existing tree.
- ⚠️ **Error detail comes from the LAST diagnostic line, not the first.** `git clone` opens with `Cloning into '<dest>'…`, so a first-line pick reported the destination path as the reason a bad branch failed (observed against a real remote). `NOT_FOUND` wording must also say "or private": GitHub answers "Repository not found" for a private repo and a typo alike when unauthenticated.
- **Multi-user**: NOT admin-gated, unlike `/api/cases/link` — it writes only inside the caller's own `resolveCasesDir`. The exception is a `local`-transport source (an absolute path or `file://`), which is admin-only there because per-user spaces live inside one `$HOME` and a local clone would read straight through that boundary.
- **UI** (`case-clone` tab in the Add Case modal): debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
- **UI** (`case-clone` tab in the Add Case modal): the **Clone into a custom folder** field is a PARENT folder, like Create New's: the tab sends `<folder>/<case name>` as `path` (`_cloneTargetPath()`), so the API's `path` stays the exact target; debounced preflight paints a verdict under the field, fills the case name from the parsed repo (until the user types their own), and turns the branch/tag field into a datalist of the remote's real refs. The **Brain** picker sets the toolbar run mode on success (gated by `isCliAvailable()`, like `#runModeMenu`), so Run already points at the chosen CLI; starting a session stays opt-in. The tab hides itself when the server reports no `git` (injected via `window.__codemanCliAvailable`).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint).
Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a REAL local bare repo for clone/ref/timeout/cleanup), `test/routes/case-clone-routes.test.ts` (deliberately **unmocked fs**, real clone through the endpoint), `test/clone-custom-folder-ui.test.ts` (the tab's parent folder, the request and the preview, against the real markup).
Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`.
+1 -1
View File
@@ -25,7 +25,7 @@ Three ways to get one, all under **New or link a case…** at the bottom of the
| How | Result |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Create New** | A fresh `~/codeman-cases/<name>` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or straight into a folder you choose, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Clone Repo** | A repo cloned into `~/codeman-cases/<name>` (or, with **Clone into a custom folder**, into `<parent folder you choose>/<name>`, which must be empty or not exist yet) and registered as a case. Private repos need this machine's own git credentials (see below). |
| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. |
Linked cases keep living where they are. Deleting a case in Codeman removes the
+3 -3
View File
@@ -3809,15 +3809,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 a folder and the repository is cloned into that folder itself (an empty folder, or one that does not exist yet); 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 instead and the repository is cloned into a subfolder of it named after the case, the same way it goes under ~/codeman-cases; 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>Folder</label>
<label>Parent 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 an empty folder, or one that does not exist yet, to clone into.</span>
<span class="form-hint" id="cloneCasePathPreview">Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.</span>
</div>
<div class="form-row">
<label>Branch or Tag (optional)</label>
+17 -8
View File
@@ -4271,30 +4271,34 @@ Object.assign(CodemanApp.prototype, {
const hint = document.getElementById('cloneCaseNameHint');
if (hint) {
hint.textContent = custom.checked
? 'Filled in from the URL. Names the case only: the repository is cloned into the folder chosen below.'
? 'Filled in from the URL. Names the case and the subfolder of the chosen folder the repository is cloned into.'
: 'Filled in from the URL. Cloned into ~/codeman-cases/<name>, so deleting the case deletes this working tree.';
}
this.updateCloneCasePathPreview();
},
/** The folder the repository is cloned into: exactly what the field holds (no case name is added). */
/** The folder the repository is cloned into: the parent field plus the case name, as Create New does. */
_cloneTargetPath() {
const raw = (document.getElementById('cloneCasePath')?.value || '').trim();
const rawParent = (document.getElementById('cloneCasePath')?.value || '').trim();
const name = (document.getElementById('cloneCaseName')?.value || '').trim();
if (!rawParent || !name) return '';
// Trailing slashes off, but `/` stays the root rather than becoming an empty path.
return raw.length > 1 ? raw.replace(/\/+$/, '') || '/' : raw;
return `${rawParent.replace(/\/+$/, '')}/${name}`;
},
updateCloneCasePathPreview() {
const hint = document.getElementById('cloneCasePathPreview');
if (!hint) return;
const target = this._cloneTargetPath();
hint.textContent = target ? `Will clone into: ${target}` : 'Pick an empty folder, or one that does not exist yet, to clone into.';
hint.textContent = target
? `Will clone into: ${target}`
: 'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.';
},
openCloneCasePathPicker() {
const input = document.getElementById('cloneCasePath');
PathPicker.open({
title: 'Choose the folder to clone into',
title: 'Choose the parent folder for the clone',
initialPath: input.value.trim(),
directoriesOnly: true,
onSelect: (path) => {
@@ -4341,12 +4345,14 @@ 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;
@@ -4401,7 +4407,10 @@ Object.assign(CodemanApp.prototype, {
// The server's suggestion wins over the local guess (it is the same function
// 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;
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) {
@@ -4456,7 +4465,7 @@ Object.assign(CodemanApp.prototype, {
const customFolder = !!document.getElementById('cloneCustomPathToggle')?.checked;
if (customFolder && !(document.getElementById('cloneCasePath')?.value || '').trim()) {
this.showToast('Choose the folder to clone into', 'error');
this.showToast('Choose the parent folder for the clone', 'error');
return;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* @fileoverview Frontend test for Clone Repo's custom folder (session-ui.js `cloneCase()` /
* `_cloneTargetPath()`): the chosen folder is the PARENT, so the clone request carries
* `<folder>/<case name>` exactly as Create New does, and without the checkbox no `path` is sent
* (the server then clones to `~/codeman-cases/<name>`). The "Will clone into" line has to name the
* folder the request will really use, so it follows every way the name or the folder can change:
* typing, the URL, the preflight's suggested name and the Browse picker. Runs against the real
* index.html markup.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
function bootClone() {
const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
const run = (src: string) => (win as unknown as { eval: (s: string) => void }).eval(src);
run('window.CodemanApp = function CodemanApp() {};');
run(CONSTANTS_JS);
run(SESSION_UI_JS);
const app = new win.CodemanApp();
app.showToast = () => {};
app._setCloneStatus = () => {};
app.closeCreateCaseModal = () => {};
app.loadQuickStartCases = async () => {};
app.saveLastUsedCase = async () => {};
const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: {} }) }));
(win as unknown as { fetch: unknown }).fetch = fetchMock;
const doc = win.document;
const set = (id: string, value: string) => ((doc.getElementById(id) as HTMLInputElement).value = value);
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
set('cloneCaseName', 'bifrost');
const preview = () => doc.getElementById('cloneCasePathPreview')?.textContent;
return { app, win, doc, set, preview, fetchMock };
}
async function requestBody(fetchMock: ReturnType<typeof vi.fn>) {
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { body: string }];
return { url, body: JSON.parse(init.body) };
}
describe('Clone Repo custom folder', () => {
it('sends <folder>/<case name> as the path and previews it', async () => {
const { app, doc, set, fetchMock } = bootClone();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454/');
app.updateCloneCasePathPreview();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
'Will clone into: /mnt/user/Scripts/GitHub/opticon454/bifrost'
);
await app.cloneCase();
const { url, body } = await requestBody(fetchMock);
expect(url).toBe('/api/cases/clone');
expect(body.path).toBe('/mnt/user/Scripts/GitHub/opticon454/bifrost');
expect(body.name).toBe('bifrost');
});
it('follows the case name when it is edited', () => {
const { app, doc, set } = bootClone();
set('cloneCasePath', '~/projects');
set('cloneCaseName', 'other');
app.onCloneNameEdited();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: ~/projects/other');
});
it('sends no path without the checkbox, even when the folder field still holds one', async () => {
const { app, set, fetchMock } = bootClone();
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body.path).toBeUndefined();
});
it.each([
['/mnt/projects', '/mnt/projects/bifrost'],
['/mnt/projects///', '/mnt/projects/bifrost'],
[' /mnt/projects/ ', '/mnt/projects/bifrost'],
['/', '/bifrost'],
['~', '~/bifrost'],
['~/', '~/bifrost'],
])('joins the parent %j to the case name', (parent, target) => {
const { app, set } = bootClone();
set('cloneCasePath', parent);
expect(app._cloneTargetPath()).toBe(target);
});
it('follows the name the preflight settles on, which is the one the request will carry', async () => {
const { app, doc, set, preview, fetchMock } = bootClone();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/projects');
// The page's own guess for a URL copied out of a browser tab; the server reads the repo as `repo`.
set('cloneCaseName', 'repo-tab-readme');
app.updateCloneCasePathPreview();
expect(preview()).toBe('Will clone into: /mnt/projects/repo-tab-readme');
app._applyClonePreflight(
{
parse: { cloneable: true, suggestedName: 'repo', provider: 'GitHub', owner: 'owner', repo: 'repo' },
gitAvailable: true,
},
'https://github.com/owner/repo?tab=readme'
);
expect(preview()).toBe('Will clone into: /mnt/projects/repo');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body).toMatchObject({ name: 'repo', path: '/mnt/projects/repo' });
});
it('sends the folder Browse picked as the parent, and titles the picker as the parent', async () => {
const { app, win, doc, preview, fetchMock } = bootClone();
const open = vi.fn();
(win as unknown as { PathPicker: { open: unknown } }).PathPicker = { open };
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
app.openCloneCasePathPicker();
const options = open.mock.calls[0]![0] as { title: string; directoriesOnly: boolean; onSelect(p: string): void };
expect(options.title).toBe('Choose the parent folder for the clone');
expect(options.directoriesOnly).toBe(true);
options.onSelect('/mnt/projects');
expect((doc.getElementById('cloneCasePath') as HTMLInputElement).value).toBe('/mnt/projects');
expect(preview()).toBe('Will clone into: /mnt/projects/bifrost');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body.path).toBe('/mnt/projects/bifrost');
});
it('asks for the parent folder before cloning when the checkbox is on and the field is empty', async () => {
const { app, doc, fetchMock } = bootClone();
app.showToast = vi.fn();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
await app.cloneCase();
expect(app.showToast).toHaveBeenCalledWith('Choose the parent folder for the clone', 'error');
expect(fetchMock).not.toHaveBeenCalled();
});
it('builds no target and sends nothing without a case name', async () => {
const { app, doc, set, preview, fetchMock } = bootClone();
app.showToast = vi.fn();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/projects');
set('cloneCaseName', '');
app.updateCloneCasePathPreview();
expect(app._cloneTargetPath()).toBe('');
expect(preview()).toBe(
'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.'
);
await app.cloneCase();
expect(app.showToast).toHaveBeenCalledWith('Please enter a case name', 'error');
expect(fetchMock).not.toHaveBeenCalled();
});
});