diff --git a/CLAUDE.md b/CLAUDE.md index adb384009..5b362aca2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **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). → [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 `/.claude/worktrees/` on `worktree-`, 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) + **Cross-session search**: `GET /api/search` federates an in-memory search over session metadata, run-summary events, and attachment-history entries. The pure core `searchSources()` does substring matching with hard per-type caps: **no regex (so no ReDoS) and no filesystem reads (so no traversal)**. The server-private `externalPath` is never read. PAST sessions (#261) come from `session-history-index.ts`, a capped snapshot of the unified list filled **outside** the request path (`/api/sessions/unified` publishes it; a stale one is rebuilt fire-and-forget), that indirection is what keeps the no-fs property. ⚠️ The snapshot is stored UNSCOPED with a per-row owner and MUST be re-filtered through `canAccessOwned()` on read; history rows carry `jumpTo.kind:'resume-session'`, since a closed session has no tab to select. → [architecture-invariants#cross-session-search](docs/architecture-invariants.md#cross-session-search) **Web tabs** (dashboard URLs as tabs): a saved URL renders as a tab beside sessions, **NOT a `SessionMode`**, and is proxied through Codeman's own origin (`/webview//...`). ⚠️ The proxy is NOT an API surface: its capability-based auth exemption stays fenced to safe methods and non-route paths (`test/webview-auth-exemption.test.ts`). ⚠️ Iframes omit `allow-same-origin` unless `trusted`, and `Authorization`/`codeman_session` are stripped upstream in both modes. ⚠️ **Egress guard**: link-local and cloud-metadata targets are refused at save time, by a sync hostname check at each connect (IP literals skip DNS), AND on the resolved address (`webview-egress.ts`); use the `undici` package's own `fetch` + `Agent`, never Node's global fetch. ⚠️ Loopback links in agent output auto-open as proxied web tabs (`openLinkThroughWebTabIfLoopback`), but never auto-route `*.localhost` (prompt-injectable DNS). Capabilities are revoked on logout (`revokeOwner`). → [architecture-invariants#web-tabs](docs/architecture-invariants.md#web-tabs), `docs/web-tabs.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index db7ac4d27..e572ee086 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1011,6 +1011,42 @@ Both routes accept two optional query parameters, which the UI sends from its pe Lists are capped (300 files and 50 commits per repository) while the counts stay exact. A branch with no upstream reports the commits no remote has (`HEAD --not --remotes`); a repository with no remote reports `unpushedCount: 0`, since there is nothing to push to. Concurrent polls of one folder share a single git invocation; `?fresh=1` (what the panel's Refresh button and opening the panel send) skips the short-lived caches, though it still joins a computation already running. +## Git worktrees of a case + +A local case whose folder is the top of a git repository can keep **worktrees** at `/.claude/worktrees/`, each on its own branch, so several agents on one repository never share a checkout. This is the layout Claude Code's own `claude --worktree ` uses (branch `worktree-`), so either tool finds the other's worktrees. Remote (SSH) and Docker cases are refused with `400 INVALID_INPUT` before git runs, and so is a local case whose folder is at or inside a Docker case workspace: `git worktree add` runs the repository's post-checkout hook and checkout filters on this machine, and a container can write there. A case folder that is not a repository, is a bare repository, or sits below the repository's top level is `400` too. The case resolves the way quick-start resolves it (a linked case is admin-only in multi-user mode, everything else is the caller's own case space; another user's case is `404`), and a case folder on a mount that does not answer is `422 OPERATION_FAILED`, never `404`. + +`GET /api/cases/:name/worktrees` lists `git worktree list` for the case's repository: + +```json +{ + "caseName": "myrepo", + "repoRoot": "/home/me/codeman-cases/myrepo", + "worktreesDir": "/home/me/codeman-cases/myrepo/.claude/worktrees", + "truncated": false, + "worktrees": [ + { "path": "/home/me/codeman-cases/myrepo", "head": "6910cb15…", "branch": "main", "detached": false, "bare": false, + "locked": false, "prunable": false, "main": true, "isCase": true, "managed": false, "sessionCount": 0, "sessions": [] }, + { "path": "/home/me/codeman-cases/myrepo/.claude/worktrees/fix-login", "branch": "worktree-fix-login", + "main": false, "isCase": false, "managed": true, "name": "fix-login", + "sessionCount": 1, "sessions": [{ "id": "2a568379-…", "name": "w1-fix-login" }] } + ] +} +``` + +`managed` is `true` only for `/.claude/worktrees/`: those are the ones Codeman starts sessions in and removes; any other worktree of the repository is listed for information. `sessionCount` counts live sessions of every owner running in that worktree (a session belongs to the deepest worktree holding its folder), `sessions` lists the ones the caller may see. `lockReason`/`prunableReason` appear when git reports them. + +`POST /api/cases/:name/worktrees` with `{ name, branch?, base? }` creates one (`201`, `{ worktree: { name, path, branch, base, head, excludeAdded, warnings } }`): + +- `name`: 1 to 64 characters of `A-Z a-z 0-9 . _ -`, starting with a letter or digit, no `..`, not ending in `.` or `.lock`. `400` otherwise. +- `branch`: the NEW branch, default `worktree-`. Letters, digits, `.`, `_`, `-`, `/`, then `git check-ref-format --branch` must accept it unchanged. An existing branch is `409 ALREADY_EXISTS` (never reset), and so is a name whose folder already exists. +- `base`: where the branch starts, default `HEAD` (the case's current commit): a branch, tag or commit, resolved before anything is written (`400` when it is not a commit, which includes an empty repository). +- `excludeAdded` lists the lines appended to the repository's local `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git did not already ignore them: `/.claude/worktrees/`, so the main checkout does not show the worktrees as untracked, `/.claude/settings.local.json`, the hooks file Codeman writes for every claude session, which would otherwise make `git worktree remove` refuse, and the agent skill copy (`/.claude/skills/codeman/`, `/.agents/skills/codeman/`: every skills folder the CLI registry names) that sessions get while `agentSkillEnabled` is on, for the same reason. +- A symlinked `.claude` or `.claude/worktrees` directory is refused (`400`): a repository cannot redirect the worktree outside itself. + +`DELETE /api/cases/:name/worktrees/:worktree[?deleteBranch=true]` removes a managed worktree with `git worktree remove` **without `--force`**: git refuses a worktree with modified or untracked files (or a locked one), and that refusal is the answer, `409 CONFLICT` with git's own words in `error`. A live session at or inside the worktree is `409` as well. With `deleteBranch=true` the branch is deleted with `git branch -d`, never `-D`, so an unmerged branch is kept: the response is still `200`, with `branchDeleted: false` and git's reason in `branchError`. Response: `{ removed: { name, path, branch, branchDeleted, branchError? } }`. A folder git does not list as a worktree of this repository, or a symlink in its place, is `404`/`400`. A managed worktree whose folder is gone (deleted by hand; the list reports it `prunable`) is still removable: git then only drops its record, which is what frees the name for a new worktree. A session never starts in one. + +**Starting a session in a worktree** goes through the normal create paths: `POST /api/quick-start` takes `worktree: ""` next to `caseName` and runs the session (and the hooks, the agent skill and `modelOverride`) in the worktree folder; the response adds `worktree` and `workingDir`. It is refused for remote and Docker cases (`400`), for a worktree that is not a registered, managed one (`404`), and for a case that does not exist (`404`: a worktree launch never scaffolds a case). `POST /api/sessions` takes the worktree path as `workingDir` like any other folder. + ## CLI management Read and write the CLI registry (`docs/cli-registry.md`). Every **write** route answers `403 FORBIDDEN` while `cliManagementEnabled` is off (the default), and for a non-admin in multi-user mode. A write that would overwrite a `clis.json` which does not parse, or which has group/world permission bits, is refused with `409 CONFLICT` and a message naming the fix; the file is left untouched. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf5..c735daa32 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -448,6 +448,20 @@ Tests: `test/git-clone.test.ts` (pure half exhaustively, plus REAL git against a Further detail: the synchronous clone request is bounded by `GIT_CLONE_TIMEOUT_MS`. +### Git worktrees of a case + +**Git worktrees** (case picker → **Git worktrees…**, the phone case sheet's **Worktrees**, Close Session's **Kill & remove worktree**): a LOCAL case whose folder is the top of a git repository keeps worktrees at `/.claude/worktrees/` on a new branch (default `worktree-`), so parallel agents each get their own checkout instead of sharing the case folder. That is Claude Code's own `--worktree` layout (measured in 2.1.296: same path, branch `worktree-`, names of `[A-Za-z0-9._-]`, at most 64), and Codeman's names are a strict subset of Claude's. Core in `src/git-worktrees.ts` (pure name/branch gates, `worktree list --porcelain` parsing, exclude text; IO behind an injectable runner); routes in `routes/worktree-routes.ts` (`GET`/`POST /api/cases/:name/worktrees`, `DELETE /api/cases/:name/worktrees/:worktree`). + +- ⚠️ **Only `/.claude/worktrees/` is ever created or removed.** Every component is `lstat`ed (a symlinked `.claude`, `worktrees` or worktree folder is refused, never followed) and the real path must equal the lexical one, so a repository cannot redirect a create or remove outside itself; removal also requires git to list the folder as a worktree of THIS repository. A worktree anywhere else is listed read-only (`managed: false`). A managed worktree whose folder is GONE stays removable (`resolveManagedWorktree({ allowMissing })`, removal only, never a session start: git lists it at exactly the managed path, no component on the way is a symlink, and `git worktree remove` then only drops git's record), since otherwise its name could never be used again. +- ⚠️ **git never runs for a case at or inside a Docker case workspace** (or whose common git dir is there), and Docker and remote cases are refused with a 400 before any git, the same rule as the git status indicator: `git worktree add` runs the repository's post-checkout hook and checkout filters ON THE HOST. That is acceptable for a local case, where the user's agent already runs code. +- ⚠️ **Removal is `git worktree remove` WITHOUT `--force`**, refused (409) while any live session (any owner) runs at or inside the worktree, and git's own refusal (modified or untracked files, a lock) is the answer, shown through `textContent`. The optional branch delete is `git branch -d`, never `-D`: an unmerged branch survives and the response says why (`branchError`). +- ⚠️ **The main checkout must not see the worktrees.** When `git check-ignore` says they are not ignored, `/.claude/worktrees/` is appended to the repository's LOCAL `/info/exclude` (never a tracked `.gitignore`), and so is `/.claude/settings.local.json`: Codeman writes its hooks there for every claude session, and an untracked copy in a worktree would make `git worktree remove` refuse forever (Claude Code covers that file with the global excludes file, which a machine may not have). The agent skill copy (`/codeman/`, injected while `agentSkillEnabled` is on) gets a line too, for every skills folder the registry names (`capabilities.agentSkillDir`, passed in by the route as data). A symlinked `info` dir or `exclude` file is reported, never written through. +- **Names are gated before git**: charset and length (Zod and `worktreeNameError()`), then `git check-ref-format --branch`, whose output must EQUAL the input (`--branch` expands `@{-1}`, which the charset gate already refuses). The base is a resolved commit (`rev-parse --verify ^{commit}`), passed after `--` with `--no-track`; an existing branch is refused (`-b`, never `-B`, which would reset it). Create and remove are serialized per repository. +- **Sessions start through the EXISTING create paths**, never a new spawn path: `POST /api/quick-start` with `worktree` (resolved with the same guards; hooks, the agent skill, the model override and the session all target the worktree, and a case that does not exist is a 404, never a scaffold), or `POST /api/sessions` with the worktree as `workingDir` (what the Run button uses for claude and shell). The frontend launches through `run({ worktree })` with a temporary `_runMode` swap, tabs named `w-`. +- The unified session list names a live session's worktree from its folder layout (`worktreeFromPath`), so a codex or shell session gets the worktree pill before (or without) a transcript record. + +Tests: `test/git-worktrees.test.ts` (pure half, then REAL git: create, exclude, symlink guards, dirty/live/locked refusals, `branch -d`), `test/routes/worktree-routes.test.ts` (real repository through the routes and quick-start, Docker/remote/ownership refusals), `test/worktree-ui.test.ts`, `test/worktree-modal-i18n.test.ts`. + ### Ultracode and workflow-run visualization **Ultracode / Workflow-run visualization** (opt-in `showUltracodeAgents`, default OFF; released 1.1.2): the Workflow tool ("ultracode") writes a COMPLETION artifact per run at `~/.claude/projects///workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_/` (journal.jsonl + `agent-*.jsonl`). `workflow-run-watcher.ts` (STANDALONE — deliberately never imports/touches `subagent-watcher.ts`; separate singleton, though it independently reads the same `subagents/workflows/` tree) scans BOTH sources via periodic poll + per-directory chokidar watchers with per-source mtime skip (LRU agentStatCache + journalCache), synthesizing ACTIVE runs (live per-agent tokens/tools/state from transcripts, title/phases from the workflow script) until the completion `wf_*.json` appears and supersedes, and broadcasts SSE `workflow:run_discovered`/`run_updated`/`run_removed`. The watcher is started when **either** `showUltracodeAgents` **or** `ultracodeFloatingWindows` is on (`server.ts` `isWorkflowAgentTrackingEnabled()` returns `(showUltracodeAgents ?? false) || (ultracodeFloatingWindows ?? false)`). Served via `GET /api/workflows` (optional `?minutes=` filter) and `GET /api/workflows/:runId`. Frontend `ultracode-panel.js` renders a docked master-detail view (LEFT: runs + phases; RIGHT: per-agent tokens + tool-calls; click an agent card → its live transcript via client-side `agentId` join). **Additionally**, `ultracode-windows.js` auto-pops a draggable **floating window per active run** (gated on a **DEDICATED** `ultracodeFloatingWindows` toggle, default OFF — independent of the dock panel's `showUltracodeAgents`; see `_ultracodeFloatingEnabled()`), connected by a glowing line to the originating session tab (resolved by `session.claudeSessionId === run.sessionUuid`) — same line idiom as subagent windows, drawn into the shared `#connectionLines` SVG from the tail of `_updateConnectionLinesImmediate`. The window auto-closes ~8s after its run finishes; explicit dismissals are remembered. Clicking an agent card opens an **in-page** connected transcript window (not a browser popup); both run and transcript windows minimize **into** the originating session tab as a merged `ULTRA` badge (🧬 runs / 📄 transcripts) with a restore/dismiss dropdown — minimized runs are skipped by auto-pop. Gesture beta: floating subagent/ultracode windows are pinch-draggable (a `window` grab kind in `entry.ts`). Types: `src/types/workflow-run.ts`. Config: `src/config/workflow-config.ts`. diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 7f1a6404c..45d9bb3ad 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -46,7 +46,8 @@ case. Each has a stable id, and that id is what the API, the wait primitives, an event use. Several sessions can share one case. That is the normal way to parallelize: three workers -in the same repo, three tabs, one case. +in the same repo, three tabs, one case. They then also share one folder; when the workers +edit and commit in parallel, give each its own checkout with [Git Worktrees](Git-Worktrees). A session carries state the case does not: diff --git a/docs/wiki/Git-Worktrees.md b/docs/wiki/Git-Worktrees.md new file mode 100644 index 000000000..aeeecc745 --- /dev/null +++ b/docs/wiki/Git-Worktrees.md @@ -0,0 +1,71 @@ +# Git Worktrees + +Sessions in one case share its folder. That is fine for one agent, but three agents in the +same checkout edit the same files, overwrite each other's builds, and commit each other's +half-finished work. A **git worktree** gives each of them a checkout of its own: same +repository, same history, a separate folder on a separate branch. + +Codeman creates worktrees for a case whose folder is a git repository, starts sessions in +them, and removes them again when the work is done. + +## Where they live + +`/.claude/worktrees/`, on a new branch `worktree-`. That is exactly where +Claude Code's own `claude --worktree ` puts them, so a worktree made in Codeman is one +Claude Code recognizes, and the other way round. Codeman's Recent Sessions and Session Manager +lists mark a session that runs in one with a `⑂ ` pill. + +The main checkout does not show the worktrees as untracked: Codeman adds `/.claude/worktrees/` +(and `/.claude/settings.local.json`, the hooks file it writes for every Claude session, plus the +`codeman` folder it writes when **Codeman Agent Skill** is on) to the repository's local +`.git/info/exclude` when git does not ignore them already. Without that, those files would make +every worktree look modified and git would refuse to remove it. Your tracked `.gitignore` is +never touched. + +## Creating one and starting a session in it + +1. Pick the case in the toolbar's case picker. +2. Open the picker again and choose **Git worktrees…** (on a phone: the case sheet's + **Worktrees** button). +3. Under **New worktree**, type a name (letters, digits, `.`, `_`, `-`, up to 64 characters). +4. Pick the CLI under **Start sessions with** (Claude Code, Codex, a shell, any installed run + mode). +5. **Create & start session**. + +The worktree is created from the case's current commit, and the session opens in it as a tab +named `w1-`. Under **Branch and start point** you can name the branch yourself or start +from another branch, tag or commit. An existing branch is never reused or reset: pick another +name. + +**Open** on a worktree's row starts another session in it, with the CLI chosen above. + +## Removing one + +Two ways, and both use `git worktree remove` **without** `--force`: + +- **Remove…** on the worktree's row in the dialog. +- Closing a session that runs in a worktree: the Close Session dialog offers **Kill & remove + worktree**, which kills the session and then removes its worktree. + +git refuses to remove a worktree that still holds uncommitted or untracked files, and Codeman +shows that refusal as git worded it and leaves everything in place. Commit, stash or delete the +work first, then remove again. A worktree a live session still runs in is refused too. + +**Also delete its branch** deletes the branch with `git branch -d`, which keeps a branch whose +commits are not merged yet. The worktree is still removed, and the message says the branch was +kept. + +If a worktree's folder was deleted by hand, its row says **missing** and offers only +**Remove…**: that clears the record git keeps of it, so the name can be used for a new worktree. + +## What is not supported + +- **Docker cases and remote (SSH) cases.** A container can write to its workspace, and creating + a worktree runs the repository's hooks and checkout filters on the Codeman machine, so git is + never run there. A remote case's repository is on another machine. +- A case folder that is not the top of a repository (a subfolder of one), a bare repository, or + an empty repository with no commit yet. +- Worktrees outside `.claude/worktrees/` (made by hand with `git worktree add`) are listed, but + Codeman neither starts sessions in them from the dialog nor removes them. + +The HTTP endpoints are in the [HTTP API](HTTP-API) reference (`/api/cases/:name/worktrees`). diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index b4fb3cae7..cbcda8fc1 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain: | ------------------- | -------- | --------------------------------------------------- | | System | 56 | Status, settings, digest, updates, tunnel. | | Sessions | 34 | Create, input, terminal, wait, last response, kill. | -| Cases | 34 | Create, link, clone, remote and docker cases. | +| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. | | Files | 17 | Preview, edit, raw, attachments, path picker. | | Orchestrator | 10 | Plans and phases. | | Ralph | 9 | Loop control and configuration. | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 7be487710..8a82a08c0 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -66,6 +66,7 @@ codeman web # then open http://localhost:3000 | [Agent CLIs](Agent-CLIs) | Which agent should this session run, and how do I set it up? | | [Custom Model Endpoints](Custom-Model-Endpoints) | How do I point a session at my own OpenAI-compatible endpoint? | | [Working With Files](Working-With-Files) | How do I read, edit, and attach files? | +| [Git Worktrees](Git-Worktrees) | How do several agents work on one repository without sharing a folder? | | [Input And Voice](Input-And-Voice) | How do I talk to an agent, including by voice? | | [Mobile Guide](Mobile-Guide) | How well does this work on a phone? | | [Keyboard Shortcuts](Keyboard-Shortcuts) | What can I drive from the keyboard? | diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 73662db4d..c6e67b87e 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -15,6 +15,7 @@ - [Agent CLIs](Agent-CLIs) - [Custom Model Endpoints](Custom-Model-Endpoints) - [Working With Files](Working-With-Files) +- [Git Worktrees](Git-Worktrees) - [Input And Voice](Input-And-Voice) - [Mobile Guide](Mobile-Guide) - [Keyboard Shortcuts](Keyboard-Shortcuts) diff --git a/src/git-worktrees.ts b/src/git-worktrees.ts new file mode 100644 index 000000000..d839429fc --- /dev/null +++ b/src/git-worktrees.ts @@ -0,0 +1,937 @@ +/** + * @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a + * repository-backed case keeps under `/.claude/worktrees/`, so several agents working + * on one repository each get their own working tree instead of sharing the case folder (sessions + * in one case share its folder, and two agents editing, building and committing in one checkout + * step on each other). Routes: `src/web/routes/worktree-routes.ts`; a session starts in one + * through the existing create paths (`POST /api/quick-start` with `worktree`, or + * `POST /api/sessions` with the worktree as `workingDir`). + * + * WHERE. `/.claude/worktrees/` on a new branch `worktree-` is Claude Code's own + * `--worktree` layout (measured in Claude Code 2.1.296: path `/.claude/worktrees/`, + * branch `worktree-`, names of letters, digits, `.`, `_`, `-`, at most 64 characters), and + * Codeman already recognizes it (the worktree pill, `CLAUDE_WORKTREE_PATH` in session-routes.ts). + * Names here are a strict subset of Claude's (one segment, starting with a letter or digit), so a + * worktree made here is one `claude --worktree ` would also find. The main checkout must not + * show the worktrees as untracked, so `/.claude/worktrees/` is appended to the repository's LOCAL + * `info/exclude` (in the real common git dir, never a tracked `.gitignore`) when git does not + * already ignore it. `/.claude/settings.local.json` gets the same treatment: Codeman installs its + * hooks there for every claude session, and an untracked copy in a worktree makes + * `git worktree remove` refuse forever (Claude Code itself ignores that file through the global + * excludes file, which is not guaranteed to be set up on a given machine). The copy of Codeman's + * agent skill (`/codeman/`, injected while `agentSkillEnabled` is on) is excluded for + * the same reason, for every skills folder the registry names (`WorktreeOptions.agentSkillDirs`). + * + * Split like the other git modules: a PURE half (name and branch validation, `worktree list` + * parsing, exclude text, the managed-path rule) unit-tested without git, and an IO half that + * spawns git through an injectable runner. + * + * Rules the code keeps and the tests pin: + * - git is spawned with an argv array, never a shell, with `--` before every path operand, the + * non-interactive environment of `gitNonInteractiveEnv()`, a timeout that kills the whole + * process group, and capped output. Read-only calls add `--no-optional-locks`. + * - Names and branches are validated BEFORE they reach git (charset, length, then + * `git check-ref-format --branch`, whose answer must equal the input: `@{-1}` style shorthands + * are refused up front, since `--branch` would expand them). + * - Only `/.claude/worktrees/` is ever created or removed. Every component of + * that path is `lstat`ed (a symlinked `.claude` or `worktrees` directory, or a symlink at the + * worktree itself, is refused) and its real path must be exactly the expected one, so a + * repository cannot redirect a create or a remove outside itself. What is left is a same-user + * race (an agent swapping a directory between the check and git), which is accepted: that + * agent already runs code as this user. + * - Removal is `git worktree remove` WITHOUT `--force` (git refuses a tree with modified or + * untracked files, and that refusal is the answer), refused while a live session runs inside + * the worktree, and the optional branch delete is `git branch -d` (never `-D`, so an unmerged + * branch survives). + * - git never runs for a case at or inside a Docker case workspace (or whose common git dir is + * there): a container could plant a checkout filter or a hook, and `git worktree add` runs the + * repository's post-checkout hook and filters on the HOST. On a local case that is acceptable, + * since the user's agent runs code there anyway. + * - A repository that merely encloses the case and is the home folder or above (a dotfiles repo + * in `$HOME`) is not the case's repository, and a case folder below a repository's top level + * is refused: the worktrees would not be where the case is. + * + * @module git-worktrees + */ + +import { spawn } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, resolve, sep } from 'node:path'; +import { gitNonInteractiveEnv, isSafeGitRef, sanitizeGitOutput } from './git-clone.js'; +import { isInsideAny, isUnrelatedAncestor } from './git-workspace-status.js'; + +// ─── Limits ────────────────────────────────────────────────────────────────── + +/** Claude Code's own limit for a `--worktree` name (2.1.296). */ +export const MAX_WORKTREE_NAME_LENGTH = 64; +/** Longest accepted branch name. git allows more; 200 covers every real one. */ +export const MAX_WORKTREE_BRANCH_LENGTH = 200; +/** A read-only git call (rev-parse, worktree list, check-ignore, check-ref-format). */ +export const WORKTREE_READ_TIMEOUT_MS = 15_000; +/** + * `git worktree add` checks out a whole tree and runs the repository's post-checkout hook and + * smudge filters (Git LFS downloads objects there), so it gets the longest budget. + */ +export const WORKTREE_ADD_TIMEOUT_MS = 180_000; +/** `git worktree remove` runs a status of the tree first, then deletes it. */ +export const WORKTREE_REMOVE_TIMEOUT_MS = 60_000; +/** Captured stdout. `worktree list` is one short block per worktree. */ +const MAX_STDOUT_BYTES = 1_000_000; +/** Captured stderr tail. */ +const MAX_STDERR_BYTES = 8_192; +/** Worktrees listed. A repository with more is unusual; the count says when it was cut. */ +export const MAX_WORKTREES_LISTED = 200; + +/** Path segments of the managed worktrees folder, relative to the repository root. */ +export const WORKTREES_DIR_SEGMENTS = ['.claude', 'worktrees'] as const; +/** The `info/exclude` line that hides the managed worktrees from the main checkout. */ +export const WORKTREES_EXCLUDE_LINE = '/.claude/worktrees/'; +/** The `info/exclude` line for the hooks file Codeman writes into every claude workspace. */ +export const SETTINGS_LOCAL_EXCLUDE_LINE = '/.claude/settings.local.json'; + +/** A workspace-relative skills folder (`.claude/skills`, `.agents/skills`): plain segments only. */ +const SKILLS_DIR_RE = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/; + +/** + * The `info/exclude` line for the copy of Codeman's agent skill in `skillsDir` of a workspace + * (`/codeman/`, written into the folder a session runs in while `agentSkillEnabled` + * is on), or null for a folder that is not a plain relative path. + */ +export function agentSkillExcludeLine(skillsDir: string): string | null { + if (!SKILLS_DIR_RE.test(skillsDir) || skillsDir.split('/').some((s) => s === '.' || s === '..')) return null; + return `/${skillsDir}/codeman/`; +} + +// ─── Pure: names ───────────────────────────────────────────────────────────── + +const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const BRANCH_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; + +/** + * Why `name` cannot be a worktree name, or null when it can. One path segment of letters, digits, + * `.`, `_` and `-`, starting with a letter or digit, at most 64 characters, never `..` (the + * session path schema refuses any path containing it), and not ending in `.` or `.lock` (git's + * own ref rules, since the default branch is derived from the name). + */ +export function worktreeNameError(name: unknown): string | null { + if (typeof name !== 'string' || name.length === 0) return 'A worktree name is required.'; + if (name.length > MAX_WORKTREE_NAME_LENGTH) + return `A worktree name is at most ${MAX_WORKTREE_NAME_LENGTH} characters.`; + if (!WORKTREE_NAME_RE.test(name)) + return 'A worktree name may contain only letters, digits, ".", "_" and "-", and must start with a letter or digit.'; + if (name.includes('..')) return 'A worktree name may not contain "..".'; + if (name.endsWith('.') || name.toLowerCase().endsWith('.lock')) + return 'A worktree name may not end with "." or ".lock".'; + return null; +} + +/** The branch a new worktree gets when the caller names none: Claude Code's `worktree-`. */ +export function defaultWorktreeBranch(name: string): string { + return `worktree-${name}`; +} + +/** + * Why `branch` cannot be a new branch name, or null when it passes the charset gate. The final + * word is `git check-ref-format --branch` (see `createCaseWorktree`); this gate exists so nothing + * option-shaped, `@{...}`-shaped or otherwise surprising ever reaches git at all. + */ +export function worktreeBranchError(branch: unknown): string | null { + if (typeof branch !== 'string' || branch.length === 0) return 'A branch name is required.'; + if (branch.length > MAX_WORKTREE_BRANCH_LENGTH) + return `A branch name is at most ${MAX_WORKTREE_BRANCH_LENGTH} characters.`; + if (!BRANCH_NAME_RE.test(branch)) + return 'A branch name may contain only letters, digits, ".", "_", "-" and "/", and must start with a letter or digit.'; + if (branch.includes('..') || branch.includes('//')) return 'A branch name may not contain ".." or "//".'; + if (branch.endsWith('/') || branch.endsWith('.') || branch.toLowerCase().endsWith('.lock')) + return 'A branch name may not end with "/", "." or ".lock".'; + if (branch.split('/').some((part) => part.startsWith('.'))) return 'No part of a branch name may start with ".".'; + return null; +} + +/** Why `base` cannot be a start point, or null. The same gate Clone Repo uses for a ref. */ +export function worktreeBaseError(base: unknown): string | null { + if (typeof base !== 'string' || !isSafeGitRef(base)) + return 'The base must be a branch, tag or commit (letters, digits, ".", "_", "-", "/" and "+").'; + return null; +} + +// ─── Pure: paths ───────────────────────────────────────────────────────────── + +/** `/.claude/worktrees`. */ +export function managedWorktreesDir(repoRoot: string): string { + return join(repoRoot, ...WORKTREES_DIR_SEGMENTS); +} + +/** `/.claude/worktrees/`. The caller validates `name` first. */ +export function managedWorktreePath(repoRoot: string, name: string): string { + return join(managedWorktreesDir(repoRoot), name); +} + +/** + * The managed worktree name of `path`, or null when it is not exactly + * `/.claude/worktrees/`. Lexical: both sides must already be real paths. + */ +export function managedWorktreeName(repoRoot: string, path: string): string | null { + const dir = managedWorktreesDir(repoRoot) + sep; + const clean = path.endsWith(sep) && path.length > 1 ? path.slice(0, -1) : path; + if (!clean.startsWith(dir)) return null; + const name = clean.slice(dir.length); + if (!name || name.includes(sep)) return null; + return worktreeNameError(name) === null ? name : null; +} + +/** True when `child` is `root` or inside it (lexical, on resolved paths). */ +export function isPathWithin(child: string, root: string): boolean { + const c = resolve(child); + const r = resolve(root); + return c === r || c.startsWith(r === sep ? r : r + sep); +} + +/** + * `path` as the repository spells it: a path under `alias` (the case folder as its case resolves + * it, which can run through a symlink) moved under `root` (the repository's real top level), else + * `path` unchanged. Lexical, so it costs no filesystem call per session. Sessions keep the + * `workingDir` they were created with, while the worktree paths git lists are real, so without + * this a session spelled through the case's link counted for no worktree and did not block its + * removal. + */ +export function respellUnder(path: string, alias: string, root: string): string { + const p = resolve(path); + const a = resolve(alias); + if (a === resolve(root) || !isPathWithin(p, a)) return p; + return join(root, p.slice(a.length)); +} + +// ─── Pure: `git worktree list --porcelain` ─────────────────────────────────── + +export interface ParsedWorktree { + path: string; + /** Commit checked out (absent for a bare entry). */ + head: string | null; + /** Short branch name (`refs/heads/` stripped), null when detached or bare. */ + branch: string | null; + detached: boolean; + bare: boolean; + locked: boolean; + lockReason?: string; + prunable: boolean; + prunableReason?: string; +} + +/** + * Undo git's C-style path quoting (`"a\\"b\\303\\251"` → `a"bé`). Paths are printed quoted when + * they hold a `"`, a `\` or a control character even with `core.quotePath=false`; octal escapes are + * raw bytes, decoded as UTF-8. Text that is not quoted comes back unchanged. + */ +export function unquoteGitPath(text: string): string { + if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) return text; + const body = text.slice(1, -1); + const bytes: number[] = []; + const simple: Record = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '"': 34, '\\': 92 }; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch !== '\\') { + for (const b of Buffer.from(ch, 'utf-8')) bytes.push(b); + continue; + } + const next = body[i + 1]; + if (next !== undefined && /[0-7]/.test(next)) { + const oct = /^[0-7]{1,3}/.exec(body.slice(i + 1))![0]; + bytes.push(parseInt(oct, 8) & 0xff); + i += oct.length; + } else if (next !== undefined && next in simple) { + bytes.push(simple[next]); + i += 1; + } else { + bytes.push(92); + } + } + return Buffer.from(bytes).toString('utf-8'); +} + +/** + * Parse `git worktree list --porcelain` (newline-terminated form: `-z` needs git 2.36, and Ubuntu + * 22.04 ships 2.34). Records are separated by blank lines; the first is the main worktree. Paths + * are read through `unquoteGitPath`, which together with `-c core.quotePath=false` keeps non-ASCII + * repository paths readable. + */ +export function parseWorktreeList(text: string): ParsedWorktree[] { + const out: ParsedWorktree[] = []; + let cur: ParsedWorktree | null = null; + const flush = () => { + if (cur) out.push(cur); + cur = null; + }; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!line) { + flush(); + continue; + } + const space = line.indexOf(' '); + const key = space === -1 ? line : line.slice(0, space); + const value = space === -1 ? '' : line.slice(space + 1); + if (key === 'worktree') { + flush(); + cur = { + path: unquoteGitPath(value), + head: null, + branch: null, + detached: false, + bare: false, + locked: false, + prunable: false, + }; + continue; + } + if (!cur) continue; + const entry: ParsedWorktree = cur; + if (key === 'HEAD') entry.head = value || null; + else if (key === 'branch') entry.branch = value.startsWith('refs/heads/') ? value.slice(11) : value || null; + else if (key === 'detached') entry.detached = true; + else if (key === 'bare') entry.bare = true; + else if (key === 'locked') { + entry.locked = true; + if (value) entry.lockReason = value; + } else if (key === 'prunable') { + entry.prunable = true; + if (value) entry.prunableReason = value; + } + } + flush(); + return out; +} + +// ─── Pure: exclude file ────────────────────────────────────────────────────── + +/** + * What to append to an `info/exclude` holding `existing` so that every line of `lines` is in it: + * the text ('' when nothing is missing) and the lines it adds. A file that does not end in a + * newline gets one first, so an appended pattern never fuses with the user's last line. + */ +export function excludeAppendText(existing: string, lines: readonly string[]): { text: string; added: string[] } { + const present = new Set(existing.split(/\r?\n/).map((l) => l.trim())); + const added = lines.filter((l) => !present.has(l)); + if (added.length === 0) return { text: '', added }; + const lead = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''; + return { + text: `${lead}# Added by Codeman: git worktrees and the files Codeman writes into each workspace stay local\n${added.join('\n')}\n`, + added, + }; +} + +// ─── Pure: git's answers ───────────────────────────────────────────────────── + +/** + * The part of git's stderr a user should see: its `fatal:`/`error:` lines (with any `hint:` lines + * dropped, they repeat what the UI says), sanitized and capped. Shown through `textContent` only. + */ +export function gitRefusalText(stderr: string): string { + const clean = sanitizeGitOutput(stderr, 2000); + const lines = clean + .split('\n') + .map((l) => l.trim()) + .filter((l) => l && !/^hint:/i.test(l)); + const diagnostic = lines.filter((l) => /^(fatal|error|warning):/i.test(l)); + const text = (diagnostic.length ? diagnostic : lines).join('\n'); + return text.length > 1000 ? `${text.slice(0, 1000)}…` : text; +} + +// ─── IO: git runner ────────────────────────────────────────────────────────── + +export interface WorktreeGitResult { + code: number | null; + stdout: string; + stderr: string; + timedOut: boolean; + spawnError?: string; +} + +/** Runs `git ` in `cwd`; never throws. Injected so tests can refuse to run git at all. */ +export type WorktreeGitRunner = ( + cwd: string, + args: string[], + opts?: { timeoutMs?: number } +) => Promise; + +/** + * Global options for a call that only reads: no optional index refresh, no filesystem monitor, + * no path quoting of non-ASCII names. Must precede the subcommand. + */ +const READ_ONLY_GIT_OPTIONS = ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-c', 'core.quotePath=false']; + +/** + * The production runner: argv only (no shell), the non-interactive environment, captured output + * caps, and a timeout that signals the whole process group (`detached: true`), because + * `git worktree add` fans out into checkout children, hooks and filters that a signal to the + * parent alone would leave running. + */ +export const runWorktreeGit: WorktreeGitRunner = (cwd, args, opts) => + new Promise((resolveRun) => { + let child: ReturnType; + try { + child = spawn('git', args, { + cwd, + env: { ...gitNonInteractiveEnv(), GIT_OPTIONAL_LOCKS: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + }); + } catch (err) { + resolveRun({ code: null, stdout: '', stderr: '', timedOut: false, spawnError: String(err) }); + return; + } + let stdout = ''; + let stdoutBytes = 0; + let stderr = ''; + let timedOut = false; + let settled = false; + let killTimer: NodeJS.Timeout | undefined; + const killTree = (signal: NodeJS.Signals) => { + try { + if (child.pid) process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + /* already gone */ + } + } + }; + const timer = setTimeout(() => { + timedOut = true; + killTree('SIGTERM'); + killTimer = setTimeout(() => killTree('SIGKILL'), 3_000); + }, opts?.timeoutMs ?? WORKTREE_READ_TIMEOUT_MS); + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes += chunk.length; + if (stdoutBytes <= MAX_STDOUT_BYTES) stdout += chunk.toString('utf-8'); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf-8'); + if (stderr.length > MAX_STDERR_BYTES * 2) stderr = stderr.slice(-MAX_STDERR_BYTES); + }); + const finish = (result: WorktreeGitResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + resolveRun(result); + }; + child.on('error', (err) => finish({ code: null, stdout, stderr, timedOut, spawnError: String(err) })); + child.on('close', (code) => finish({ code, stdout, stderr, timedOut })); + }); + +// ─── IO: results ───────────────────────────────────────────────────────────── + +/** Failure codes, named after the API error codes the routes map them to. */ +export type WorktreeFailureCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'ALREADY_EXISTS' | 'CONFLICT' | 'OPERATION_FAILED'; + +export interface WorktreeFailure { + ok: false; + code: WorktreeFailureCode; + message: string; + /** git's own words, when git refused (sanitized; render as text). */ + gitMessage?: string; +} + +const fail = (code: WorktreeFailureCode, message: string, gitMessage?: string): WorktreeFailure => ({ + ok: false, + code, + message, + ...(gitMessage ? { gitMessage } : {}), +}); + +function gitFailure(run: WorktreeGitResult, what: string): WorktreeFailure { + if (run.spawnError && /enoent/i.test(run.spawnError)) + return fail('OPERATION_FAILED', 'git is not installed on this machine (or not on the server’s PATH).'); + if (run.timedOut) return fail('OPERATION_FAILED', `git timed out while ${what}.`); + const text = gitRefusalText(run.stderr); + return fail('OPERATION_FAILED', text ? `git failed while ${what}: ${text}` : `git failed while ${what}.`, text); +} + +// ─── IO: the case's repository ─────────────────────────────────────────────── + +export interface CaseRepository { + /** The repository's top level, which is the case folder (real path). */ + repoRoot: string; + /** The real common git dir (`.git` of the main checkout), where `info/exclude` lives. */ + commonDir: string; +} + +export interface WorktreeOptions { + git?: WorktreeGitRunner; + /** Host paths of every Docker case workspace: git never runs at or inside one. */ + dockerWorkspaces?: string[]; + /** Overrides `os.homedir()` for the unrelated-ancestor rule (tests). */ + home?: string; + /** + * Every workspace skills folder a session can get Codeman's agent skill in (the registry's + * `capabilities.agentSkillDir` values, passed in as data by the caller). A create excludes + * `/codeman/` for each, like the hooks file: a session in a worktree gets an untracked + * copy there, and `git worktree remove` would refuse that worktree forever. + */ + agentSkillDirs?: readonly string[]; +} + +const realOr = async (p: string): Promise => { + try { + return await fs.realpath(p); + } catch { + return resolve(p); + } +}; + +const DOCKER_REFUSAL = + 'Worktrees are not available for a folder inside a Docker case workspace: a container can write there, and git would run its hooks and filters on this machine.'; + +/** + * The repository a case folder IS, or why it is not one Codeman makes worktrees for. Never runs + * git when the folder (or the repository's common git dir) is at or inside a Docker case workspace. + */ +export async function resolveCaseRepository( + casePath: string, + opts: WorktreeOptions = {} +): Promise<{ ok: true; repo: CaseRepository } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + const dockerRoots = await Promise.all((opts.dockerWorkspaces ?? []).filter(Boolean).map(realOr)); + if (await isInsideAny(casePath, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL); + + const run = await git(casePath, [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--show-toplevel', '--git-common-dir'], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + if (run.code !== 0 || run.spawnError || run.timedOut) { + if (/must be run in a work tree/i.test(run.stderr)) { + return fail('INVALID_INPUT', 'This case folder is a bare git repository, which has no work tree to branch from.'); + } + if (/not a git repository/i.test(run.stderr)) { + return fail('INVALID_INPUT', 'This case folder is not a git repository.'); + } + return gitFailure(run, 'reading the repository'); + } + const [top, common] = run.stdout.split('\n').map((l) => l.trim()); + if (!top || !common) return fail('OPERATION_FAILED', 'git did not report the repository root.'); + const [repoRoot, caseReal] = await Promise.all([realOr(top), realOr(casePath)]); + const commonDir = await realOr(resolve(casePath, common)); + if (await isInsideAny(commonDir, dockerRoots)) return fail('INVALID_INPUT', DOCKER_REFUSAL); + if (repoRoot !== caseReal) { + if (await isUnrelatedAncestor(repoRoot, caseReal, opts.home ?? homedir())) { + return fail('INVALID_INPUT', 'This case folder is not a git repository.'); + } + return fail( + 'INVALID_INPUT', + `Worktrees need the case folder to be the top of a git repository; this folder is inside ${repoRoot}.` + ); + } + return { ok: true, repo: { repoRoot, commonDir } }; +} + +// ─── IO: listing ───────────────────────────────────────────────────────────── + +export interface CaseWorktree extends ParsedWorktree { + /** The repository's main checkout (git lists it first). */ + main: boolean; + /** The case folder itself (the main checkout, or a linked worktree linked as the case). */ + isCase: boolean; + /** Under `/.claude/worktrees/`: Codeman may start sessions in it and remove it. */ + managed: boolean; + /** The managed name, when `managed`. */ + name?: string; +} + +export async function listCaseWorktrees( + repo: CaseRepository, + opts: WorktreeOptions = {} +): Promise<{ ok: true; worktrees: CaseWorktree[]; truncated: boolean } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'worktree', 'list', '--porcelain'], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + if (run.code !== 0 || run.spawnError || run.timedOut) return gitFailure(run, 'listing worktrees'); + const parsed = parseWorktreeList(run.stdout); + const truncated = parsed.length > MAX_WORKTREES_LISTED; + const worktrees = parsed.slice(0, MAX_WORKTREES_LISTED).map((wt, index): CaseWorktree => { + const name = index === 0 ? null : managedWorktreeName(repo.repoRoot, wt.path); + return { + ...wt, + main: index === 0, + isCase: wt.path === repo.repoRoot, + managed: name !== null, + ...(name ? { name } : {}), + }; + }); + return { ok: true, worktrees, truncated }; +} + +// ─── IO: path guards ───────────────────────────────────────────────────────── + +type DirState = 'dir' | 'absent' | 'symlink' | 'other'; + +async function lstatState(path: string): Promise { + try { + const st = await fs.lstat(path); + if (st.isSymbolicLink()) return 'symlink'; + return st.isDirectory() ? 'dir' : 'other'; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT') return 'absent'; + throw err; + } +} + +/** + * Make sure `/.claude` and `/.claude/worktrees` are real directories (created when + * absent, refused when a symlink or a file) whose real path is exactly the lexical one. + */ +async function prepareWorktreesDir(repoRoot: string): Promise<{ ok: true; dir: string } | WorktreeFailure> { + let cur = repoRoot; + for (const segment of WORKTREES_DIR_SEGMENTS) { + cur = join(cur, segment); + const state = await lstatState(cur); + if (state === 'symlink') { + return fail( + 'INVALID_INPUT', + `${cur} is a symlink. Codeman will not create worktrees through a link that could point outside the repository.` + ); + } + if (state === 'other') return fail('INVALID_INPUT', `${cur} exists and is not a directory.`); + if (state === 'absent') { + try { + await fs.mkdir(cur); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + return fail('OPERATION_FAILED', `Could not create ${cur}: ${(err as Error).message}`); + } + if ((await lstatState(cur)) !== 'dir') return fail('INVALID_INPUT', `${cur} is not a plain directory.`); + } + } + } + if ((await realOr(cur)) !== cur) { + return fail('INVALID_INPUT', `${cur} does not resolve to itself; refusing to create a worktree there.`); + } + return { ok: true, dir: cur }; +} + +/** + * The managed worktree `name` of `repo` as it is on disk now: every component a real directory + * (no symlinks), the real path the expected one, and git listing it as a worktree of this + * repository. Used by removal and by starting a session in it. + * + * `allowMissing` (removal only, never a session start) also accepts a worktree whose folder is + * GONE (deleted by hand, or by an agent) while git still lists it, as prunable, at exactly the + * managed path: no component on the way may be a symlink or a file, and `missing` says so. + * Without it such a worktree was a dead end: it could not be removed, and its name could not be + * used again (the branch exists, then git refuses "a missing but already registered worktree"). + */ +export async function resolveManagedWorktree( + repo: CaseRepository, + name: string, + opts: WorktreeOptions & { allowMissing?: boolean } = {} +): Promise<{ ok: true; path: string; worktree: CaseWorktree; missing: boolean } | WorktreeFailure> { + const nameError = worktreeNameError(name); + if (nameError) return fail('INVALID_INPUT', nameError); + const target = managedWorktreePath(repo.repoRoot, name); + let cur = repo.repoRoot; + let missing = false; + for (const segment of [...WORKTREES_DIR_SEGMENTS, name]) { + cur = join(cur, segment); + const state = await lstatState(cur); + if (state === 'absent') { + if (!opts.allowMissing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`); + // Nothing below an absent folder exists, so nothing below it can redirect anything. + missing = true; + break; + } + if (state === 'symlink') { + return fail('INVALID_INPUT', `${cur} is a symlink. Codeman only manages worktrees that are real directories.`); + } + if (state === 'other') return fail('INVALID_INPUT', `${cur} is not a directory.`); + } + if (!missing && (await realOr(target)) !== target) { + return fail('INVALID_INPUT', `${target} does not resolve to itself; refusing to use it.`); + } + const listed = await listCaseWorktrees(repo, opts); + if (!listed.ok) return listed; + for (const wt of listed.worktrees) { + if (wt.main) continue; + if (missing) { + // Lexical only: the folder is gone, so there is no real path to compare. git never calls a + // LOCKED worktree prunable (prune skips it), so a locked one is taken too, and the caller's + // lock check then refuses it with git's lock reason. + if (wt.path === target && (wt.prunable || wt.locked)) { + return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: true }; + } + continue; + } + if (wt.path === target || (await realOr(wt.path)) === target) { + return { ok: true, path: target, worktree: { ...wt, managed: true, name }, missing: false }; + } + } + if (missing) return fail('NOT_FOUND', `No worktree named "${name}" in this case.`); + return fail( + 'NOT_FOUND', + `${target} is not a registered git worktree of this repository (a leftover folder, or one git no longer knows about).` + ); +} + +// ─── IO: excludes ──────────────────────────────────────────────────────────── + +/** + * Append each of `lines` that git does not already ignore to `/info/exclude`. Returns + * the lines written. A symlinked `info` dir or `exclude` file is left alone (reported, never + * written through). + */ +async function ensureExcluded( + repo: CaseRepository, + probes: Array<{ line: string; probe: string }>, + git: WorktreeGitRunner +): Promise<{ added: string[]; warning?: string }> { + const needed: string[] = []; + for (const { line, probe } of probes) { + const run = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ignore', '-q', '--', probe], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + // 0: ignored. 1: not ignored. Anything else (128) is a git error: assume not ignored, since an + // extra exclude line is harmless and a missing one leaves the worktrees untracked. + if (run.code !== 0) needed.push(line); + } + if (needed.length === 0) return { added: [] }; + const infoDir = join(repo.commonDir, 'info'); + const excludePath = join(infoDir, 'exclude'); + try { + const infoState = await lstatState(infoDir); + if (infoState === 'absent') await fs.mkdir(infoDir); + else if (infoState !== 'dir') + return { added: [], warning: `${infoDir} is not a plain directory; nothing was excluded.` }; + const fileState = await lstatState(excludePath); + if (fileState === 'symlink' || fileState === 'dir') { + return { added: [], warning: `${excludePath} is not a plain file; nothing was excluded.` }; + } + const existing = fileState === 'absent' ? '' : await fs.readFile(excludePath, 'utf-8'); + const { text, added } = excludeAppendText(existing, needed); + if (text) await fs.appendFile(excludePath, text, { mode: 0o644 }); + return { added }; + } catch (err) { + return { added: [], warning: `Could not update ${excludePath}: ${(err as Error).message}` }; + } +} + +// ─── IO: one operation per repository at a time ────────────────────────────── + +const repoLocks = new Map>(); + +/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */ +async function withRepoLock(repoRoot: string, fn: () => Promise): Promise { + const prev = repoLocks.get(repoRoot) ?? Promise.resolve(); + const next = prev.then(fn, fn); + const tail = next.catch(() => undefined); + repoLocks.set(repoRoot, tail); + try { + return await next; + } finally { + if (repoLocks.get(repoRoot) === tail) repoLocks.delete(repoRoot); + } +} + +// ─── IO: create ────────────────────────────────────────────────────────────── + +export interface CreateWorktreeRequest { + name: string; + /** New branch; default `worktree-`. */ + branch?: string; + /** Start point; default `HEAD` (the case's current commit). */ + base?: string; +} + +export interface CreatedWorktree { + name: string; + path: string; + branch: string; + base: string; + /** The commit the new branch starts at. */ + head: string; + /** Lines appended to the repository's `info/exclude` (empty when git already ignored them). */ + excludeAdded: string[]; + warnings: string[]; +} + +export async function createCaseWorktree( + repo: CaseRepository, + req: CreateWorktreeRequest, + opts: WorktreeOptions = {} +): Promise<{ ok: true; worktree: CreatedWorktree } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + const nameError = worktreeNameError(req.name); + if (nameError) return fail('INVALID_INPUT', nameError); + const branch = req.branch || defaultWorktreeBranch(req.name); + const branchError = worktreeBranchError(branch); + if (branchError) return fail('INVALID_INPUT', branchError); + const base = req.base || 'HEAD'; + const baseError = worktreeBaseError(base); + if (baseError) return fail('INVALID_INPUT', baseError); + + return withRepoLock(repo.repoRoot, async () => { + // A name already taken says so in those words, before the branch check would report the + // derived branch instead. Rechecked after the folders are prepared, below. + try { + if ((await lstatState(managedWorktreePath(repo.repoRoot, req.name))) !== 'absent') { + return fail('ALREADY_EXISTS', `A worktree named "${req.name}" already exists in this case.`); + } + } catch { + /* unreadable: the check after the folders are prepared reports it */ + } + // git's own word on the branch name. The output must be the input: `--branch` expands + // shorthands, and anything it rewrote is not the name the user typed. + const refFormat = await git(repo.repoRoot, [...READ_ONLY_GIT_OPTIONS, 'check-ref-format', '--branch', branch], { + timeoutMs: WORKTREE_READ_TIMEOUT_MS, + }); + if (refFormat.code !== 0 || refFormat.stdout.trim() !== branch) { + return fail('INVALID_INPUT', `"${branch}" is not a valid branch name.`, gitRefusalText(refFormat.stderr)); + } + const existingBranch = await git( + repo.repoRoot, + [...READ_ONLY_GIT_OPTIONS, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`], + { timeoutMs: WORKTREE_READ_TIMEOUT_MS } + ); + if (existingBranch.code === 0) { + return fail('ALREADY_EXISTS', `A branch named "${branch}" already exists. Pick another worktree name or branch.`); + } + const resolved = await git( + repo.repoRoot, + // No `--end-of-options` (git 2.30+): the base already passed the ref gate, which refuses a + // leading `-`, so it cannot be read as an option. + [...READ_ONLY_GIT_OPTIONS, 'rev-parse', '--verify', '--quiet', `${base}^{commit}`], + { timeoutMs: WORKTREE_READ_TIMEOUT_MS } + ); + const head = resolved.stdout.trim(); + if (resolved.code !== 0 || !/^[0-9a-f]{40,64}$/.test(head)) { + return fail('INVALID_INPUT', `"${base}" is not a commit in this repository (an empty repository has none yet).`); + } + + const prepared = await prepareWorktreesDir(repo.repoRoot); + if (!prepared.ok) return prepared; + const target = join(prepared.dir, req.name); + try { + if ((await lstatState(target)) !== 'absent') { + return fail('ALREADY_EXISTS', `${target} already exists. Pick another worktree name.`); + } + } catch (err) { + return fail('OPERATION_FAILED', `Could not inspect ${target}: ${(err as Error).message}`); + } + + const skillProbes: Array<{ line: string; probe: string }> = []; + for (const dir of new Set(opts.agentSkillDirs ?? [])) { + const line = agentSkillExcludeLine(dir); + if (line) skillProbes.push({ line, probe: `${dir}/codeman` }); + } + const excluded = await ensureExcluded( + repo, + [ + { line: WORKTREES_EXCLUDE_LINE, probe: `${WORKTREES_DIR_SEGMENTS.join('/')}/${req.name}` }, + { line: SETTINGS_LOCAL_EXCLUDE_LINE, probe: '.claude/settings.local.json' }, + ...skillProbes, + ], + git + ); + + // The resolved commit, not the ref text, after `--`: nothing here can be read as an option, + // and `--no-track` keeps a worktree started from `origin/main` from tracking (and pulling) main. + const add = await git(repo.repoRoot, ['worktree', 'add', '--no-track', '-b', branch, '--', target, head], { + timeoutMs: WORKTREE_ADD_TIMEOUT_MS, + }); + if (add.code !== 0 || add.spawnError || add.timedOut) { + const text = gitRefusalText(add.stderr); + if (/already exists/i.test(text)) return fail('ALREADY_EXISTS', `git refused: ${text}`, text); + return gitFailure(add, 'creating the worktree'); + } + return { + ok: true, + worktree: { + name: req.name, + path: target, + branch, + base, + head, + excludeAdded: excluded.added, + warnings: excluded.warning ? [excluded.warning] : [], + }, + }; + }); +} + +// ─── IO: remove ────────────────────────────────────────────────────────────── + +export interface RemoveWorktreeRequest { + name: string; + /** Also `git branch -d` the worktree's branch (never `-D`). */ + deleteBranch?: boolean; + /** True when a live session runs at or inside `path`: the removal is refused. */ + isInUse: (path: string) => boolean | Promise; +} + +export interface RemovedWorktree { + name: string; + path: string; + branch: string | null; + branchDeleted: boolean; + /** git's refusal to delete the branch (typically "not fully merged"); the worktree is gone regardless. */ + branchError?: string; +} + +export async function removeCaseWorktree( + repo: CaseRepository, + req: RemoveWorktreeRequest, + opts: WorktreeOptions = {} +): Promise<{ ok: true; removed: RemovedWorktree } | WorktreeFailure> { + const git = opts.git ?? runWorktreeGit; + return withRepoLock(repo.repoRoot, async () => { + // A worktree whose folder is gone is removable too: `git worktree remove` then only drops + // git's record of it (measured on git 2.43: exit 0, nothing on disk touched). + const found = await resolveManagedWorktree(repo, req.name, { ...opts, allowMissing: true }); + if (!found.ok) return found; + if (await req.isInUse(found.path)) { + return fail( + 'CONFLICT', + `A live session is still running in worktree "${req.name}". Close it first, then remove the worktree.` + ); + } + if (found.worktree.locked) { + return fail( + 'CONFLICT', + `Worktree "${req.name}" is locked${found.worktree.lockReason ? ` (${found.worktree.lockReason})` : ''}. Unlock it with git first.` + ); + } + const removed = await git(repo.repoRoot, ['worktree', 'remove', '--', found.path], { + timeoutMs: WORKTREE_REMOVE_TIMEOUT_MS, + }); + if (removed.code !== 0 || removed.spawnError || removed.timedOut) { + if (removed.timedOut || removed.spawnError) return gitFailure(removed, 'removing the worktree'); + const text = gitRefusalText(removed.stderr); + // git's own refusal (modified or untracked files, a lock): Codeman never forces, so this is + // the answer, in git's words. + return fail( + 'CONFLICT', + text ? `git refused to remove the worktree: ${text}` : 'git refused to remove the worktree.', + text + ); + } + const branch = found.worktree.branch; + let branchDeleted = false; + let branchError: string | undefined; + if (req.deleteBranch && branch) { + const del = await git(repo.repoRoot, ['branch', '-d', '--', branch], { timeoutMs: WORKTREE_READ_TIMEOUT_MS }); + if (del.code === 0) branchDeleted = true; + else branchError = gitRefusalText(del.stderr) || 'git refused to delete the branch.'; + } + return { + ok: true, + removed: { + name: req.name, + path: found.path, + branch, + branchDeleted, + ...(branchError ? { branchError } : {}), + }, + }; + }); +} diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 9bd447b3d..5fb9b514a 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -167,6 +167,18 @@ function overwrite( if (value !== undefined) item[key] = value; } +/** `/.claude/worktrees/`: Claude Code's `--worktree` layout, which Codeman's own worktrees use too. */ +const WORKTREE_PATH = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/; + +/** + * The worktree a working directory is, by its layout alone: `{ repo, name }` for + * `/.claude/worktrees/` (the folder itself, not a subfolder of it), else null. + */ +export function worktreeFromPath(workingDir: string): { repo: string; name: string } | null { + const m = WORKTREE_PATH.exec(workingDir); + return m ? { repo: m[1], name: m[2] } : null; +} + /** * Merge all source views into one list, applying precedence * (history → lifecycle → persisted → live) and the meaningfulness floor. @@ -326,6 +338,17 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte if (byDir) item.lastPrompt = byDir.prompt; } } + // A live or persisted session started in a worktree Codeman created (or Claude Code's own + // `--worktree`) has no transcript record naming it until claude writes one, and a codex or + // shell session never gets one, so its row read like any other session of the repo. The + // folder layout says which worktree it is; a transcript record, when there is one, still wins. + if (!item.worktreeName && item.workingDir) { + const wt = worktreeFromPath(item.workingDir); + if (wt) { + item.worktreeName = wt.name; + item.worktreeRepo ??= wt.repo; + } + } } // Meaningfulness floor: keep real rows, drop bare lifecycle/mux-only noise. diff --git a/src/web/public/app.js b/src/web/public/app.js index 2795b5992..7fb67351b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1447,6 +1447,7 @@ class CodemanApp { this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); + this.closeWorktreeModal?.(); this.closeSessionManager(); this.closeCommandPalette?.(); this.closeShortcutOverlay?.(); @@ -10336,9 +10337,10 @@ class CodemanApp { delete this.respawnActionLogs[sessionId]; } + /** Resolves true once the session is gone from the server, false when it is still there. */ async closeSession(sessionId, killMux = true) { // Already on its way out (a repeated click, the mux panel racing the tab). - if (this._closingSessions.has(sessionId)) return; + if (this._closingSessions.has(sessionId)) return false; // The tab goes FIRST and the server is asked after. The kill takes the server // a few hundred ms (SIGTERM grace, the process tree, tmux), and a tab that sat // there that long after "Kill" read as a click that did nothing. A refused @@ -10416,7 +10418,7 @@ class CodemanApp { } else { this.showToast('Tab hidden, tmux still running', 'info'); } - return; + return true; } // Still on the server (or the server is unreachable, in which case the resync @@ -10431,6 +10433,7 @@ class CodemanApp { this.renderSessionTabs(); } this.showToast('Failed to close session', 'error'); + return false; } // Request confirmation before closing a session @@ -10445,6 +10448,20 @@ class CodemanApp { const sessionNameEl = document.getElementById('closeConfirmSessionName'); sessionNameEl.textContent = name; + // A session in a Codeman-managed git worktree also offers to remove the worktree + // (git worktree remove, never forced) once the session is gone. Read now, before the + // session leaves this.sessions. + const worktree = this._managedWorktreeOfSession?.(session) || null; + this.pendingCloseWorktree = worktree; + const wtOption = document.getElementById('closeConfirmWorktree'); + if (wtOption) { + wtOption.hidden = !worktree; + const wtName = document.getElementById('closeConfirmWorktreeName'); + if (wtName) wtName.textContent = worktree ? worktree.name : ''; + const wtBranch = document.getElementById('closeConfirmWorktreeBranch'); + if (wtBranch) wtBranch.checked = false; + } + // Update kill button text based on session mode const killTitle = document.getElementById('closeConfirmKillTitle'); if (killTitle) { @@ -10472,15 +10489,24 @@ class CodemanApp { cancelCloseSession() { this.pendingCloseSessionId = null; + this.pendingCloseWorktree = null; document.getElementById('closeConfirmModal').classList.remove('active'); } - async confirmCloseSession(killMux = true) { + /** + * `removeWorktree`: Close Session's "Kill & remove worktree". The worktree is removed only + * after the server confirms the session is gone (a live session inside is refused anyway), + * and only ever through `git worktree remove` without --force. + */ + async confirmCloseSession(killMux = true, { removeWorktree = false } = {}) { const sessionId = this.pendingCloseSessionId; + const worktree = removeWorktree ? this.pendingCloseWorktree : null; + const deleteBranch = !!document.getElementById('closeConfirmWorktreeBranch')?.checked; this.cancelCloseSession(); if (sessionId) { - await this.closeSession(sessionId, killMux); + const gone = await this.closeSession(sessionId, killMux); + if (gone && worktree) await this._removeWorktreeAfterClose?.(worktree, deleteBranch); } } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index e9cb509bd..7cedde3e4 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -265,6 +265,50 @@ 'New or link a case…': '新建或关联案例…', 'Case settings…': '案例设置…', 'No cases match': '没有匹配的案例', + // Git worktrees of a case (session-ui.js openWorktreeModal, the case picker row, the + // phone case sheet and Close Session's worktree option). Worktree and branch names, + // paths and git's own messages are user/git text and pass through untranslated. + 'Git worktrees…': 'Git 工作树…', + 'Git worktrees': 'Git 工作树', + 'Close git worktrees': '关闭 Git 工作树', + Worktrees: '工作树', + 'Worktrees…': '工作树…', + "Each worktree is its own checkout of the case's repository, on its own branch, in .claude/worktrees/ of the case. Agents working in parallel then never share one folder.": + '每个工作树都是案例仓库的独立检出,有自己的分支,位于案例的 .claude/worktrees/ 中。并行工作的智能体因此不会共用同一个文件夹。', + 'Start sessions with': '启动会话所用', + 'New worktree': '新建工作树', + Branch: '分支', + 'Start from': '起点', + "New branch worktree- from the case's current commit, unless you pick another below.": + '从案例当前提交新建分支 worktree-,除非在下方另行选择。', + 'Branch and start point': '分支和起点', + "A branch, tag or commit. Default: the case's current commit (HEAD).": + '分支、标签或提交。默认:案例当前提交(HEAD)。', + 'Create & start session': '创建并启动会话', + 'Loading worktrees…': '正在加载工作树…', + 'No worktrees yet. Create one below.': '还没有工作树。请在下方创建。', + 'detached HEAD': '游离 HEAD', + 'main checkout': '主检出', + 'not created by Codeman': '非 {name} 创建', + locked: '已锁定', + missing: '已丢失', + Open: '打开', + 'Start a session in this worktree': '在此工作树中启动会话', + 'Remove…': '移除…', + 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.': + '工作树中有未提交或未跟踪的文件时,git worktree remove 会拒绝执行。{name} 从不强制移除。', + 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.': + '它的文件夹已不存在。移除它会清除 git 保存的记录,这个名称便可再次使用。', + 'Remove worktree': '移除工作树', + 'Kill & remove worktree': '终止并移除工作树', + 'git refuses while the worktree has uncommitted or untracked files; nothing is forced': + '工作树中有未提交或未跟踪的文件时 git 会拒绝;不会强制执行', + 'Also delete its branch (git branch -d keeps a branch that is not merged)': + '同时删除其分支(git branch -d 会保留未合并的分支)', + 'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.': + '名称:字母、数字、"."、"_" 和 "-",以字母或数字开头,最多 64 个字符。', + 'A launch is already in progress': '已有启动正在进行', + 'Codeman did not answer.': '{name} 没有响应。', 'Add Case': '添加案例', 'Open sessions': '打开会话', 'Recent Sessions': '最近会话', @@ -1252,6 +1296,16 @@ [/^Terminal restored to (\d+)x(\d+)$/, (_m, cols, rows) => `终端已恢复为 ${cols}x${rows}`], [/^View truncated to the first (\d+) cells$/, (_m, n) => `视图仅显示前 ${n} 个单元格`], [/^(\d+) unsupported number formats$/, (_m, n) => `${n} 种不支持的数字格式`], + // Git worktree toasts (session-ui.js): worktree and branch names and git's own + // words pass through untranslated. + [/^Worktree (\S+) created on branch (\S+)$/, (_m, wt, branch) => `已创建工作树 ${wt},分支 ${branch}`], + [ + /^Worktree (\S+) removed\. Branch (\S+) kept: ([\s\S]+)$/, + (_m, wt, branch, why) => `已移除工作树 ${wt}。保留了分支 ${branch}:${why}`, + ], + [/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`], + [/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`], + [/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`], // Group names are user text: they pass through untranslated. [/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`], // Session names are user text too. diff --git a/src/web/public/index.html b/src/web/public/index.html index 1e8e3da5e..1cc8308aa 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1693,6 +1693,18 @@

Close Session

Kill Tmux & Claude Code Terminate the session completely + + + + + diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 9e32f0b0a..df26df9e8 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -128,14 +128,37 @@ const CASE_PICKER_REFRESH_MS = 5000; /** * Action rows at the bottom of the toolbar case picker. They replaced the "+" * and gear buttons that sat beside the picker (owner, 1.36.0 beta): the same two - * actions, one click away, without two extra controls in the toolbar. The arrow - * keys reach them after the last case; Enter or a click runs `run`. + * actions, one click away, without two extra controls in the toolbar, and the git + * worktrees of the selected case joined them. The arrow keys reach them after the + * last case; Enter or a click runs `run`. */ const CASE_PICKER_ACTIONS = [ { id: 'add', icon: '+', label: 'New or link a case…', run: (app) => app.showCreateCaseModal() }, { id: 'settings', icon: '\u2699', label: 'Case settings…', run: (app) => app.toggleCaseSettings() }, + // The selected case's git worktrees (openWorktreeModal below): a row here rather than a + // toolbar button, for the same reason the two above replaced theirs. + { id: 'worktrees', icon: '⑂', label: 'Git worktrees…', run: (app) => app.openWorktreeModal() }, ]; +/** A worktree name the server accepts (src/git-worktrees.ts `worktreeNameError`), checked before the POST. */ +const WORKTREE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; + +/** + * The Codeman-managed worktree a working directory IS: `{ caseName, name, path }` when it is + * exactly `/.claude/worktrees/` of a local case in `cases`, else null. Lexical + * on purpose (the browser cannot resolve links): the server re-checks every removal against the + * case's own repository, so a stale or wrong match can only be refused, never act elsewhere. + */ +function managedWorktreeOfDir(workingDir, cases) { + if (typeof workingDir !== 'string') return null; + const m = /^(.+)\/\.claude\/worktrees\/([^/]+)\/?$/.exec(workingDir); + if (!m || !WORKTREE_NAME_RE.test(m[2]) || m[2].includes('..')) return null; + const owner = (cases || []).find( + (c) => c && c.path === m[1] && c.location !== 'remote' && c.location !== 'docker' + ); + return owner ? { caseName: owner.name, name: m[2], path: workingDir.replace(/\/$/, '') } : null; +} + const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH)); const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]); @@ -1919,12 +1942,16 @@ Object.assign(CodemanApp.prototype, { }, async runClaude(options = {}) { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // `options.worktree` ({ caseName, name, path }, from the worktree modal): the session runs in + // that git worktree of the case and its tab is named after the worktree. + const wt = options.worktree || null; + const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const nameBase = wt ? wt.name : caseName; const tabCount = this._readTabCount(options.count); const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} Claude session(s) in ${caseName}...` + `Starting ${tabCount} Claude session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...` ); // Focus terminal NOW, in the synchronous user-gesture context (button click). // iOS Safari ignores programmatic focus() after any await, so this must happen @@ -1933,25 +1960,32 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); try { - // Get case path first - const caseRes = await fetch(`/api/cases/${caseName}`); - const caseLookup = await caseRes.json(); - let caseData = caseLookup?.data ?? {}; - - // Create the case only when the server says it does not exist. Any other - // failure (a linked folder on a mount that is not answering) must not - // scaffold a same-name local case that would then shadow the real one. - if (!caseData.path) { - if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed'); - const createCaseRes = await fetch('/api/cases', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName, description: '' }) - }); - const createCaseData = await createCaseRes.json(); - if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); - // API returns { success, data: { case: { name, path } } } - caseData = createCaseData.data.case; + let caseData; + if (wt) { + // A worktree is local by construction (the server refuses one for a remote or Docker + // case) and its folder came from the worktree list: no case lookup, never a scaffold. + caseData = { path: wt.path, location: 'local' }; + } else { + // Get case path first + const caseRes = await fetch(`/api/cases/${caseName}`); + const caseLookup = await caseRes.json(); + caseData = caseLookup?.data ?? {}; + + // Create the case only when the server says it does not exist. Any other + // failure (a linked folder on a mount that is not answering) must not + // scaffold a same-name local case that would then shadow the real one. + if (!caseData.path) { + if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed'); + const createCaseRes = await fetch('/api/cases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: caseName, description: '' }) + }); + const createCaseData = await createCaseRes.json(); + if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); + // API returns { success, data: { case: { name, path } } } + caseData = createCaseData.data.case; + } } const workingDir = caseData.path; @@ -2024,8 +2058,8 @@ Object.assign(CodemanApp.prototype, { let firstSessionId = null; - // Find the highest existing w-number for THIS case to avoid duplicates - const startNumber = this._nextCaseSessionStartNumber(caseName); + // Find the highest existing w-number for THIS case (or worktree) to avoid duplicates + const startNumber = this._nextCaseSessionStartNumber(nameBase); // Get global Ralph tracker setting const ralphEnabled = this.isRalphTrackerEnabledByDefault(); @@ -2033,7 +2067,7 @@ Object.assign(CodemanApp.prototype, { // Create all sessions in parallel for speed const sessionNames = []; for (let i = 0; i < tabCount; i++) { - sessionNames.push(`w${startNumber + i}-${caseName}`); + sessionNames.push(`w${startNumber + i}-${nameBase}`); } // Build env overrides from global + case settings (case overrides global) @@ -2140,40 +2174,49 @@ Object.assign(CodemanApp.prototype, { }, async runShell(options = {}) { - const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // `options.worktree`: see runClaude. + const wt = options.worktree || null; + const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; + const nameBase = wt ? wt.name : caseName; // Run Shell reads the toolbar's one instance stepper, like every other run*(); // its own second `− 1 +` group (#shellCount) was removed (#428). const shellCount = this._readTabCount(options.count); const group = options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}; const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${shellCount} Shell session(s) in ${caseName}...`, + `Starting ${shellCount} Shell session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...`, '1;33' ); try { - // Get the case path - const caseRes = await fetch(`/api/cases/${caseName}`); - const caseLookup = await caseRes.json(); - let caseData = caseLookup?.data ?? {}; - - // Create the case only when the server says it does not exist. Any other - // failure (a linked folder on a mount that is not answering) must not - // scaffold a same-name local case that would then shadow the real one. - if (!caseData.path) { - if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed'); - const createCaseRes = await fetch('/api/cases', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: caseName, description: '' }) - }); - const createCaseData = await createCaseRes.json(); - if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); - // API returns { success, data: { case: { name, path } } } - caseData = createCaseData.data.case; + let caseData; + if (wt) { + // Local by construction, folder from the worktree list (see runClaude). + caseData = { path: wt.path, location: 'local' }; + } else { + // Get the case path + const caseRes = await fetch(`/api/cases/${caseName}`); + const caseLookup = await caseRes.json(); + caseData = caseLookup?.data ?? {}; + + // Create the case only when the server says it does not exist. Any other + // failure (a linked folder on a mount that is not answering) must not + // scaffold a same-name local case that would then shadow the real one. + if (!caseData.path) { + if (caseLookup?.errorCode !== 'NOT_FOUND') throw new Error(caseLookup?.error || 'Case lookup failed'); + const createCaseRes = await fetch('/api/cases', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: caseName, description: '' }) + }); + const createCaseData = await createCaseRes.json(); + if (!createCaseData.success) throw new Error(createCaseData.error || 'Failed to create case'); + // API returns { success, data: { case: { name, path } } } + caseData = createCaseData.data.case; + } } - const selectedCase = (this.cases || []).find(c => c.name === caseName); + const selectedCase = wt ? null : (this.cases || []).find(c => c.name === caseName); const isRemoteCase = caseData.location === 'remote' || caseData.location === 'docker' || @@ -2206,13 +2249,13 @@ Object.assign(CodemanApp.prototype, { return; } - // Find the highest existing s-number for THIS case to avoid duplicates - const startNumber = this._nextCaseSessionStartNumber(caseName, 's'); + // Find the highest existing s-number for THIS case (or worktree) to avoid duplicates + const startNumber = this._nextCaseSessionStartNumber(nameBase, 's'); // Create all shell sessions in parallel const sessionNames = []; for (let i = 0; i < shellCount; i++) { - sessionNames.push(`s${startNumber + i}-${caseName}`); + sessionNames.push(`s${startNumber + i}-${nameBase}`); } // Step 1: Create all sessions in parallel @@ -2331,16 +2374,19 @@ Object.assign(CodemanApp.prototype, { buildConfig: () => null, }); if (!entry) throw new Error(`Unknown run mode: ${mode}`); - const caseName = document.getElementById('quickStartCase').value || 'testcase'; + // `options.worktree`: see runClaude. Quick-start resolves it server-side (`worktree`), so the + // session starts through the same path as any other, only in the worktree's folder. + const wt = options.worktree || null; + const caseName = wt ? wt.caseName : document.getElementById('quickStartCase').value || 'testcase'; // Remote/docker cases run the CLI on the OTHER side — the local status // probe and the local-only config/env below don't apply (quick-start // rejects them for remote cases). - const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; + const _runLoc = wt ? 'local' : (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; const tabCount = this._readTabCount(options.count); const ownsLaunchTerminal = this._beginSessionLaunchStatus( - `Starting ${tabCount} ${entry.label} session(s) in ${caseName}...` + `Starting ${tabCount} ${entry.label} session(s) in ${wt ? `worktree ${wt.name} of ` : ''}${caseName}...` ); // Focus in sync gesture context (see runClaude comment) this.terminal.focus(); @@ -2369,13 +2415,15 @@ Object.assign(CodemanApp.prototype, { // into `claude --settings`); none of these CLIs has an /effort. Each of // the eight bodies this launcher replaced carried that rule as a comment. const firstSessionId = await this._launchQuickStartInstances( - caseName, + // Only names the tabs (`w-`): a worktree session is named after the worktree. + wt ? wt.name : caseName, tabCount, entry.label, (sessionName) => ({ caseName, mode, sessionName, + ...(wt ? { worktree: wt.name } : {}), ...(options.tabGroupId ? { tabGroupId: options.tabGroupId } : {}), ...(isRemote ? {} : { ...(entry.buildConfig(globalSettings) || {}), @@ -5007,6 +5055,404 @@ Object.assign(CodemanApp.prototype, { // Remove animation class after it plays setTimeout(() => modal.classList.remove('from-mobile'), 300); }, + + // ═══════════════════════════════════════════════════════════════ + // Git worktrees of a case (src/git-worktrees.ts, routes/worktree-routes.ts) + // ═══════════════════════════════════════════════════════════════ + // + // One dialog per case: its worktrees (Open, Remove), and a form for a new one that starts a + // session in it at once. Sessions start through run() with `options.worktree`, so every run + // mode keeps its own launch path (POST /api/sessions for claude and shell, quick-start with + // `worktree` for the rest). Everything git or the user named (worktree and branch names, paths, + // git's own refusals) renders through textContent and carries data-i18n-skip. + + /** Open the worktree dialog for `caseName` (default: the case selected in the toolbar). */ + openWorktreeModal(caseName) { + const modal = document.getElementById('worktreeModal'); + const name = caseName || document.getElementById('quickStartCase')?.value || ''; + if (!modal || !name) return; + this._worktreeCase = name; + this._worktreeData = null; + this._worktreeRemoveConfirm = null; + this._worktreeRemoveError = null; + const caseLabel = document.getElementById('worktreeModalCase'); + if (caseLabel) caseLabel.textContent = name; + for (const id of ['worktreeName', 'worktreeBranch', 'worktreeBase']) { + const input = document.getElementById(id); + if (input) input.value = ''; + } + this._setWorktreeFormError(''); + this._fillWorktreeModeSelect(); + this._renderWorktreeList(); + modal.classList.add('active'); + void this._loadWorktrees(); + // Not on a touch device: focusing the field raises the on-screen keyboard over the list. + const touch = typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.(); + if (!touch) setTimeout(() => document.getElementById('worktreeName')?.focus?.(), 50); + }, + + /** The phone case sheet's Worktrees button: the selected case's worktrees. */ + showWorktreesFromMobile() { + this.closeMobileCasePicker(); + this.openWorktreeModal(); + }, + + closeWorktreeModal() { + const modal = document.getElementById('worktreeModal'); + // Called on every Escape (app.js): a no-op unless the dialog is open. + if (!modal || !modal.classList.contains('active')) return; + modal.classList.remove('active'); + this._worktreeCase = null; + this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1; + }, + + _worktreesUrl(caseName, suffix = '') { + return `/api/cases/${encodeURIComponent(caseName)}/worktrees${suffix}`; + }, + + /** A JSON call that keeps the server's error text, which `_apiJson()` drops. */ + async _worktreeRequest(path, opts = {}) { + const res = await this._api(path, opts); + if (!res) return { ok: false, error: 'Codeman did not answer.' }; + let body = null; + try { + body = await res.json(); + } catch { + /* an empty or non-JSON body: the status says enough */ + } + if (res.ok && body?.success !== false) return { ok: true, data: body?.data ?? body }; + return { ok: false, status: res.status, error: body?.error || `Request failed (${res.status})` }; + }, + + async _loadWorktrees() { + const caseName = this._worktreeCase; + if (!caseName) return; + const seq = (this._worktreeLoadSeq = (this._worktreeLoadSeq || 0) + 1); + this._setWorktreeStatus('Loading worktrees…'); + const r = await this._worktreeRequest(this._worktreesUrl(caseName)); + if (seq !== this._worktreeLoadSeq || caseName !== this._worktreeCase) return; + this._worktreeData = r.ok ? r.data : null; + this._setWorktreeStatus(r.ok ? '' : r.error, !r.ok); + // A case that cannot list worktrees (not a repository, a Docker or remote case) cannot get one. + const createBtn = document.getElementById('worktreeCreateBtn'); + if (createBtn) createBtn.disabled = !r.ok; + this._renderWorktreeList(); + }, + + _setWorktreeStatus(text, isError = false) { + const el = document.getElementById('worktreeStatus'); + if (!el) return; + el.textContent = text || ''; + el.hidden = !text; + el.classList.toggle('worktree-status-error', !!isError); + }, + + _setWorktreeFormError(text) { + const el = document.getElementById('worktreeFormError'); + if (!el) return; + el.textContent = text || ''; + el.hidden = !text; + }, + + /** Every enabled, installed run mode, the shell last; the Run button's mode preselected. */ + _fillWorktreeModeSelect() { + const select = document.getElementById('worktreeMode'); + if (!select) return; + // Every launchable registry entry (agents and the shell), as data: no id is branched on here. + const modes = []; + for (const cli of registryCliCatalog()) { + if (cli.enabled && cli.available !== false) modes.push({ id: cli.id, label: cli.label }); + } + if (modes.length === 0) { + // A cached page from before the registry catalogue: the built-in modes. + modes.push({ id: 'claude', label: 'Claude Code' }); + for (const [id, entry] of Object.entries(RUN_MODE_LAUNCH)) modes.push({ id, label: entry.label }); + modes.push({ id: 'shell', label: 'Terminal / Shell' }); + } + select.replaceChildren( + ...modes.map((m) => { + const option = document.createElement('option'); + option.value = m.id; + option.textContent = m.label; + return option; + }) + ); + const current = this._runMode || 'claude'; + select.value = modes.some((m) => m.id === current) ? current : modes[0].id; + }, + + _selectedWorktreeMode() { + return document.getElementById('worktreeMode')?.value || this._runMode || 'claude'; + }, + + _renderWorktreeList() { + const list = document.getElementById('worktreeList'); + if (!list) return; + list.replaceChildren(); + const data = this._worktreeData; + if (!data) return; + for (const wt of data.worktrees) list.appendChild(this._buildWorktreeRow(wt)); + if (!data.worktrees.some((wt) => wt.managed)) { + const empty = document.createElement('div'); + empty.className = 'worktree-empty'; + empty.textContent = 'No worktrees yet. Create one below.'; + list.appendChild(empty); + } + }, + + _buildWorktreeRow(wt) { + const row = document.createElement('div'); + row.className = 'worktree-row' + (wt.managed ? ' worktree-row--managed' : ''); + const head = document.createElement('div'); + head.className = 'worktree-row-head'; + + const label = document.createElement('span'); + label.className = 'worktree-row-name'; + label.setAttribute('data-i18n-skip', ''); + label.textContent = wt.managed ? `⑂ ${wt.name}` : wt.path.split('/').pop() || wt.path; + head.appendChild(label); + + const badge = (text, extra = '', skip = false) => { + const b = document.createElement('span'); + b.className = `worktree-badge${extra ? ` ${extra}` : ''}`; + if (skip) b.setAttribute('data-i18n-skip', ''); + b.textContent = text; + head.appendChild(b); + }; + if (wt.branch) badge(wt.branch, 'worktree-badge-branch', true); + else if (wt.detached) badge('detached HEAD', 'worktree-badge-branch'); + if (wt.main) badge('main checkout'); + else if (!wt.managed) badge('not created by Codeman', 'worktree-badge-dim'); + if (wt.locked) badge('locked', 'worktree-badge-warn'); + if (wt.prunable) badge('missing', 'worktree-badge-warn'); + if (wt.sessionCount > 0) { + badge(`${wt.sessionCount} session${wt.sessionCount === 1 ? '' : 's'}`, 'worktree-badge-live'); + } + + if (wt.managed) { + const actions = document.createElement('span'); + actions.className = 'worktree-row-actions'; + // A worktree whose folder is gone (`prunable`) cannot host a session, but Remove still + // clears git's record of it, which is what frees its name for a new worktree. + if (!wt.prunable) { + const open = document.createElement('button'); + open.type = 'button'; + open.className = 'wt-btn wt-btn-sm'; + open.textContent = 'Open'; + open.title = 'Start a session in this worktree'; + open.addEventListener('click', () => void this.openWorktreeSession(wt.name)); + actions.appendChild(open); + } + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'wt-btn wt-btn-sm wt-btn-danger'; + remove.textContent = 'Remove…'; + remove.addEventListener('click', () => this.confirmWorktreeRemoval(wt.name)); + actions.appendChild(remove); + head.appendChild(actions); + } + row.appendChild(head); + + // A managed worktree's folder is always `.claude/worktrees/` of the case: say just that, + // so the long repository prefix (already on the main row) is not repeated on every row. + const path = document.createElement('div'); + path.className = 'worktree-row-path'; + path.setAttribute('data-i18n-skip', ''); + path.textContent = wt.managed ? `.claude/worktrees/${wt.name}` : this._shortenHomePath(wt.path); + path.title = wt.path; + row.appendChild(path); + + if (wt.managed && this._worktreeRemoveConfirm === wt.name) row.appendChild(this._buildWorktreeRemoveConfirm(wt)); + return row; + }, + + _buildWorktreeRemoveConfirm(wt) { + const box = document.createElement('div'); + box.className = 'worktree-remove-confirm'; + const note = document.createElement('p'); + note.className = 'worktree-remove-note'; + note.textContent = wt.prunable + ? 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.' + : 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.'; + box.appendChild(note); + + let checkbox = null; + if (wt.branch) { + const lbl = document.createElement('label'); + lbl.className = 'worktree-remove-branch'; + checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const text = document.createElement('span'); + text.textContent = 'Also delete its branch (git branch -d keeps a branch that is not merged)'; + lbl.append(checkbox, text); + box.appendChild(lbl); + } + + if (this._worktreeRemoveError?.name === wt.name) { + const err = document.createElement('pre'); + err.className = 'worktree-remove-error'; + err.setAttribute('data-i18n-skip', ''); + err.textContent = this._worktreeRemoveError.text; + box.appendChild(err); + } + + const buttons = document.createElement('div'); + buttons.className = 'worktree-remove-buttons'; + const cancel = document.createElement('button'); + cancel.type = 'button'; + cancel.className = 'wt-btn wt-btn-sm'; + cancel.textContent = 'Cancel'; + cancel.addEventListener('click', () => this.cancelWorktreeRemoval()); + const confirm = document.createElement('button'); + confirm.type = 'button'; + confirm.className = 'wt-btn wt-btn-sm wt-btn-danger'; + confirm.textContent = 'Remove worktree'; + confirm.addEventListener('click', async () => { + confirm.disabled = true; + try { + await this.removeWorktreeFromModal(wt.name, !!checkbox?.checked); + } finally { + confirm.disabled = false; + } + }); + buttons.append(cancel, confirm); + box.appendChild(buttons); + return box; + }, + + confirmWorktreeRemoval(name) { + this._worktreeRemoveConfirm = name; + this._worktreeRemoveError = null; + this._renderWorktreeList(); + }, + + cancelWorktreeRemoval() { + this._worktreeRemoveConfirm = null; + this._worktreeRemoveError = null; + this._renderWorktreeList(); + }, + + async removeWorktreeFromModal(name, deleteBranch) { + const caseName = this._worktreeCase; + if (!caseName) return; + const r = await this.removeCaseWorktree(caseName, name, deleteBranch); + if (caseName !== this._worktreeCase) return; + if (!r.ok) { + this._worktreeRemoveError = { name, text: r.error }; + this._renderWorktreeList(); + return; + } + this._worktreeRemoveConfirm = null; + this._worktreeRemoveError = null; + await this._loadWorktrees(); + }, + + /** DELETE one worktree (never forced); shared by the dialog and Close Session. Toasts the outcome. */ + async removeCaseWorktree(caseName, name, deleteBranch) { + const query = deleteBranch ? '?deleteBranch=true' : ''; + const r = await this._worktreeRequest(this._worktreesUrl(caseName, `/${encodeURIComponent(name)}${query}`), { + method: 'DELETE', + }); + if (r.ok) { + const removed = r.data?.removed || { name }; + if (removed.branchError) { + this.showToast?.( + `Worktree ${removed.name} removed. Branch ${removed.branch} kept: ${removed.branchError}`, + 'warning' + ); + } else if (removed.branchDeleted) { + this.showToast?.(`Worktree ${removed.name} and branch ${removed.branch} removed`, 'success'); + } else { + this.showToast?.(`Worktree ${removed.name} removed`, 'success'); + } + } + return r; + }, + + async createWorktreeFromModal() { + const caseName = this._worktreeCase; + if (!caseName) return; + const name = (document.getElementById('worktreeName')?.value || '').trim(); + const branch = (document.getElementById('worktreeBranch')?.value || '').trim(); + const base = (document.getElementById('worktreeBase')?.value || '').trim(); + if (!WORKTREE_NAME_RE.test(name) || name.includes('..') || /\.(lock)?$/i.test(name)) { + this._setWorktreeFormError( + 'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.' + ); + return; + } + this._setWorktreeFormError(''); + const mode = this._selectedWorktreeMode(); + const btn = document.getElementById('worktreeCreateBtn'); + if (btn) btn.disabled = true; + try { + const r = await this._worktreeRequest(this._worktreesUrl(caseName), { + method: 'POST', + body: { name, ...(branch ? { branch } : {}), ...(base ? { base } : {}) }, + }); + if (caseName !== this._worktreeCase) return; + if (!r.ok) { + this._setWorktreeFormError(r.error); + return; + } + const created = r.data.worktree; + this.showToast?.(`Worktree ${created.name} created on branch ${created.branch}`, 'success'); + this.closeWorktreeModal(); + await this._launchInWorktree({ caseName, name: created.name, path: created.path }, mode); + } finally { + if (btn) btn.disabled = false; + } + }, + + async openWorktreeSession(name) { + const caseName = this._worktreeCase; + const wt = this._worktreeData?.worktrees?.find((w) => w.managed && w.name === name); + if (!caseName || !wt) return; + const mode = this._selectedWorktreeMode(); + this.closeWorktreeModal(); + await this._launchInWorktree({ caseName, name, path: wt.path }, mode); + }, + + /** + * Start ONE session of `mode` in a worktree through run(), the Run button's own entry (its + * launch lock, its per-mode paths), with the same temporary `_runMode` swap the custom-model + * entries use, so the Run button's chosen mode is untouched afterwards. + */ + async _launchInWorktree(worktree, mode) { + if (this._runInFlight) { + this.showToast?.('A launch is already in progress', 'info'); + return; + } + const previous = this._runMode; + this._runMode = mode || previous; + try { + await this.run({ count: 1, worktree }); + } finally { + this._runMode = previous; + } + }, + + /** The Codeman-managed worktree a session runs in, or null (see managedWorktreeOfDir). */ + _managedWorktreeOfSession(session) { + if (!session || session.remote || session.docker) return null; + return managedWorktreeOfDir(session.workingDir, this.cases); + }, + + /** + * Close Session's "Kill & remove worktree", once the session is gone. git's refusal (a dirty + * tree) is the answer: the worktree stays, and the toast says why and opens the dialog. + */ + async _removeWorktreeAfterClose(wt, deleteBranch) { + const r = await this.removeCaseWorktree(wt.caseName, wt.name, deleteBranch); + if (r.ok) { + if (this._worktreeCase === wt.caseName) void this._loadWorktrees(); + return; + } + this.showToast?.(`Worktree ${wt.name} was kept: ${r.error}`, 'error', { + duration: 15000, + action: { label: 'Worktrees…', onClick: () => this.openWorktreeModal(wt.caseName) }, + }); + }, }); Object.defineProperty(CodemanApp.prototype, 'runMode', { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f8efa7d75..15517d205 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -7793,6 +7793,9 @@ html[data-skin="og"] .welcome-primary:hover { .mobile-case-picker-footer { padding: 12px 20px 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); + /* Create New Case and Worktrees share the row, half each. */ + display: flex; + gap: 8px; } .btn-case-create-mobile { @@ -8148,6 +8151,249 @@ html[data-skin="og"] .welcome-primary:hover { border-top: 1px solid var(--border); } +/* Close Session: "Kill & remove worktree" (a session in a Codeman-managed git + worktree only). The wrapper re-asserts [hidden]: it lays out as a column. */ +.close-option-worktree { + display: flex; + flex-direction: column; + gap: 0.35rem; +} +.close-option-worktree[hidden] { + display: none; +} +.close-option-worktree-branch { + display: flex; + align-items: flex-start; + gap: 0.4rem; + font-size: 0.75rem; + color: var(--text-dim); + padding: 0 0.25rem; + cursor: pointer; +} +.close-option-worktree-branch input { + margin-top: 0.1rem; +} + +/* Git worktrees dialog (session-ui.js openWorktreeModal). Its own small button + set: the skin block greys `.btn-toolbar.btn-primary` on non-OG skins, and this + dialog needs a primary action that reads as one on every skin. */ +.modal-content.worktree-modal { + max-width: 640px; + max-height: 88vh; + display: flex; + flex-direction: column; +} +.worktree-modal > .modal-body { + overflow-y: auto; + min-height: 0; +} +.worktree-modal-case { + font-family: 'SF Mono', Monaco, monospace; + font-weight: 500; + color: var(--text-dim); +} +.worktree-intro { + margin: 0 0 0.6rem; + font-size: 0.72rem; + line-height: 1.45; +} +.worktree-status { + font-size: 0.78rem; + color: var(--text-dim); + margin-bottom: 0.6rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.worktree-status[hidden], +.worktree-form-error[hidden] { + display: none; +} +.worktree-status-error, +.worktree-form-error { + color: var(--red); +} +.worktree-form-error { + font-size: 0.75rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + margin-top: 0.25rem; +} +.worktree-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 0.9rem; +} +.worktree-row { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.65rem; + background: var(--control-bg); +} +.worktree-row--managed { + border-color: var(--border-light); +} +.worktree-row-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 0.45rem; +} +.worktree-row-name { + font-weight: 600; + font-size: 0.85rem; + color: var(--text); + overflow-wrap: anywhere; +} +.worktree-badge { + font-size: 0.66rem; + line-height: 1.5; + padding: 0 0.4rem; + border-radius: 999px; + border: 1px solid var(--border-light); + color: var(--text-dim); + white-space: nowrap; +} +.worktree-badge-branch { + font-family: 'SF Mono', Monaco, monospace; + color: var(--accent); + border-color: rgba(var(--accent-rgb), 0.35); + white-space: normal; + overflow-wrap: anywhere; +} +.worktree-badge-dim { + color: var(--text-muted); +} +.worktree-badge-warn { + color: var(--yellow); + border-color: var(--yellow); +} +.worktree-badge-live { + color: var(--green); + border-color: var(--green); +} +.worktree-row-actions { + display: inline-flex; + gap: 0.35rem; + margin-left: auto; +} +.worktree-row-path { + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.68rem; + color: var(--text-muted); + margin-top: 0.25rem; + overflow-wrap: anywhere; +} +.worktree-empty { + font-size: 0.75rem; + color: var(--text-muted); + padding: 0.25rem 0.1rem; +} +.worktree-remove-confirm { + margin-top: 0.5rem; + padding-top: 0.5rem; + border-top: 1px dashed var(--border-light); + display: flex; + flex-direction: column; + gap: 0.4rem; +} +.worktree-remove-note { + margin: 0; + font-size: 0.72rem; + color: var(--text-dim); +} +.worktree-remove-branch { + display: flex; + align-items: flex-start; + gap: 0.4rem; + font-size: 0.72rem; + color: var(--text-dim); + cursor: pointer; +} +.worktree-remove-error { + margin: 0; + font-size: 0.72rem; + color: var(--red); + white-space: pre-wrap; + overflow-wrap: anywhere; + background: rgba(224, 99, 99, 0.08); + border-radius: 6px; + padding: 0.4rem 0.5rem; +} +.worktree-remove-buttons { + display: flex; + justify-content: flex-end; + gap: 0.4rem; +} +.worktree-mode-row { + max-width: 320px; +} +.worktree-new { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.6rem 0.75rem 0.4rem; + margin: 0; + min-width: 0; +} +.worktree-new > legend { + font-size: 0.72rem; + font-weight: 600; + color: var(--text-dim); + padding: 0 0.3rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.worktree-advanced { + margin-bottom: 0.5rem; +} +.worktree-advanced > summary { + cursor: pointer; + font-size: 0.75rem; + color: var(--text-dim); + margin-bottom: 0.5rem; +} +.wt-btn { + font: inherit; + font-size: 0.8rem; + padding: 0.4rem 0.85rem; + border-radius: var(--btn-radius); + border: 1px solid var(--control-border); + background: var(--control-bg); + color: var(--text); + cursor: pointer; + transition: background var(--transition-smooth), border-color var(--transition-smooth); +} +.wt-btn:hover:not(:disabled) { + background: var(--control-bg-hover); + border-color: var(--control-border-hover); +} +.wt-btn:disabled { + opacity: 0.5; + cursor: default; +} +.wt-btn-sm { + font-size: 0.72rem; + padding: 0.2rem 0.6rem; +} +.wt-btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-ink); + font-weight: 600; +} +.wt-btn-primary:hover:not(:disabled) { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +.wt-btn-danger { + color: var(--red); + border-color: rgba(224, 99, 99, 0.45); +} +.wt-btn-danger:hover:not(:disabled) { + background: rgba(224, 99, 99, 0.12); + border-color: var(--red); +} + .modal-session-name { font-family: 'SF Mono', Monaco, monospace; font-size: 0.8rem; diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 7d9be0b15..aa1d721fd 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -17,6 +17,7 @@ export { registerReadMyMindRoutes } from './readmymind-routes.js'; export { registerGitStatusRoutes } from './git-status-routes.js'; export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js'; export { registerCaseRoutes } from './case-routes.js'; +export { registerWorktreeRoutes } from './worktree-routes.js'; export { registerSessionRoutes } from './session-routes.js'; export { registerRespawnRoutes } from './respawn-routes.js'; export { registerRalphRoutes } from './ralph-routes.js'; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc5533..82a6a7b7e 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -181,6 +181,7 @@ import { readDockerHosts, toSessionDocker, } from '../../docker-hosts.js'; +import { resolveCaseRepository, resolveManagedWorktree } from '../../git-worktrees.js'; import { LRUMap } from '../../utils/lru-map.js'; import { describeUnknownPath, probePathKind } from '../../utils/index.js'; import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js'; @@ -3675,6 +3676,7 @@ export function registerSessionRoutes( agentOrigin, customModel, tabGroupId, + worktree, } = parseBody(QuickStartSchema, req.body); // Resolved ONCE here: the same value labels a case directory this request creates @@ -3708,6 +3710,16 @@ export function registerSessionRoutes( : (await readDockerCases(CODEMAN_CONFIG_DIR)).find( (item) => item.name === caseName && canAccessOwned(getAuthUser(req), item.owner) ); + // Worktrees are local only (src/git-worktrees.ts): refused BEFORE a remote host is woken or a + // container is touched, since neither branch below could honour it. + if (worktree && (remoteCase || dockerCase)) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + remoteCase + ? 'worktree is not available for remote (SSH) cases: the repository is on another machine' + : 'worktree is not available for Docker cases: git would run on this machine over a workspace the container can write' + ); + } if (remoteCase) { const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId); if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found'); @@ -3949,6 +3961,27 @@ export function registerSessionRoutes( ); } + // Where the session runs: the case folder, or one of its git worktrees. A worktree is only + // ever USED here (created through POST /api/cases/:name/worktrees), so a case that does not + // exist yet is a 404 rather than a scaffold, and the worktree must be a registered, managed + // one, every path component a real directory (src/git-worktrees.ts). + let workspacePath = resolvedCasePath; + if (worktree) { + if (localCaseState === 'absent') { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${caseName}" not found`); + } + if (localCaseState !== 'directory') { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Case path is not a folder'); + } + const caseRepo = await resolveCaseRepository(resolvedCasePath, { + dockerWorkspaces: (await readDockerCases(CODEMAN_CONFIG_DIR)).map((c) => c.hostWorkspacePath).filter(Boolean), + }); + if (!caseRepo.ok) return createErrorResponse(ApiErrorCode[caseRepo.code], caseRepo.message); + const managed = await resolveManagedWorktree(caseRepo.repo, worktree); + if (!managed.ok) return createErrorResponse(ApiErrorCode[managed.code], managed.message); + workspacePath = managed.path; + } + // Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote, // non-docker cases — docker workspaces are scaffolded in their own block below) if (localCaseState === 'absent') { @@ -4007,9 +4040,9 @@ export function registerSessionRoutes( // of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that // doesn't exist on the local filesystem. if (getCli(mode)?.capabilities.hooks === 'always') { - await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled()); + await applyWorkspaceHooks(workspacePath, await ctx.getWorkspaceHooksEnabled()); } else { - await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); + await refreshStaleCodemanHooks(workspacePath).catch(() => {}); } } @@ -4021,7 +4054,7 @@ export function registerSessionRoutes( // hostWorkspacePath is a real host dir and the skill crosses the bind mount. const quickStartSkillDir = agentSkillDir(getCli(mode)?.capabilities); if (!remote && quickStartSkillDir && (await ctx.getAgentSkillEnabled())) { - await injectAgentSkill(resolvedCasePath, quickStartSkillDir); + await injectAgentSkill(workspacePath, quickStartSkillDir); } // Docker cases: the workspace is a REAL host dir bind-mounted into the container. @@ -4054,14 +4087,14 @@ export function registerSessionRoutes( // docker — the docker workspace is a real host dir, so the settings file crosses // the bind mount and the in-container claude reads it). Remote was rejected above. if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) { - await updateCaseModel(resolvedCasePath, modelOverride || null); + await updateCaseModel(workspacePath, modelOverride || null); } // Strip stale disk entries for keys this request is actively setting (Claude only — // see POST /api/sessions for full rationale). // Same chain, same replacement as the create path above: byte-identical, drift-proof. if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) { - await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides)); + await stripCaseEnvKeys(workspacePath, Object.keys(envOverrides)); } // Create a new session with the case as working directory @@ -4117,7 +4150,7 @@ export function registerSessionRoutes( ); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides); - const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig); + const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, workspacePath, qsLaunchOmpConfig); // Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md) // rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom- @@ -4223,7 +4256,7 @@ export function registerSessionRoutes( customModel.modelId, qsCustomModelSessionId, cmContextLength, - resolvedCasePath + workspacePath ); if (!cmApplied) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`); @@ -4264,7 +4297,7 @@ export function registerSessionRoutes( const qsSessionOptions: ConstructorParameters[0] = { id: qsCustomModelSessionId, - workingDir: resolvedCasePath, + workingDir: workspacePath, name: sessionName ? sessionName.slice(0, MAX_SESSION_NAME_LENGTH) : '', mux: ctx.mux, useMux: true, @@ -4322,7 +4355,7 @@ export function registerSessionRoutes( // Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting // so the initial state already has the phrase configured (only if globally enabled) if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) { - autoConfigureRalph(session, resolvedCasePath, ctx); + autoConfigureRalph(session, workspacePath, ctx); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart @@ -4414,6 +4447,7 @@ export function registerSessionRoutes( sessionId: session.id, casePath: resolvedCasePath, caseName, + ...(worktree ? { worktree, workingDir: workspacePath } : {}), ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}), ...(tabGroupId ? { tabLayout } : {}), }; diff --git a/src/web/routes/worktree-routes.ts b/src/web/routes/worktree-routes.ts new file mode 100644 index 000000000..7c0f5d029 --- /dev/null +++ b/src/web/routes/worktree-routes.ts @@ -0,0 +1,291 @@ +/** + * @fileoverview Git worktrees of a local case (src/git-worktrees.ts holds the rules): + * + * GET /api/cases/:name/worktrees the case repository's worktrees, which of them + * Codeman manages, and the live sessions in each + * POST /api/cases/:name/worktrees create `/.claude/worktrees/` on a + * new branch (`{ name, branch?, base? }`) + * DELETE /api/cases/:name/worktrees/:worktree `git worktree remove` WITHOUT --force, refused + * while a live session runs inside; optional + * `?deleteBranch=true` is `git branch -d` + * + * A session starts in a worktree through the EXISTING create paths, never a new spawn path here: + * `POST /api/quick-start` with `worktree: ` (validated against this module's rules), or + * `POST /api/sessions` with the worktree path as `workingDir`. + * + * The case is resolved the way quick-start resolves it: an owned remote or Docker case is refused + * with a 400 (git must never run on the host over a Docker workspace, and a remote case's folder + * is on another machine); a linked case resolves for admins only in multi-user mode; anything else + * is the caller's own case space, confined with `isWorkingDirAllowed`. The case folder is probed + * BOUNDED (#516): a case on a network mount that stopped answering is a 422, never a 404. + * + * Statuses are set explicitly from the error code, so the route answers the same with or without + * the server's envelope hook. + */ + +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, httpStatusForErrorCode, type ApiResponse } from '../../types.js'; +import { readDockerCases } from '../../docker-hosts.js'; +import { readRemoteCases } from '../../remote-hosts.js'; +import { dataPath, getDataDir } from '../../config/instance.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { listClis } from '../../config/cli-registry/registry.js'; +import { agentSkillDir } from '../../hooks-config.js'; +import { describeUnknownPath, probePathKind } from '../../utils/index.js'; +import { + createCaseWorktree, + isPathWithin, + listCaseWorktrees, + managedWorktreesDir, + removeCaseWorktree, + resolveCaseRepository, + respellUnder, + worktreeNameError, + type CaseRepository, + type CaseWorktree, + type CreatedWorktree, + type RemovedWorktree, + type WorktreeFailure, + type WorktreeGitRunner, +} from '../../git-worktrees.js'; +import { CreateWorktreeSchema, RemoveWorktreeQuerySchema } from '../schemas.js'; +import { + canAccessOwned, + getAuthUser, + isAdmin, + isWorkingDirAllowed, + parseBody, + readJsonConfig, + resolveCasesDir, + validatePathWithinBase, +} from '../route-helpers.js'; +import type { SessionPort } from '../ports/index.js'; + +const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; + +/** Host paths of every Docker case workspace: git never runs for a case at or inside one. */ +const defaultDockerWorkspaces = async (): Promise => + (await readDockerCases(getDataDir()).catch(() => [])).map((c) => c.hostWorkspacePath).filter(Boolean); + +/** + * Every workspace skills folder a session of some registry CLI gets Codeman's agent skill in + * (`capabilities.agentSkillDir`), read from the registry at call time as data, never by CLI id. + */ +const registryAgentSkillDirs = (): string[] => { + const dirs = new Set(); + for (const cli of listClis()) { + const dir = agentSkillDir(cli.capabilities); + if (dir) dirs.add(dir); + } + return [...dirs]; +}; + +/** A live session in a worktree, as the list reports it to a caller who may see it. */ +export interface WorktreeSessionRef { + id: string; + name: string; +} + +/** One row of `GET /api/cases/:name/worktrees`. */ +export interface CaseWorktreeInfo extends CaseWorktree { + /** Live sessions running in this worktree (deepest worktree wins), all owners. */ + sessionCount: number; + /** The ones the caller may see (all of them outside multi-user mode). */ + sessions: WorktreeSessionRef[]; +} + +export interface CaseWorktreeList { + caseName: string; + repoRoot: string; + /** `/.claude/worktrees`, where Codeman creates and removes worktrees. */ + worktreesDir: string; + worktrees: CaseWorktreeInfo[]; + truncated: boolean; +} + +type Fail = ApiResponse; + +function failWith(reply: FastifyReply, code: ApiErrorCode, message: string): Fail { + reply.code(httpStatusForErrorCode(code)); + return createErrorResponse(code, message); +} + +function failFrom(reply: FastifyReply, failure: WorktreeFailure): Fail { + return failWith(reply, ApiErrorCode[failure.code], failure.message); +} + +/** + * The LOCAL folder of case `name` for this caller, or the refusal. Mirrors quick-start's + * resolution (remote, then Docker, then linked, then the caller's case space) so a name means + * the same case here as it does to the Run button. + */ +async function resolveLocalCaseFolder( + req: FastifyRequest, + name: string +): Promise<{ ok: true; casePath: string } | { ok: false; code: ApiErrorCode; message: string }> { + if (!SAFE_CASE_NAME.test(name)) { + return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case name' }; + } + const user = getAuthUser(req); + const configDir = getDataDir(); + // Ownership folded into the match, as quick-start does: a foreign same-named remote/Docker + // case falls through to the caller's own local case instead of shadowing it. + if ((await readRemoteCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) { + return { + ok: false, + code: ApiErrorCode.INVALID_INPUT, + message: 'Worktrees are not available for remote (SSH) cases: the repository is on another machine.', + }; + } + if ((await readDockerCases(configDir)).some((c) => c.name === name && canAccessOwned(user, c.owner))) { + return { + ok: false, + code: ApiErrorCode.INVALID_INPUT, + message: + 'Worktrees are not available for Docker cases: a container can write to the workspace, and git would run its hooks and filters on this machine.', + }; + } + const linkedCases = await readJsonConfig>(dataPath('linked-cases.json'), 'linked cases', {}); + // Linked cases carry no owner: only admins resolve them in multi-user mode. + const linked = !isMultiUserMode() || isAdmin(req) ? linkedCases[name] : undefined; + const casePath = linked || validatePathWithinBase(name, resolveCasesDir(user)); + if (!casePath) return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Invalid case path' }; + if (!isWorkingDirAllowed(user, casePath)) { + return { ok: false, code: ApiErrorCode.FORBIDDEN, message: 'case path is outside your workspace' }; + } + const kind = await probePathKind(casePath, { pastCap: true }); + if (kind === 'unknown') { + return { + ok: false, + code: ApiErrorCode.OPERATION_FAILED, + message: describeUnknownPath('Case folder', casePath, { pastCap: true }), + }; + } + if (kind === 'absent') return { ok: false, code: ApiErrorCode.NOT_FOUND, message: `Case "${name}" not found` }; + if (kind !== 'directory') + return { ok: false, code: ApiErrorCode.INVALID_INPUT, message: 'Case path is not a folder' }; + return { ok: true, casePath }; +} + +/** + * Live local sessions, with the folder each runs in as the case's repository spells it + * (`respellUnder`: a folder named through the case's own path, which can run through a symlink, + * is moved onto the repository's real top level, where git lists the worktrees). Remote sessions + * have no local folder. + */ +function liveLocalSessions( + ctx: SessionPort, + casePath: string, + repoRoot: string +): Array<{ id: string; name: string; owner?: string; dir: string }> { + const out: Array<{ id: string; name: string; owner?: string; dir: string }> = []; + for (const session of ctx.sessions.values()) { + if (session.remote || !session.workingDir || !session.workingDir.startsWith('/')) continue; + const dir = respellUnder(session.workingDir, casePath, repoRoot); + out.push({ id: session.id, name: session.name, owner: session.owner, dir }); + } + return out; +} + +export function registerWorktreeRoutes( + app: FastifyInstance, + ctx: SessionPort, + deps: { git?: WorktreeGitRunner; dockerWorkspaces?: () => Promise } = {} +): void { + const dockerWorkspaces = deps.dockerWorkspaces ?? defaultDockerWorkspaces; + + /** + * Case folder → its repository, or the refusal already rendered. `casePath` is the folder as + * the case resolves it (possibly through a symlink); `repo.repoRoot` is its real path. + */ + async function caseRepository( + req: FastifyRequest, + reply: FastifyReply, + name: string + ): Promise<{ ok: true; repo: CaseRepository; casePath: string } | { ok: false; fail: Fail }> { + const folder = await resolveLocalCaseFolder(req, name); + if (!folder.ok) return { ok: false, fail: failWith(reply, folder.code, folder.message) }; + const repo = await resolveCaseRepository(folder.casePath, { + git: deps.git, + dockerWorkspaces: await dockerWorkspaces(), + }); + if (!repo.ok) return { ok: false, fail: failFrom(reply, repo) }; + return { ok: true, repo: repo.repo, casePath: folder.casePath }; + } + + app.get('/api/cases/:name/worktrees', async (req, reply): Promise> => { + const { name } = req.params as { name: string }; + const resolved = await caseRepository(req, reply, name); + if (!resolved.ok) return resolved.fail; + const { repo, casePath } = resolved; + const listed = await listCaseWorktrees(repo, { git: deps.git }); + if (!listed.ok) return failFrom(reply, listed); + + // Each live session belongs to the DEEPEST worktree holding its folder, so a session in a + // managed worktree is not also counted against the main checkout that contains it. + const user = getAuthUser(req); + const rows: CaseWorktreeInfo[] = listed.worktrees.map((wt) => ({ ...wt, sessionCount: 0, sessions: [] })); + for (const session of liveLocalSessions(ctx, casePath, repo.repoRoot)) { + let best: CaseWorktreeInfo | undefined; + for (const row of rows) { + if (!isPathWithin(session.dir, row.path)) continue; + if (!best || row.path.length > best.path.length) best = row; + } + if (!best) continue; + best.sessionCount += 1; + if (canAccessOwned(user, session.owner)) best.sessions.push({ id: session.id, name: session.name }); + } + return { + success: true, + data: { + caseName: name, + repoRoot: repo.repoRoot, + worktreesDir: managedWorktreesDir(repo.repoRoot), + worktrees: rows, + truncated: listed.truncated, + }, + }; + }); + + app.post('/api/cases/:name/worktrees', async (req, reply): Promise> => { + const { name } = req.params as { name: string }; + const body = parseBody(CreateWorktreeSchema, req.body); + const nameError = worktreeNameError(body.name); + if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError); + const resolved = await caseRepository(req, reply, name); + if (!resolved.ok) return resolved.fail; + const created = await createCaseWorktree(resolved.repo, body, { + git: deps.git, + agentSkillDirs: registryAgentSkillDirs(), + }); + if (!created.ok) return failFrom(reply, created); + reply.code(201); + return { success: true, data: { worktree: created.worktree } }; + }); + + app.delete( + '/api/cases/:name/worktrees/:worktree', + async (req, reply): Promise> => { + const { name, worktree } = req.params as { name: string; worktree: string }; + const query = parseBody(RemoveWorktreeQuerySchema, req.query ?? {}); + const nameError = worktreeNameError(worktree); + if (nameError) return failWith(reply, ApiErrorCode.INVALID_INPUT, nameError); + const resolved = await caseRepository(req, reply, name); + if (!resolved.ok) return resolved.fail; + const removed = await removeCaseWorktree( + resolved.repo, + { + name: worktree, + deleteBranch: query.deleteBranch === 'true' || query.deleteBranch === '1', + // Any owner's session blocks: removing the folder under a running agent is the damage + // this refusal exists to prevent, whoever started it. + isInUse: (path) => + liveLocalSessions(ctx, resolved.casePath, resolved.repo.repoRoot).some((s) => isPathWithin(s.dir, path)), + }, + { git: deps.git } + ); + if (!removed.ok) return failFrom(reply, removed); + return { success: true, data: { removed: removed.removed } }; + } + ); +} diff --git a/src/web/schemas.ts b/src/web/schemas.ts index f40a2a163..48281ca62 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -759,6 +759,34 @@ export const ClonePreflightSchema = z.object({ repository: z.string().min(1).max(2048), }); +// ========== Git worktrees of a case (src/git-worktrees.ts) ========== + +/** + * A worktree name: one segment of letters, digits, `.`, `_`, `-`, starting with a letter or + * digit, at most 64 characters (Claude Code's own `--worktree` limit). The finer rules (no `..`, + * no trailing `.lock`) are `worktreeNameError()`, which every route applies after this. + */ +const worktreeNameSchema = z + .string() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, 'Invalid worktree name. Use letters, digits, ".", "_" and "-".'); + +/** Schema for POST /api/cases/:name/worktrees: a new worktree on a new branch. */ +export const CreateWorktreeSchema = z.object({ + name: worktreeNameSchema, + /** New branch name; default `worktree-`, Claude Code's convention. */ + branch: z.string().min(1).max(200).optional(), + /** Start point (branch, tag or commit); default the case's current HEAD. */ + base: z.string().min(1).max(200).optional(), +}); + +/** Query of DELETE /api/cases/:name/worktrees/:worktree. */ +export const RemoveWorktreeQuerySchema = z.object({ + /** Also `git branch -d` the worktree's branch (never `-D`). */ + deleteBranch: z.enum(['true', 'false', '1', '0']).optional(), +}); + const RemoteCommandOverridesSchema = z .object({ shell: z.string().min(1).max(300).optional(), @@ -1139,6 +1167,13 @@ export const QuickStartSchema = z.object({ parentSessionId: parentSessionIdSchema, /** Tab group to place the new session in: see tabGroupIdSchema. */ tabGroupId: tabGroupIdSchema, + /** + * Start the session in this git worktree of the case (`/.claude/worktrees/`, + * created through POST /api/cases/:name/worktrees) instead of the case folder. Local cases + * only: refused (400) for remote and Docker cases. The worktree must be a registered worktree + * of the case's repository; it is never created here. + */ + worktree: worktreeNameSchema.optional(), /** Model override written to /.claude/settings.local.json (e.g. "opus[1m]"). * Empty string clears. Applied for local AND docker cases (the docker workspace is * a real host dir, so the settings file crosses the bind mount); rejected for diff --git a/src/web/server.ts b/src/web/server.ts index 2b11b4b38..a34d85419 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -191,6 +191,7 @@ import { registerStatusTelemetryRoutes, registerSystemRoutes, registerCaseRoutes, + registerWorktreeRoutes, registerSessionRoutes, registerRespawnRoutes, registerRalphRoutes, @@ -1153,6 +1154,7 @@ export class WebServer extends EventEmitter { registerStatusTelemetryRoutes(this.app, ctx); registerSystemRoutes(this.app, ctx); registerCaseRoutes(this.app, ctx); + registerWorktreeRoutes(this.app, ctx); // The registry's lifetime is the server's: it drops per-session wake state on every // cleanup path and resolves in-flight wakes on shutdown. this.remoteWake = registerSessionRoutes(this.app, ctx); diff --git a/test/git-worktrees.test.ts b/test/git-worktrees.test.ts new file mode 100644 index 000000000..8872c1850 --- /dev/null +++ b/test/git-worktrees.test.ts @@ -0,0 +1,437 @@ +/** + * @fileoverview src/git-worktrees.ts: the pure rules (worktree and branch names, `worktree list` + * parsing, the managed-path rule, exclude text) without git, then the IO half against a REAL temp + * repository (create, list, exclude, symlink guards, remove without --force, `branch -d`), plus an + * injected runner proving the Docker refusal never spawns git at all. Port: N/A. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + agentSkillExcludeLine, + createCaseWorktree, + defaultWorktreeBranch, + excludeAppendText, + gitRefusalText, + isPathWithin, + listCaseWorktrees, + managedWorktreeName, + parseWorktreeList, + removeCaseWorktree, + resolveCaseRepository, + resolveManagedWorktree, + respellUnder, + unquoteGitPath, + worktreeBaseError, + worktreeBranchError, + worktreeNameError, + type CaseRepository, + type WorktreeGitRunner, +} from '../src/git-worktrees.js'; + +const ENV = { + ...process.env, + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + +describe('worktreeNameError', () => { + it.each(['fix-login', 'a', 'v1.2', 'under_score', 'A9', 'x'.repeat(64)])('accepts %s', (name) => { + expect(worktreeNameError(name)).toBeNull(); + }); + it.each([ + ['', 'required'], + ['x'.repeat(65), 'at most 64'], + ['-flag', 'start with a letter'], + ['.hidden', 'start with a letter'], + ['a/b', 'only letters'], + ['a b', 'only letters'], + ['a..b', '".."'], + ['name.', 'end with'], + ['name.lock', 'end with'], + ['semi;colon', 'only letters'], + ['ünï', 'only letters'], + ])('refuses %j', (name, why) => { + expect(worktreeNameError(name)).toContain(why); + }); + it('refuses a non-string', () => { + expect(worktreeNameError(undefined)).not.toBeNull(); + expect(worktreeNameError(42)).not.toBeNull(); + }); +}); + +describe('branch and base gates', () => { + it('derives Claude Code’s default branch name', () => { + expect(defaultWorktreeBranch('fix-login')).toBe('worktree-fix-login'); + }); + it.each(['main', 'feat/login', 'worktree-a.b', 'user/x_y-z'])('accepts branch %s', (b) => { + expect(worktreeBranchError(b)).toBeNull(); + }); + it.each(['-D', '@{-1}', 'a..b', 'a//b', 'trail/', 'x.lock', 'feat/.hidden', 'sp ace', 'a~1', 'a^', 'a:b'])( + 'refuses branch %j', + (b) => { + expect(worktreeBranchError(b)).not.toBeNull(); + } + ); + it('gates the base like Clone Repo’s ref', () => { + expect(worktreeBaseError('HEAD')).toBeNull(); + expect(worktreeBaseError('origin/main')).toBeNull(); + expect(worktreeBaseError('--upload-pack=x')).not.toBeNull(); + expect(worktreeBaseError('HEAD@{1}')).not.toBeNull(); + }); +}); + +describe('paths', () => { + it('names only /.claude/worktrees/', () => { + expect(managedWorktreeName('/r', '/r/.claude/worktrees/a')).toBe('a'); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/a/')).toBe('a'); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/a/sub')).toBeNull(); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/')).toBeNull(); + expect(managedWorktreeName('/r', '/r/../x/.claude/worktrees/a')).toBeNull(); + expect(managedWorktreeName('/r', '/other/.claude/worktrees/a')).toBeNull(); + expect(managedWorktreeName('/r', '/r/.claude/worktrees/-x')).toBeNull(); + }); + it('isPathWithin is a segment match, not a string prefix', () => { + expect(isPathWithin('/r/.claude/worktrees/a', '/r/.claude/worktrees/a')).toBe(true); + expect(isPathWithin('/r/.claude/worktrees/a/src', '/r/.claude/worktrees/a')).toBe(true); + expect(isPathWithin('/r/.claude/worktrees/ab', '/r/.claude/worktrees/a')).toBe(false); + }); + it('respellUnder moves a path named through the case link onto the real repository root', () => { + expect(respellUnder('/link/.claude/worktrees/a', '/link', '/real')).toBe('/real/.claude/worktrees/a'); + expect(respellUnder('/link/.claude/worktrees/a/src/', '/link', '/real')).toBe('/real/.claude/worktrees/a/src'); + expect(respellUnder('/link', '/link', '/real')).toBe('/real'); + // Not under the alias (a segment match, never a string prefix), or no alias at all: unchanged. + expect(respellUnder('/linked/x', '/link', '/real')).toBe('/linked/x'); + expect(respellUnder('/real/.claude/worktrees/a', '/link', '/real')).toBe('/real/.claude/worktrees/a'); + expect(respellUnder('/real/x', '/real', '/real')).toBe('/real/x'); + }); +}); + +describe('parseWorktreeList', () => { + it('parses main, branch, detached, locked and prunable records', () => { + const text = [ + 'worktree /repo', + 'HEAD 1111111111111111111111111111111111111111', + 'branch refs/heads/main', + '', + 'worktree /repo/.claude/worktrees/a', + 'HEAD 2222222222222222222222222222222222222222', + 'branch refs/heads/worktree-a', + 'locked busy agent', + '', + 'worktree /elsewhere/b', + 'HEAD 3333333333333333333333333333333333333333', + 'detached', + 'prunable gitdir file points to non-existent location', + '', + ].join('\n'); + const list = parseWorktreeList(text); + expect(list).toHaveLength(3); + expect(list[0]).toMatchObject({ path: '/repo', branch: 'main', detached: false, locked: false }); + expect(list[1]).toMatchObject({ branch: 'worktree-a', locked: true, lockReason: 'busy agent' }); + expect(list[2]).toMatchObject({ branch: null, detached: true, prunable: true }); + }); + it('reads a bare record and a record with no trailing blank line', () => { + const list = parseWorktreeList('worktree /bare.git\nbare\n\nworktree /w\nHEAD abc\nbranch refs/heads/x'); + expect(list[0]).toMatchObject({ bare: true, head: null }); + expect(list[1]).toMatchObject({ path: '/w', branch: 'x' }); + }); + it('unquotes a C-quoted path', () => { + expect(parseWorktreeList('worktree "/r/a\\"b\\303\\251"\nHEAD x\n')[0].path).toBe('/r/a"bé'); + }); +}); + +describe('unquoteGitPath', () => { + it('leaves plain text alone and decodes escapes', () => { + expect(unquoteGitPath('/plain path')).toBe('/plain path'); + expect(unquoteGitPath('"/a\\tb\\\\c"')).toBe('/a\tb\\c'); + expect(unquoteGitPath('"/\\344\\275\\240"')).toBe('/你'); + }); +}); + +describe('excludeAppendText', () => { + it('adds only what is missing, after a newline the file lacked', () => { + const r = excludeAppendText('*.log', ['/.claude/worktrees/', '/.claude/settings.local.json']); + expect(r.added).toEqual(['/.claude/worktrees/', '/.claude/settings.local.json']); + expect(r.text.startsWith('\n# Added by Codeman')).toBe(true); + expect(r.text.endsWith('/.claude/settings.local.json\n')).toBe(true); + }); + it('is a no-op when every line is present', () => { + expect(excludeAppendText('/.claude/worktrees/\n', ['/.claude/worktrees/'])).toEqual({ text: '', added: [] }); + }); + it('agentSkillExcludeLine names the skill copy of a plain relative skills folder only', () => { + expect(agentSkillExcludeLine('.claude/skills')).toBe('/.claude/skills/codeman/'); + expect(agentSkillExcludeLine('.agents/skills')).toBe('/.agents/skills/codeman/'); + for (const bad of ['', '/abs', 'a/../b', 'a//b', '.', 'x/', 'a b', '*', '!neg']) { + expect(agentSkillExcludeLine(bad), bad).toBeNull(); + } + }); +}); + +describe('gitRefusalText', () => { + it('keeps fatal/error lines, drops hints and control bytes', () => { + const t = gitRefusalText( + "fatal: '.claude/worktrees/a' contains modified or untracked files, use --force to delete it\nhint: x\n\u001b[31m" + ); + expect(t).toBe("fatal: '.claude/worktrees/a' contains modified or untracked files, use --force to delete it"); + }); +}); + +// ─── IO against a real repository ──────────────────────────────────────────── + +describe('git worktrees on a real repository', () => { + let dir: string; + let repoDir: string; + let repo: CaseRepository; + + beforeEach(async () => { + dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-worktrees-'))); + repoDir = join(dir, 'repo'); + mkdirSync(repoDir); + git(repoDir, 'init', '-q', '-b', 'main'); + writeFileSync(join(repoDir, 'a.txt'), '1\n'); + git(repoDir, 'add', 'a.txt'); + git(repoDir, 'commit', '-q', '-m', 'base'); + const resolved = await resolveCaseRepository(repoDir, { home: '/nonexistent-home' }); + if (!resolved.ok) throw new Error(resolved.message); + repo = resolved.repo; + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('resolves the repository root and the real common git dir', () => { + expect(repo.repoRoot).toBe(repoDir); + expect(repo.commonDir).toBe(join(repoDir, '.git')); + }); + + it('refuses a folder that is not a repository, and one below the top level', async () => { + const plain = join(dir, 'plain'); + mkdirSync(plain); + const notRepo = await resolveCaseRepository(plain, { home: '/nonexistent-home' }); + expect(notRepo).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + mkdirSync(join(repoDir, 'sub')); + const sub = await resolveCaseRepository(join(repoDir, 'sub'), { home: '/nonexistent-home' }); + expect(sub.ok).toBe(false); + if (!sub.ok) expect(sub.message).toContain('top of a git repository'); + }); + + it('treats a repository at the home folder above the case as no repository', async () => { + mkdirSync(join(repoDir, 'proj')); + const r = await resolveCaseRepository(join(repoDir, 'proj'), { home: repoDir }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toBe('This case folder is not a git repository.'); + }); + + it('never spawns git for a case inside a Docker case workspace', async () => { + let spawned = 0; + const spy: WorktreeGitRunner = async () => { + spawned++; + return { code: 0, stdout: '', stderr: '', timedOut: false }; + }; + const r = await resolveCaseRepository(repoDir, { git: spy, dockerWorkspaces: [dir] }); + expect(r).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + expect(spawned).toBe(0); + }); + + it('creates /.claude/worktrees/ on worktree-, excluded from the main checkout', async () => { + const created = await createCaseWorktree(repo, { name: 'fix-login' }); + expect(created.ok).toBe(true); + if (!created.ok) return; + const wtPath = join(repoDir, '.claude', 'worktrees', 'fix-login'); + expect(created.worktree).toMatchObject({ path: wtPath, branch: 'worktree-fix-login', base: 'HEAD' }); + expect(created.worktree.excludeAdded).toEqual(['/.claude/worktrees/', '/.claude/settings.local.json']); + expect(readFileSync(join(wtPath, 'a.txt'), 'utf-8')).toBe('1\n'); + expect(git(wtPath, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('worktree-fix-login'); + // The main checkout stays clean: the worktree folder is excluded, not untracked. + expect(git(repoDir, 'status', '--porcelain')).toBe(''); + expect(readFileSync(join(repoDir, '.git', 'info', 'exclude'), 'utf-8')).toContain('/.claude/worktrees/'); + // A hooks file Codeman would write into the worktree does not make it dirty either. + mkdirSync(join(wtPath, '.claude'), { recursive: true }); + writeFileSync(join(wtPath, '.claude', 'settings.local.json'), '{}'); + expect(git(wtPath, 'status', '--porcelain')).toBe(''); + + // A second create with the same lines already present appends nothing. + const second = await createCaseWorktree(repo, { name: 'other', branch: 'feat/other' }); + expect(second.ok && second.worktree.excludeAdded).toEqual([]); + const listed = await listCaseWorktrees(repo); + expect(listed.ok).toBe(true); + if (!listed.ok) return; + expect(listed.worktrees.map((w) => [w.main, w.isCase, w.managed, w.name ?? null, w.branch])).toEqual([ + [true, true, false, null, 'main'], + [false, false, true, 'fix-login', 'worktree-fix-login'], + [false, false, true, 'other', 'feat/other'], + ]); + }); + + it('refuses an existing branch, a bad base, a bad branch and a taken folder before touching anything', async () => { + git(repoDir, 'branch', 'worktree-taken'); + expect(await createCaseWorktree(repo, { name: 'taken' })).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' }); + expect(await createCaseWorktree(repo, { name: 'x', base: 'nosuchref' })).toMatchObject({ + ok: false, + code: 'INVALID_INPUT', + }); + expect(await createCaseWorktree(repo, { name: 'x', branch: '@{-1}' })).toMatchObject({ + ok: false, + code: 'INVALID_INPUT', + }); + expect(await createCaseWorktree(repo, { name: '../escape' })).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + mkdirSync(join(repoDir, '.claude', 'worktrees', 'busy'), { recursive: true }); + expect(await createCaseWorktree(repo, { name: 'busy' })).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' }); + expect(git(repoDir, 'branch', '--list', 'worktree-busy')).toBe(''); + }); + + it('names a taken worktree name as such, not as its derived branch', async () => { + expect((await createCaseWorktree(repo, { name: 'dup' })).ok).toBe(true); + const again = await createCaseWorktree(repo, { name: 'dup' }); + expect(again).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' }); + if (!again.ok) expect(again.message).toBe('A worktree named "dup" already exists in this case.'); + }); + + it('says a bare repository is bare', async () => { + const bare = join(dir, 'bare.git'); + git(dir, 'init', '-q', '--bare', bare); + const r = await resolveCaseRepository(bare, { home: '/nonexistent-home' }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toContain('bare git repository'); + }); + + it('starts from a named base', async () => { + git(repoDir, 'checkout', '-q', '-b', 'side'); + writeFileSync(join(repoDir, 'b.txt'), '2\n'); + git(repoDir, 'add', 'b.txt'); + git(repoDir, 'commit', '-q', '-m', 'side'); + git(repoDir, 'checkout', '-q', 'main'); + const created = await createCaseWorktree(repo, { name: 'from-side', base: 'side' }); + expect(created.ok).toBe(true); + expect(readFileSync(join(repoDir, '.claude', 'worktrees', 'from-side', 'b.txt'), 'utf-8')).toBe('2\n'); + }); + + it('refuses to create through a symlinked .claude or worktrees directory', async () => { + const outside = join(dir, 'outside'); + mkdirSync(join(outside, 'worktrees'), { recursive: true }); + symlinkSync(outside, join(repoDir, '.claude')); + const viaClaude = await createCaseWorktree(repo, { name: 'evil' }); + expect(viaClaude).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + if (!viaClaude.ok) expect(viaClaude.message).toContain('symlink'); + rmSync(join(repoDir, '.claude')); + mkdirSync(join(repoDir, '.claude')); + symlinkSync(join(outside, 'worktrees'), join(repoDir, '.claude', 'worktrees')); + expect(await createCaseWorktree(repo, { name: 'evil' })).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('evil'); + }); + + it('removes a clean worktree, refuses a dirty one and a live one, and never force-deletes', async () => { + const created = await createCaseWorktree(repo, { name: 'w1' }); + expect(created.ok).toBe(true); + const wtPath = join(repoDir, '.claude', 'worktrees', 'w1'); + + const live = await removeCaseWorktree(repo, { name: 'w1', isInUse: (p) => p === wtPath }); + expect(live).toMatchObject({ ok: false, code: 'CONFLICT' }); + + writeFileSync(join(wtPath, 'scratch.txt'), 'untracked work'); + const dirty = await removeCaseWorktree(repo, { name: 'w1', isInUse: () => false }); + expect(dirty).toMatchObject({ ok: false, code: 'CONFLICT' }); + if (!dirty.ok) expect(dirty.gitMessage).toContain('contains modified or untracked files'); + expect(readFileSync(join(wtPath, 'scratch.txt'), 'utf-8')).toBe('untracked work'); + + rmSync(join(wtPath, 'scratch.txt')); + const clean = await removeCaseWorktree(repo, { name: 'w1', deleteBranch: true, isInUse: () => false }); + expect(clean).toMatchObject({ ok: true, removed: { name: 'w1', branch: 'worktree-w1', branchDeleted: true } }); + expect(git(repoDir, 'branch', '--list', 'worktree-w1')).toBe(''); + expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('w1'); + }); + + it('keeps an unmerged branch: git branch -d refuses, never -D', async () => { + await createCaseWorktree(repo, { name: 'w2' }); + const wtPath = join(repoDir, '.claude', 'worktrees', 'w2'); + writeFileSync(join(wtPath, 'new.txt'), 'x'); + git(wtPath, 'add', 'new.txt'); + git(wtPath, 'commit', '-q', '-m', 'work'); + const r = await removeCaseWorktree(repo, { name: 'w2', deleteBranch: true, isInUse: () => false }); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.removed.branchDeleted).toBe(false); + expect(r.removed.branchError).toContain('not fully merged'); + expect(git(repoDir, 'branch', '--list', 'worktree-w2')).toContain('worktree-w2'); + }); + + it('only removes a registered worktree that is a real directory', async () => { + mkdirSync(join(repoDir, '.claude', 'worktrees', 'stray'), { recursive: true }); + expect(await removeCaseWorktree(repo, { name: 'stray', isInUse: () => false })).toMatchObject({ + ok: false, + code: 'NOT_FOUND', + }); + expect(await removeCaseWorktree(repo, { name: 'missing', isInUse: () => false })).toMatchObject({ + ok: false, + code: 'NOT_FOUND', + }); + // A symlink at the worktree's own place is refused, never followed. + await createCaseWorktree(repo, { name: 'real' }); + symlinkSync(join(repoDir, '.claude', 'worktrees', 'real'), join(repoDir, '.claude', 'worktrees', 'link')); + expect(await resolveManagedWorktree(repo, 'link')).toMatchObject({ ok: false, code: 'INVALID_INPUT' }); + }); + + it('refuses a locked worktree', async () => { + await createCaseWorktree(repo, { name: 'locked' }); + git(repoDir, 'worktree', 'lock', '--reason', 'agent busy', join(repoDir, '.claude', 'worktrees', 'locked')); + const r = await removeCaseWorktree(repo, { name: 'locked', isInUse: () => false }); + expect(r).toMatchObject({ ok: false, code: 'CONFLICT' }); + if (!r.ok) expect(r.message).toContain('agent busy'); + }); + + it('removes a worktree whose folder was deleted by hand, so its name can be used again', async () => { + const made = await createCaseWorktree(repo, { name: 'gone' }); + if (!made.ok) throw new Error(made.message); + rmSync(made.worktree.path, { recursive: true, force: true }); + const listed = await listCaseWorktrees(repo); + expect(listed.ok && listed.worktrees.find((w) => w.name === 'gone')).toMatchObject({ + managed: true, + prunable: true, + }); + // A session never starts in it; only removal accepts a missing folder. + expect(await resolveManagedWorktree(repo, 'gone')).toMatchObject({ ok: false, code: 'NOT_FOUND' }); + // Before the fix this was NOT_FOUND, and both ways of reusing the name failed. + const r = await removeCaseWorktree(repo, { name: 'gone', deleteBranch: true, isInUse: () => false }); + expect(r).toMatchObject({ ok: true, removed: { name: 'gone', branch: 'worktree-gone', branchDeleted: true } }); + expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('gone'); + expect((await createCaseWorktree(repo, { name: 'gone' })).ok).toBe(true); + }); + + it('refuses a missing worktree that a live session still names, or that is locked', async () => { + const made = await createCaseWorktree(repo, { name: 'held' }); + if (!made.ok) throw new Error(made.message); + git(repoDir, 'worktree', 'lock', '--reason', 'on a removable disk', made.worktree.path); + rmSync(made.worktree.path, { recursive: true, force: true }); + expect(await removeCaseWorktree(repo, { name: 'held', isInUse: () => true })).toMatchObject({ + ok: false, + code: 'CONFLICT', + }); + const locked = await removeCaseWorktree(repo, { name: 'held', isInUse: () => false }); + expect(locked).toMatchObject({ ok: false, code: 'CONFLICT' }); + if (!locked.ok) expect(locked.message).toContain('removable disk'); + }); + + it('never follows a symlink on the way to a missing worktree', async () => { + const made = await createCaseWorktree(repo, { name: 'via' }); + if (!made.ok) throw new Error(made.message); + // The whole worktrees folder replaced by a link to somewhere else. + const elsewhere = join(dir, 'elsewhere'); + mkdirSync(elsewhere); + rmSync(join(repoDir, '.claude', 'worktrees'), { recursive: true, force: true }); + symlinkSync(elsewhere, join(repoDir, '.claude', 'worktrees')); + expect(await removeCaseWorktree(repo, { name: 'via', isInUse: () => false })).toMatchObject({ + ok: false, + code: 'INVALID_INPUT', + }); + }); +}); diff --git a/test/i18n-exit-run-help.test.ts b/test/i18n-exit-run-help.test.ts index 41841c246..27b67bf84 100644 --- a/test/i18n-exit-run-help.test.ts +++ b/test/i18n-exit-run-help.test.ts @@ -42,11 +42,14 @@ afterAll(() => { en.dom.window.close(); }); -/** Latin words left after removing what may stay: key names, the AI acronym, the N placeholder. */ +/** + * Latin words left after removing what may stay: key names, the AI acronym, the N placeholder, + * and Git, a product name every translation that names it keeps (the Git status rows too). + */ const leftover = (text: string) => text .replace(/\b(Ctrl|Cmd|Shift|Alt|Option)\+\w+/g, '') - .replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N)\b/g, '') + .replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N|Git)\b/g, '') .match(/[A-Za-z]+/g) ?? []; const helpers = new Function( @@ -204,7 +207,7 @@ describe('the case picker rows and the host-window and dictation toasts in zh-CN ); it('finds the strings it checks (the check is not vacuous)', () => { - expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']); + expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026', 'Git worktrees\u2026']); expect(SESSION_UI).toContain('
No cases match
'); expect(toasts.sort()).toEqual([ 'Could not open a new window for this dashboard', diff --git a/test/routes/worktree-routes.test.ts b/test/routes/worktree-routes.test.ts new file mode 100644 index 000000000..4c2331f14 --- /dev/null +++ b/test/routes/worktree-routes.test.ts @@ -0,0 +1,422 @@ +/** + * @fileoverview The worktree routes (`/api/cases/:name/worktrees`) and quick-start's `worktree` + * field, against a REAL temp git repository in the per-file temp HOME's case space + * (`test/setup.ts`). Covers the happy path end to end (create, list with live sessions, start a + * session in it, remove, branch delete) and every refusal: Docker and remote cases (git never + * spawned for Docker), a folder that is not a repository, bad names, a symlinked `.claude`, a + * dirty tree, a live session inside, and multi-user ownership. Port: N/A (app.inject()). + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; +import { registerWorktreeRoutes } from '../../src/web/routes/worktree-routes.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; +import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { userCasesDir } from '../../src/config/multiuser.js'; +import { Session } from '../../src/session.js'; +import { applyAgentSkill } from '../../src/hooks-config.js'; +import type { WorktreeGitRunner } from '../../src/git-worktrees.js'; + +const ENV = { + ...process.env, + LC_ALL: 'C', + GIT_AUTHOR_NAME: 'T', + GIT_AUTHOR_EMAIL: 't@example.com', + GIT_COMMITTER_NAME: 'T', + GIT_COMMITTER_EMAIL: 't@example.com', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_CONFIG_NOSYSTEM: '1', +}; +const git = (cwd: string, ...args: string[]) => + execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }); + +/** A git repository with one commit at `dir`. */ +function makeRepo(dir: string): string { + mkdirSync(dir, { recursive: true }); + git(dir, 'init', '-q', '-b', 'main'); + writeFileSync(join(dir, 'README.md'), '# repo\n'); + git(dir, 'add', 'README.md'); + git(dir, 'commit', '-q', '-m', 'init'); + return realpathSync(dir); +} + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +/** + * The worktree routes plus the session routes (quick-start, POST /api/sessions), with the + * production envelope hook mirrored from server.ts: quick-start returns a bare object and relies + * on it for both the `{ success, data }` wrapper and the error-code status, as in + * case-clone-routes.test.ts. + */ +async function harness( + opts: { authUser?: { username: string; role: 'admin' | 'user' }; git?: WorktreeGitRunner } = {} +): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + if (opts.authUser) { + const authUser = opts.authUser; + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: typeof authUser }).authUser = authUser; + }); + } + app.addHook('preSerialization', (req, reply, payload: unknown, done) => { + if (!req.url.startsWith('/api')) return done(null, payload); + if (payload === null || typeof payload !== 'object') return done(null, payload); + const p = payload as { success?: unknown; errorCode?: unknown }; + if (p.success === false) { + if (reply.statusCode === 200 && typeof p.errorCode === 'string') { + reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode)); + } + return done(null, payload); + } + if (p.success === true) return done(null, payload); + return done(null, { success: true, data: payload }); + }); + const ctx = createMockRouteContext(); + registerWorktreeRoutes(app, ctx as never, { git: opts.git }); + registerSessionRoutes(app, ctx as never); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +let h: Harness; +let repo: string; + +beforeEach(async () => { + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + repo = makeRepo(join(CASES_DIR, 'wtcase')); + h = await harness(); +}); + +afterEach(async () => { + await h.app.close(); + vi.restoreAllMocks(); + delete process.env.CODEMAN_MULTIUSER; + await rm(join(getDataDir(), 'docker-hosts.json'), { force: true }); + await rm(join(getDataDir(), 'docker-cases.json'), { force: true }); + await rm(join(getDataDir(), 'remote-hosts.json'), { force: true }); + await rm(join(getDataDir(), 'remote-cases.json'), { force: true }); + await rm(join(getDataDir(), 'linked-cases.json'), { force: true }); + safeRmHomeTree(CASES_DIR); + safeRmHomeTree(join(process.env.HOME ?? '/nonexistent', 'codeman-users')); +}); + +const list = (name = 'wtcase') => h.app.inject({ method: 'GET', url: `/api/cases/${name}/worktrees` }); +const create = (payload: Record, name = 'wtcase') => + h.app.inject({ method: 'POST', url: `/api/cases/${name}/worktrees`, payload }); +const remove = (wt: string, query = '', name = 'wtcase') => + h.app.inject({ method: 'DELETE', url: `/api/cases/${name}/worktrees/${wt}${query}` }); + +describe('worktree routes: happy path', () => { + it('creates, lists, starts a session in, and removes a worktree', async () => { + const before = await list(); + expect(before.statusCode).toBe(200); + expect(before.json().data).toMatchObject({ caseName: 'wtcase', repoRoot: repo, truncated: false }); + expect(before.json().data.worktrees).toHaveLength(1); + + const res = await create({ name: 'fix-login' }); + expect(res.statusCode).toBe(201); + const wtPath = join(repo, '.claude', 'worktrees', 'fix-login'); + expect(res.json().data.worktree).toMatchObject({ name: 'fix-login', path: wtPath, branch: 'worktree-fix-login' }); + expect(git(repo, 'status', '--porcelain')).toBe(''); + + // Start a session in it through the EXISTING quick-start path. + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'claude', worktree: 'fix-login', sessionName: 'w1-fix-login' }, + }); + expect(qs.statusCode).toBe(200); + const qsData = qs.json().data; + expect(qsData).toMatchObject({ casePath: repo, worktree: 'fix-login', workingDir: wtPath }); + const session = h.ctx.sessions.get(qsData.sessionId) as unknown as { workingDir: string }; + expect(session.workingDir).toBe(wtPath); + // The session's hooks land in the worktree it runs in, not the case folder. + expect(readFileSync(join(wtPath, '.claude', 'settings.local.json'), 'utf-8')).toContain('/api/hook-event'); + // ...and that file does not make the worktree dirty (it is excluded). + expect(git(wtPath, 'status', '--porcelain')).toBe(''); + + const listed = (await list()).json().data.worktrees; + const row = listed.find((w: { name?: string }) => w.name === 'fix-login'); + expect(row).toMatchObject({ managed: true, branch: 'worktree-fix-login', sessionCount: 1 }); + expect(row.sessions).toEqual([{ id: qsData.sessionId, name: 'w1-fix-login' }]); + // The main checkout does not count a session that runs in a worktree below it. + expect(listed[0]).toMatchObject({ main: true, isCase: true, managed: false }); + + // Refused while that session lives. + const busy = await remove('fix-login'); + expect(busy.statusCode).toBe(409); + expect(busy.json().error).toContain('live session'); + + h.ctx.sessions.delete(qsData.sessionId); + const gone = await remove('fix-login', '?deleteBranch=true'); + expect(gone.statusCode).toBe(200); + expect(gone.json().data.removed).toMatchObject({ + name: 'fix-login', + branch: 'worktree-fix-login', + branchDeleted: true, + }); + expect(existsSync(wtPath)).toBe(false); + expect(git(repo, 'branch', '--list', 'worktree-fix-login')).toBe(''); + }); + + it('surfaces git’s refusal for a dirty tree and keeps the files', async () => { + await create({ name: 'dirty', branch: 'feat/dirty' }); + const wtPath = join(repo, '.claude', 'worktrees', 'dirty'); + writeFileSync(join(wtPath, 'wip.txt'), 'work'); + const res = await remove('dirty'); + expect(res.statusCode).toBe(409); + expect(res.json().error).toContain('contains modified or untracked files'); + expect(readFileSync(join(wtPath, 'wip.txt'), 'utf-8')).toBe('work'); + }); + + it('removes a worktree its sessions got the agent skill in (agentSkillEnabled on)', async () => { + // The agent skill is injected into the folder a session runs in, so a worktree session gets + // an untracked `/codeman/`, which used to make `git worktree remove` refuse forever. + (h.ctx.getAgentSkillEnabled as unknown as { mockResolvedValue(v: boolean): void }).mockResolvedValue(true); + expect((await create({ name: 'skilled' })).statusCode).toBe(201); + const wtPath = join(repo, '.claude', 'worktrees', 'skilled'); + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'claude', worktree: 'skilled' }, + }); + expect(qs.statusCode).toBe(200); + h.ctx.sessions.delete(qs.json().data.sessionId); + expect(existsSync(join(wtPath, '.claude', 'skills', 'codeman', 'SKILL.md'))).toBe(true); + // The other skills directory (codex, gemini and the rest scan `.agents/skills`), written by + // the same function quick-start calls; those CLIs are not installed here. + await applyAgentSkill(wtPath, true, '.agents/skills'); + expect(existsSync(join(wtPath, '.agents', 'skills', 'codeman', 'SKILL.md'))).toBe(true); + expect(git(wtPath, 'status', '--porcelain')).toBe(''); + expect(git(repo, 'status', '--porcelain')).toBe(''); + const res = await remove('skilled'); + expect(res.statusCode).toBe(200); + expect(existsSync(wtPath)).toBe(false); + }); + + it('removes a worktree whose folder was deleted by hand and frees its name', async () => { + await create({ name: 'gone' }); + const wtPath = join(repo, '.claude', 'worktrees', 'gone'); + await rm(wtPath, { recursive: true, force: true }); + const row = (await list()).json().data.worktrees.find((w: { name?: string }) => w.name === 'gone'); + expect(row).toMatchObject({ managed: true, prunable: true }); + // A session cannot start in it. + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'shell', worktree: 'gone' }, + }); + expect(qs.statusCode).toBe(404); + const res = await remove('gone', '?deleteBranch=true'); + expect(res.statusCode).toBe(200); + expect(res.json().data.removed).toMatchObject({ name: 'gone', branchDeleted: true }); + expect(git(repo, 'worktree', 'list')).not.toContain('gone'); + expect((await create({ name: 'gone' })).statusCode).toBe(201); + }); + + it('lets a POST /api/sessions create path use the worktree as workingDir', async () => { + await create({ name: 'viasessions' }); + const wtPath = join(repo, '.claude', 'worktrees', 'viasessions'); + const res = await h.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { workingDir: wtPath, mode: 'shell', name: 's1-viasessions' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().data.session.workingDir).toBe(wtPath); + }); +}); + +describe('worktree routes: refusals', () => { + it('rejects bad names before git (schema and the finer rules)', async () => { + for (const name of ['-x', 'a/b', '.hidden', 'x'.repeat(65), 'a..b', 'n.lock']) { + const res = await create({ name }); + expect(res.statusCode, name).toBe(400); + } + expect((await remove('a..b')).statusCode).toBe(400); + expect((await create({ name: 'ok', branch: '-D' })).statusCode).toBe(400); + expect((await create({ name: 'ok', base: '--upload-pack=sh' })).statusCode).toBe(400); + expect(git(repo, 'worktree', 'list')).not.toContain('ok'); + }); + + it('answers 404 for a missing case and 400 for a folder that is not a repository', async () => { + expect((await list('nosuchcase')).statusCode).toBe(404); + mkdirSync(join(CASES_DIR, 'plain'), { recursive: true }); + const res = await list('plain'); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('not a git repository'); + }); + + it('refuses a Docker case with a 400 and never spawns git', async () => { + let spawned = 0; + const spy: WorktreeGitRunner = async () => { + spawned++; + return { code: 0, stdout: '', stderr: '', timedOut: false }; + }; + await h.app.close(); + h = await harness({ git: spy }); + await mkdir(getDataDir(), { recursive: true }); + await writeFile( + join(getDataDir(), 'docker-hosts.json'), + JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }]) + ); + await writeFile( + join(getDataDir(), 'docker-cases.json'), + JSON.stringify([{ name: 'dockcase', type: 'docker', hostId: 'd1', hostWorkspacePath: repo }]) + ); + for (const res of [ + await list('dockcase'), + await create({ name: 'x' }, 'dockcase'), + await remove('x', '', 'dockcase'), + ]) { + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('Docker'); + } + // The local case whose folder IS that Docker workspace is refused too (git would run there). + const viaLocal = await list('wtcase'); + expect(viaLocal.statusCode).toBe(400); + expect(viaLocal.json().error).toContain('Docker'); + expect(spawned).toBe(0); + + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'dockcase', mode: 'shell', worktree: 'x' }, + }); + expect(qs.statusCode).toBe(400); + expect(qs.json().error).toContain('Docker'); + }); + + it('refuses a remote case with a 400', async () => { + await mkdir(getDataDir(), { recursive: true }); + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'r1', label: 'far', host: 'far.example', username: 'u' }]) + ); + await writeFile( + join(getDataDir(), 'remote-cases.json'), + JSON.stringify([{ name: 'farcase', type: 'remote', hostId: 'r1', remotePath: '/srv/repo' }]) + ); + const res = await list('farcase'); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('remote'); + const qs = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'farcase', mode: 'shell', worktree: 'x' }, + }); + expect(qs.statusCode).toBe(400); + expect(qs.json().error).toContain('remote'); + }); + + it('refuses to create through a symlinked .claude directory', async () => { + const outside = join(CASES_DIR, 'outside'); + mkdirSync(join(outside, 'worktrees'), { recursive: true }); + symlinkSync(outside, join(repo, '.claude')); + const res = await create({ name: 'evil' }); + expect(res.statusCode).toBe(400); + expect(res.json().error).toContain('symlink'); + expect(existsSync(join(outside, 'worktrees', 'evil'))).toBe(false); + }); + + it('quick-start refuses an unregistered worktree and never scaffolds a missing case', async () => { + mkdirSync(join(repo, '.claude', 'worktrees', 'stray'), { recursive: true }); + const stray = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'shell', worktree: 'stray' }, + }); + expect(stray.statusCode).toBe(404); + const missing = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'brandnew', mode: 'shell', worktree: 'x' }, + }); + expect(missing.statusCode).toBe(404); + expect(existsSync(join(CASES_DIR, 'brandnew'))).toBe(false); + const badName = await h.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'wtcase', mode: 'shell', worktree: '../x' }, + }); + expect(badName.statusCode).toBe(400); + }); +}); + +describe('worktree routes: a case reached through a symlink', () => { + // A linked case's folder can be a symlink (a repository on another disk, macOS's /tmp), and + // POST /api/sessions stores `workingDir` as it was spelled. The worktree list and the removal + // guard compare paths lexically, so a session spelled through the link has to be mapped onto + // the repository's real path first, or it is invisible to both. + it('counts and protects a session whose folder names the worktree through the case link', async () => { + const link = join(CASES_DIR, 'repo-link'); + symlinkSync(repo, link); + await mkdir(getDataDir(), { recursive: true }); + await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedviasym: link })); + const made = await create({ name: 'sym' }, 'linkedviasym'); + expect(made.statusCode).toBe(201); + const real = join(repo, '.claude', 'worktrees', 'sym'); + expect(made.json().data.worktree.path).toBe(real); + + // The same worktree, spelled through the link. + const spelled = join(link, '.claude', 'worktrees', 'sym'); + const res = await h.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { workingDir: spelled, mode: 'shell', name: 's1-sym' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().data.session.workingDir).toBe(spelled); + + const busy = await remove('sym', '', 'linkedviasym'); + expect(busy.statusCode).toBe(409); + expect(busy.json().error).toContain('live session'); + expect(existsSync(real)).toBe(true); + + const row = (await list('linkedviasym')).json().data.worktrees.find((w: { name?: string }) => w.name === 'sym'); + expect(row).toMatchObject({ managed: true, sessionCount: 1 }); + expect(row.sessions).toEqual([{ id: res.json().data.session.id, name: 's1-sym' }]); + }); +}); + +describe('worktree routes: multi-user ownership', () => { + it('resolves a non-admin’s own case space only, never a linked case or another user’s case', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + // A linked case is admin-only: a non-admin's lookup falls through to their own space. + await mkdir(getDataDir(), { recursive: true }); + await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedrepo: repo })); + makeRepo(join(userCasesDir('alice'), 'alicecase')); + + await h.app.close(); + h = await harness({ authUser: { username: 'bob', role: 'user' } }); + expect((await list('linkedrepo')).statusCode).toBe(404); + expect((await list('alicecase')).statusCode).toBe(404); + expect((await create({ name: 'x' }, 'alicecase')).statusCode).toBe(404); + expect(existsSync(join(userCasesDir('alice'), 'alicecase', '.claude'))).toBe(false); + + await h.app.close(); + h = await harness({ authUser: { username: 'alice', role: 'user' } }); + expect((await list('alicecase')).statusCode).toBe(200); + expect((await create({ name: 'mine' }, 'alicecase')).statusCode).toBe(201); + + await h.app.close(); + h = await harness({ authUser: { username: 'root', role: 'admin' } }); + const linked = await list('linkedrepo'); + expect(linked.statusCode).toBe(200); + expect(linked.json().data.repoRoot).toBe(repo); + }); +}); diff --git a/test/session-worktree-label.test.ts b/test/session-worktree-label.test.ts index 537641124..826df58aa 100644 --- a/test/session-worktree-label.test.ts +++ b/test/session-worktree-label.test.ts @@ -45,6 +45,24 @@ describe('worktree fields through the unified merge (#266)', () => { expect(merged[0].gitBranch).toBeUndefined(); }); + it('names the worktree of a LIVE session from its folder layout (no transcript record yet)', () => { + const merged = mergeUnifiedSessions({ + live: [{ id: 'live1', name: 'w1-fix-login', mode: 'codex', workingDir: '/repo/.claude/worktrees/fix-login' }], + }); + expect(merged[0]).toMatchObject({ worktreeName: 'fix-login', worktreeRepo: '/repo' }); + }); + + it('lets a transcript record win over the layout, and ignores a subfolder of a worktree', () => { + const fromTranscript = mergeUnifiedSessions({ + history: [historyRow({ worktreeName: 'named-by-claude', worktreeRepo: '/elsewhere' })], + }); + expect(fromTranscript[0]).toMatchObject({ worktreeName: 'named-by-claude', worktreeRepo: '/elsewhere' }); + const sub = mergeUnifiedSessions({ + live: [{ id: 'live2', name: 'x', workingDir: '/repo/.claude/worktrees/fix-login/src' }], + }); + expect(sub[0].worktreeName).toBeUndefined(); + }); + it('finds a session by worktree name and by branch', () => { const items = [ { sessionId: 'a', worktreeName: 'autodev', sources: ['history'] }, diff --git a/test/worktree-modal-i18n.test.ts b/test/worktree-modal-i18n.test.ts new file mode 100644 index 000000000..180226815 --- /dev/null +++ b/test/worktree-modal-i18n.test.ts @@ -0,0 +1,154 @@ +/** + * @fileoverview The git worktree UI in Chinese (zh-CN), run through the real translator over the + * real index.html in JSDOM: + * + * - every application string of the worktree dialog, Close Session's worktree option and the + * phone case sheet's Worktrees button reads in Chinese, with no English left beyond Git (a + * product name), git's own commands and the `.claude/worktrees/` and `worktree-` paths; + * - the strings session-ui.js inserts at runtime (row badges, buttons, the removal note, the + * validation message) and its toasts translate too, the toasts keeping the user's worktree and + * branch names verbatim; + * - the slots holding user or git text (case name, repository path, worktree name) are skipped. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); +const SESSION_UI = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8'); + +type Api = { t(s: string): string; configure(o: Record): void; start(): void }; + +function translator(language: string) { + const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); + vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); + const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; + api.start(); + api.configure({ language }); + return { dom, api }; +} + +const zh = translator('zh-CN'); +const en = translator('en'); +afterAll(() => { + zh.dom.window.close(); + en.dom.window.close(); +}); + +/** + * Latin left once the names that stay are removed: Git (product), git and its commands, the + * `.claude/worktrees/` path, the branch template, HEAD, and the display name (Codeman by default). + */ +const leftover = (text: string) => + text + .replace(/git (worktree remove|branch -d)/g, '') + .replace(/\.claude\/worktrees\/|worktree-|\bHEAD\b|\bGit\b|\bgit\b|\bCodeman\b/g, '') + .match(/[A-Za-z]+/g) ?? []; + +/** Visible text of the static surfaces, one entry per text-bearing element (skips excluded). */ +function staticStrings(doc: Document): string[] { + const roots = [ + doc.getElementById('worktreeModal')!, + doc.getElementById('closeConfirmWorktree')!, + doc.querySelector('.btn-case-worktrees-mobile')!, + ]; + const out: string[] = []; + for (const root of roots) { + const walker = doc.createTreeWalker(root, 4 /* SHOW_TEXT */); + for (let n = walker.nextNode(); n; n = walker.nextNode()) { + const text = (n.nodeValue || '').trim(); + if (!text || (n.parentElement && n.parentElement.closest('[data-i18n-skip]'))) continue; + out.push(text); + } + for (const el of root.querySelectorAll('[aria-label], [title]')) { + for (const attr of ['aria-label', 'title']) { + const v = el.getAttribute(attr); + if (v) out.push(v); + } + } + } + return out; +} + +describe('the git worktree UI in zh-CN', () => { + const english = staticStrings(en.dom.window.document); + const chinese = staticStrings(zh.dom.window.document); + + it('finds the strings it checks (not vacuous)', () => { + expect(english).toContain('Create & start session'); + expect(english).toContain('Kill & remove worktree'); + expect(english).toContain('Worktrees'); + expect(english).toContain('Close git worktrees'); + expect(english.length).toBe(chinese.length); + expect(english.length).toBeGreaterThan(15); + }); + + it('translates every static string, leaving only Git, git commands and paths in Latin', () => { + // Symbols with no words (the close cross, the worktree glyph) have nothing to translate. + const bad = chinese.filter( + (text, i) => /[A-Za-z]/.test(english[i]) && (text === english[i] || leftover(text).length > 0) + ); + expect(bad).toEqual([]); + }); + + it('keeps the user/git slots out of the translator', () => { + const doc = zh.dom.window.document; + for (const id of ['worktreeModalCase', 'closeConfirmWorktreeName']) { + expect(doc.getElementById(id)!.hasAttribute('data-i18n-skip'), id).toBe(true); + } + }); + + it('translates the strings session-ui.js inserts at runtime', () => { + const runtime = [ + 'Git worktrees…', + 'Loading worktrees…', + 'No worktrees yet. Create one below.', + 'detached HEAD', + 'main checkout', + 'not created by Codeman', + 'locked', + 'missing', + 'Open', + 'Start a session in this worktree', + 'Remove…', + 'git worktree remove refuses while the worktree has uncommitted or untracked files. Codeman never forces it.', + 'Its folder is gone. Removing it clears the record git keeps of it, so the name can be used again.', + 'Also delete its branch (git branch -d keeps a branch that is not merged)', + 'Remove worktree', + 'Cancel', + 'Name: letters, digits, ".", "_" and "-", starting with a letter or digit, at most 64 characters.', + 'A launch is already in progress', + 'Codeman did not answer.', + 'Worktrees…', + '2 sessions', + ]; + // Each literal is really in session-ui.js (the count badge is built from `${n} session`). + for (const s of runtime.filter((r) => r !== '2 sessions')) expect(SESSION_UI.includes(`'${s}'`), s).toBe(true); + expect(SESSION_UI).toContain('session${wt.sessionCount === 1 ?'); + const bad = runtime.filter((s) => { + const text = zh.api.t(s); + return text === s || leftover(text).length > 0 || en.api.t(s) !== s; + }); + expect(bad).toEqual([]); + }); + + it('translates the toasts and keeps the names verbatim', () => { + expect(zh.api.t('Worktree fix-login created on branch feat/login')).toBe('已创建工作树 fix-login,分支 feat/login'); + expect(zh.api.t('Worktree fix-login and branch worktree-fix-login removed')).toBe( + '已移除工作树 fix-login 和分支 worktree-fix-login' + ); + expect(zh.api.t('Worktree fix-login removed')).toBe('已移除工作树 fix-login'); + expect( + zh.api.t("Worktree w2 removed. Branch worktree-w2 kept: error: the branch 'worktree-w2' is not fully merged") + ).toBe("已移除工作树 w2。保留了分支 worktree-w2:error: the branch 'worktree-w2' is not fully merged"); + expect(zh.api.t('Worktree w1 was kept: git refused to remove the worktree: fatal: x')).toBe( + '工作树 w1 已保留:git refused to remove the worktree: fatal: x' + ); + }); +}); diff --git a/test/worktree-ui.test.ts b/test/worktree-ui.test.ts new file mode 100644 index 000000000..4702af30b --- /dev/null +++ b/test/worktree-ui.test.ts @@ -0,0 +1,188 @@ +/** + * @fileoverview The git worktree launch wiring in session-ui.js, in a vm sandbox (no real DOM), + * the way test/run-mode-ui.test.ts drives the run*() methods: + * + * - `managedWorktreeOfDir()` names only `/.claude/worktrees/`; + * - a worktree launch goes through the EXISTING create paths with the worktree's folder: + * POST /api/sessions with `workingDir` for claude and shell, quick-start with `worktree` for + * every other CLI; never a case lookup or scaffold, and tabs named after the worktree; + * - `_launchInWorktree()` swaps the run mode for the one launch and puts it back. + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { describe, expect, it, vi } from 'vitest'; + +const SESSION_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + +const WT = { caseName: 'myrepo', name: 'fix-login', path: '/cases/myrepo/.claude/worktrees/fix-login' }; + +function load(fetchImpl: (url: string, init?: { method?: string; body?: string }) => Promise) { + const elements: Record = { + // The toolbar still points at ANOTHER case: a worktree launch must not read it. + quickStartCase: { value: 'other-case' }, + tabCount: { value: '3' }, + }; + const requests: Array<{ url: string; method?: string; body?: any }> = []; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: (id: string) => elements[id] ?? null }, + fetch: async (url: string, init?: { method?: string; body?: string }) => { + requests.push({ url, method: init?.method, body: init?.body ? JSON.parse(init.body) : undefined }); + return fetchImpl(url, init); + }, + console, + setTimeout, + }); + vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' }); + const app = new (CodemanApp as any)(); + app.terminal = { clear: () => {}, writeln: () => {}, focus: () => {} }; + app.sessions = new Map(); + app.cases = [ + { name: 'myrepo', path: '/cases/myrepo', location: 'local' }, + { name: 'dock', path: '/cases/dock', location: 'docker' }, + ]; + app.getCaseSettings = () => ({}); + app.loadAppSettingsFromStorage = () => ({}); + app.buildEnvOverrides = () => ({}); + app.getEffortSetting = () => undefined; + app.getAdvisorSetting = () => undefined; + app.isRalphTrackerEnabledByDefault = () => false; + app.getTerminalDimensions = () => null; + app.loadQuickStartCases = () => {}; + app.isCliAvailable = () => true; + app._ensureCreatedSessionVisible = async () => {}; + app.selectSession = vi.fn(async () => {}); + return { app, context, requests }; +} + +const json = (data: unknown) => ({ ok: true, status: 200, json: async () => data }); + +describe('managedWorktreeOfDir', () => { + const { context } = load(async () => json({})); + const fn = (context as unknown as { managedWorktreeOfDir: (d: unknown, c: unknown) => unknown }).managedWorktreeOfDir; + const cases = [ + { name: 'myrepo', path: '/cases/myrepo', location: 'local' }, + { name: 'linked', path: '/srv/linked', location: 'linked-local' }, + { name: 'dock', path: '/cases/dock', location: 'docker' }, + ]; + + it('names a worktree folder of a local or linked case', () => { + expect(fn('/cases/myrepo/.claude/worktrees/fix-login', cases)).toEqual(WT); + expect(fn('/srv/linked/.claude/worktrees/a.b_c/', cases)).toEqual({ + caseName: 'linked', + name: 'a.b_c', + path: '/srv/linked/.claude/worktrees/a.b_c', + }); + }); + + it('ignores the case folder, a subfolder, unknown or Docker cases and invalid names', () => { + expect(fn('/cases/myrepo', cases)).toBeNull(); + expect(fn('/cases/myrepo/.claude/worktrees/fix-login/src', cases)).toBeNull(); + expect(fn('/elsewhere/.claude/worktrees/x', cases)).toBeNull(); + expect(fn('/cases/dock/.claude/worktrees/x', cases)).toBeNull(); + expect(fn('/cases/myrepo/.claude/worktrees/-x', cases)).toBeNull(); + expect(fn('/cases/myrepo/.claude/worktrees/a..b', cases)).toBeNull(); + expect(fn(undefined, cases)).toBeNull(); + }); +}); + +describe('launching in a worktree', () => { + it('claude: POST /api/sessions in the worktree folder, one tab named after the worktree', async () => { + const { app, requests } = load(async (url) => { + if (url === '/api/sessions') return json({ success: true, data: { session: { id: 's-1' } } }); + return json({ success: true }); + }); + await app.runClaude({ count: 1, worktree: WT }); + const create = requests.filter((r) => r.url === '/api/sessions'); + expect(create).toHaveLength(1); + expect(create[0].body).toMatchObject({ workingDir: WT.path, name: 'w1-fix-login' }); + expect(requests.some((r) => r.url.startsWith('/api/cases'))).toBe(false); + expect(requests.some((r) => r.url === '/api/sessions/s-1/interactive')).toBe(true); + expect(app.selectSession).toHaveBeenCalledWith('s-1'); + }); + + it('shell: POST /api/sessions in the worktree folder, no case lookup', async () => { + const { app, requests } = load(async (url) => { + if (url === '/api/sessions') return json({ success: true, data: { session: { id: 's-2' } } }); + return json({ success: true }); + }); + await app.runShell({ count: 1, worktree: WT }); + expect(requests.find((r) => r.url === '/api/sessions')?.body).toMatchObject({ + workingDir: WT.path, + mode: 'shell', + name: 's1-fix-login', + }); + expect(requests.some((r) => r.url.startsWith('/api/cases'))).toBe(false); + }); + + it('other CLIs: quick-start with the case and the worktree name', async () => { + const { app, requests } = load(async (url) => { + if (url === '/api/codex/status') return json({ success: true, data: { available: true } }); + if (url === '/api/quick-start') return json({ success: true, data: { sessionId: 's-3' } }); + return json({ success: true }); + }); + await app._runCliMode('codex', { count: 1, worktree: WT }); + const qs = requests.filter((r) => r.url === '/api/quick-start'); + expect(qs).toHaveLength(1); + expect(qs[0].body).toMatchObject({ + caseName: 'myrepo', + mode: 'codex', + worktree: 'fix-login', + sessionName: 'w1-fix-login', + }); + }); + + it('_launchInWorktree runs ONE session of the chosen mode and restores the Run button mode', async () => { + const { app } = load(async () => json({})); + app._runMode = 'claude'; + const seen: Array<{ mode: string; options: unknown }> = []; + app.run = vi.fn(async (options: unknown) => { + seen.push({ mode: app._runMode, options }); + }); + await app._launchInWorktree(WT, 'codex'); + expect(seen).toEqual([{ mode: 'codex', options: { count: 1, worktree: WT } }]); + expect(app._runMode).toBe('claude'); + }); + + it('a worktree whose folder is gone offers Remove but not Open, with a note saying what Remove does', () => { + const dom = new JSDOM(''); + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: dom.window.document, + console, + setTimeout, + }); + vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' }); + const app = new (CodemanApp as any)(); + app._shortenHomePath = (p: string) => p; + const row = (wt: Record) => + app._buildWorktreeRow({ branch: 'worktree-x', path: '/r/.claude/worktrees/x', name: 'x', ...wt }); + const buttons = (el: HTMLElement) => [...el.querySelectorAll('button')].map((b) => b.textContent); + + expect(buttons(row({ managed: true, prunable: false }))).toEqual(['Open', 'Remove…']); + expect(buttons(row({ managed: true, prunable: true }))).toEqual(['Remove…']); + expect(buttons(row({ managed: false, prunable: true }))).toEqual([]); + + app._worktreeRemoveConfirm = 'x'; + const missingNote = row({ managed: true, prunable: true }).querySelector('.worktree-remove-note')!.textContent; + expect(missingNote).toContain('folder is gone'); + const liveNote = row({ managed: true, prunable: false }).querySelector('.worktree-remove-note')!.textContent; + expect(liveNote).toContain('never forces'); + dom.window.close(); + }); + + it('closing offers worktree removal only for a session in a managed worktree', () => { + const { app } = load(async () => json({})); + expect(app._managedWorktreeOfSession({ workingDir: WT.path })).toEqual(WT); + expect(app._managedWorktreeOfSession({ workingDir: '/cases/myrepo' })).toBeNull(); + expect(app._managedWorktreeOfSession({ workingDir: WT.path, remote: { hostId: 'x' } })).toBeNull(); + }); +});