diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 9be87e00..262192a5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -474,7 +474,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. +**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 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 URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c ` makes git run an arbitrary command as its transport, and ANY `::` dispatches to a `git-remote-` 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. diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 2d097a7b..fd57b80c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -4291,7 +4291,7 @@ Object.assign(CodemanApp.prototype, { openCloneCasePathPicker() { const input = document.getElementById('cloneCasePath'); PathPicker.open({ - title: 'Choose the folder to clone into', + title: 'Choose the folder to clone inside', initialPath: input.value.trim(), directoriesOnly: true, onSelect: (path) => { @@ -4401,6 +4401,7 @@ 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) { @@ -4455,7 +4456,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 folder to clone inside', 'error'); return; } diff --git a/test/clone-custom-folder-ui.test.ts b/test/clone-custom-folder-ui.test.ts index b763490f..4a441bd7 100644 --- a/test/clone-custom-folder-ui.test.ts +++ b/test/clone-custom-folder-ui.test.ts @@ -72,4 +72,18 @@ describe('Clone Repo custom folder', () => { const { body } = await requestBody(fetchMock); expect(body.path).toBeUndefined(); }); + + it('refreshes the preview when the preflight replaces the case name', () => { + const { app, doc, set } = bootClone(); + set('cloneRepoUrl', 'https://github.com/opticon454/bifrost?tab=readme-ov-file'); + set('cloneCaseName', 'bifrost-tab-readme-ov-file'); + set('cloneCasePath', '/mnt/projects'); + app.updateCloneCasePathPreview(); + expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe( + 'Will clone into: /mnt/projects/bifrost-tab-readme-ov-file' + ); + app._applyClonePreflight({ gitAvailable: true, parse: { cloneable: true, suggestedName: 'bifrost' } }, ''); + expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost'); + expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost'); + }); });