Compare commits

...
Author SHA1 Message Date
Codeman maintainer 28cbbad591 fix(worktrees): the injected agent skill no longer blocks removal
With Codeman Agent Skill on (agentSkillEnabled), every session create
writes the skill into the folder the session runs in, so a session in a
worktree leaves an untracked .claude/skills/codeman/ (claude) or
.agents/skills/codeman/ (codex, gemini and the rest) behind. git then
refuses `git worktree remove` for that worktree forever, and an agent
running `git add -A` there would commit Codeman's skill into its branch
(reproduced: the worktree's status showed "?? .agents/" and
"?? .claude/").

A create now excludes <skills dir>/codeman/ next to the hooks file, for
every skills folder the registry names (capabilities.agentSkillDir,
read at call time by the route and passed in as data, so no CLI id is
branched on). Like the other lines it is only added when git does not
already ignore the path, and only to the repository's local
info/exclude.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:37:48 +02:00
Codeman maintainer 64fea4ff84 fix(worktrees): a worktree whose folder was deleted can still be removed
When a managed worktree's folder is deleted by hand (a user in a file
manager, an agent's rm -rf), git keeps its record and lists it as
prunable. Codeman then had no way out: the dialog showed the row as
"missing" with no action, DELETE answered "No worktree named ...", and
the name could never be used again (the branch still existed, and with
another branch git refused "a missing but already registered
worktree"). Reproduced on git 2.43; git's own `git worktree remove` on
the missing path drops the record with exit 0.

Removal (only removal, never a session start) now accepts a worktree
whose folder is gone when git lists it at exactly the managed path, as
prunable or locked, and no component on the way is a symlink or a file.
The live-session and lock refusals still apply. The dialog offers
Remove... (not Open) on such a row, with a note saying what it does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:34:49 +02:00
Codeman maintainer 0ce6e09725 fix(worktrees): a session spelled through the case's symlink blocks removal
The removal guard and the per-worktree session count compared each
session's workingDir lexically against the worktree paths git lists,
which are real paths. POST /api/sessions stores workingDir as it was
spelled, so a session started in a worktree of a linked case whose
folder is a symlink (a repository on another disk, macOS /tmp, a
symlinked cases folder), named through that link, counted for no
worktree, and DELETE /api/cases/:name/worktrees/:worktree removed the
folder under the running agent (reproduced: 200 instead of 409).

Each session folder under the case path is now respelled onto the
repository's real top level before the comparison (respellUnder, pure
and lexical, so no filesystem call per session).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:31:37 +02:00
Codeman maintainer 3952963bad docs(worktrees): API reference, wiki page and the invariants behind them
Documents the worktree endpoints and quick-start's worktree field in
docs/api-reference.md, adds a Git Worktrees page to the wiki (sidebar,
Home index, a pointer from Core Concepts where sessions sharing one
case folder is explained), and records the rules that keep the feature
safe in docs/architecture-invariants.md with a short CLAUDE.md pointer:
only the managed .claude/worktrees path is ever touched, git never runs
over a Docker workspace, removal is never forced and branch delete is
-d only, and sessions start through the existing create paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:10 +02:00
Codeman maintainer 8cffcf4be5 feat(worktrees): worktree dialog, launch in a worktree, remove on close
The worktree API needs a front door that works from the place cases are
already picked. The toolbar case picker gains a third action row,
"Git worktrees…" (the phone case sheet gets a Worktrees button next to
Create New Case; no header button anywhere), opening one dialog per case:

- the repository's worktrees, with the managed ones (.claude/worktrees)
  offering Open (start another session in it) and Remove…, an inline
  confirmation with "Also delete its branch" (git branch -d) whose git
  refusal is shown verbatim through textContent;
- a New worktree form (name, optional branch and start point) and a
  "Start sessions with" picker over every enabled, installed run mode,
  built from the registry catalogue as data, never an id branch.

Launching goes through run() with options.worktree and the same temporary
_runMode swap the custom-model entries use, so every mode keeps its own
path: claude and shell create through POST /api/sessions with the
worktree as workingDir, every other CLI through quick-start's worktree
field. Tabs are named w<n>-<worktree> and the Run button's mode is left
as it was.

Close Session offers "Kill & remove worktree" for a session that runs in
a Codeman-managed worktree; the removal waits for the server to confirm
the session is gone (closeSession now resolves true/false), and a dirty
tree keeps the worktree with git's reason in a toast that opens the
dialog. zh-CN strings and toast patterns for all of it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:06 +02:00
Codeman maintainer 517e12fe1d feat(worktrees): create, list and remove git worktrees of a local case
Sessions in one case share its folder, so several agents on one repository
edit, build and commit in the same checkout and step on each other. A case
whose folder is a git repository can now keep worktrees under
<repo>/.claude/worktrees/<name> on a new branch (default worktree-<name>),
which is Claude Code's own --worktree layout (measured in Claude Code
2.1.296) and the one Codeman's worktree pill already recognizes.

New module src/git-worktrees.ts (pure name/branch gates, worktree list
parsing, exclude text; IO through an injectable runner) and routes
GET/POST /api/cases/:name/worktrees and
DELETE /api/cases/:name/worktrees/:worktree:

- git runs as argv with -- before paths, non-interactive env, process
  group timeouts, --no-optional-locks on reads; names are gated before
  git and confirmed with git check-ref-format --branch.
- Only <repo>/.claude/worktrees/<valid-name> is created or removed; every
  path component is lstat'ed and realpath-checked, so a symlinked .claude
  cannot redirect a create or remove outside the repository.
- /.claude/worktrees/ (and the per-workspace hooks file Codeman writes,
  which otherwise blocks git worktree remove) go to the repository's
  local info/exclude, never a tracked .gitignore.
- Removal is git worktree remove WITHOUT --force, refused while a live
  session runs inside; branch delete is git branch -d, never -D.
- Docker and remote cases are refused with a 400, and git never runs for
  a case at or inside a Docker case workspace.

Starting a session reuses the existing create paths: quick-start takes
a worktree name (validated, local cases only) and runs hooks, the skill,
the model override and the session itself in the worktree;
POST /api/sessions takes the worktree as workingDir. The unified session
list names the worktree of a live session from its folder layout, so a
codex or shell session in one gets the worktree pill too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 05:16:00 +02:00
26 changed files with 3577 additions and 71 deletions
+2
View File
@@ -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 `<repo>/.claude/worktrees/<name>` on `worktree-<name>`, Claude Code's own `--worktree` layout. ⚠️ Only that managed path is ever created or removed, every component `lstat`ed and realpath-checked (a symlinked `.claude` is refused). ⚠️ git never runs for a Docker or remote case, nor at or inside a Docker workspace (`worktree add` runs hooks and filters on the host). ⚠️ Remove is `git worktree remove` WITHOUT `--force`, refused while a live session is inside; branch delete is `-d`, never `-D`. ⚠️ Sessions start through the existing create paths (quick-start `worktree`, or `POST /api/sessions` `workingDir`), never a new spawn path. → [architecture-invariants#git-worktrees-of-a-case](docs/architecture-invariants.md#git-worktrees-of-a-case)
**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/<cap>/...`). ⚠️ 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`
+36
View File
@@ -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 `<case>/.claude/worktrees/<name>`, 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 <name>` uses (branch `worktree-<name>`), 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 `<repoRoot>/.claude/worktrees/<name>`: 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-<name>`. 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: "<name>"` 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.
+14
View File
@@ -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 `<repo>/.claude/worktrees/<name>` on a new branch (default `worktree-<name>`), 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-<name>`, 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 `<repo>/.claude/worktrees/<valid-name>` 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 `<common git dir>/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 (`<skills dir>/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 <base>^{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<n>-<worktree>`.
- 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/<projHash>/<sessionUuid>/workflows/wf_*.json` (written only at run end); LIVE in-flight runs exist only as transcript dirs at `…/subagents/workflows/wf_<id>/` (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`.
+2 -1
View File
@@ -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:
+71
View File
@@ -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
`<case>/.claude/worktrees/<name>`, on a new branch `worktree-<name>`. That is exactly where
Claude Code's own `claude --worktree <name>` 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 `⑂ <name>` 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-<name>`. 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`).
+1 -1
View File
@@ -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. |
+1
View File
@@ -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? |
+1
View File
@@ -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)
+937
View File
@@ -0,0 +1,937 @@
/**
* @fileoverview Git worktrees for a LOCAL case: list, create and remove the worktrees a
* repository-backed case keeps under `<repo>/.claude/worktrees/<name>`, 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. `<repo>/.claude/worktrees/<name>` on a new branch `worktree-<name>` is Claude Code's own
* `--worktree` layout (measured in Claude Code 2.1.296: path `<repo>/.claude/worktrees/<name>`,
* branch `worktree-<name>`, 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 <name>` 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 (`<skills dir>/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 `<repo>/.claude/worktrees/<valid-name>` 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
* (`<skillsDir>/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-<name>`. */
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 ─────────────────────────────────────────────────────────────
/** `<repo>/.claude/worktrees`. */
export function managedWorktreesDir(repoRoot: string): string {
return join(repoRoot, ...WORKTREES_DIR_SEGMENTS);
}
/** `<repo>/.claude/worktrees/<name>`. 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
* `<repoRoot>/.claude/worktrees/<valid-name>`. 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<string, number> = { 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 <args>` 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<WorktreeGitResult>;
/**
* 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<WorktreeGitResult>((resolveRun) => {
let child: ReturnType<typeof spawn>;
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
* `<dir>/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<string> => {
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 `<repo>/.claude/worktrees/<valid-name>`: 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<DirState> {
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 `<repo>/.claude` and `<repo>/.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 `<commonDir>/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<string, Promise<unknown>>();
/** Serialize create/remove per repository: two creates must not both pass the "absent" check. */
async function withRepoLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
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-<name>`. */
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<boolean>;
}
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 } : {}),
},
};
});
}
+23
View File
@@ -167,6 +167,18 @@ function overwrite<K extends keyof UnifiedSessionItem>(
if (value !== undefined) item[key] = value;
}
/** `<repo>/.claude/worktrees/<name>`: 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
* `<repo>/.claude/worktrees/<name>` (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.
+30 -4
View File
@@ -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);
}
}
+54
View File
@@ -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-<name> from the case's current commit, unless you pick another below.":
'从案例当前提交新建分支 worktree-<name>,除非在下方另行选择。',
'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.
+63
View File
@@ -1693,6 +1693,18 @@
<span class="close-option-title" id="closeConfirmKillTitle">Kill Tmux & Claude Code</span>
<span class="close-option-desc">Terminate the session completely</span>
</button>
<!-- Shown only for a session in a Codeman-managed git worktree (requestCloseSession,
app.js): kill it, then `git worktree remove` without --force. -->
<div class="close-option-worktree" id="closeConfirmWorktree" hidden>
<button class="close-option close-option-danger" onclick="app.confirmCloseSession(true, { removeWorktree: true })">
<span class="close-option-title">Kill &amp; remove worktree <span id="closeConfirmWorktreeName" data-i18n-skip></span></span>
<span class="close-option-desc">git refuses while the worktree has uncommitted or untracked files; nothing is forced</span>
</button>
<label class="close-option-worktree-branch">
<input type="checkbox" id="closeConfirmWorktreeBranch">
<span>Also delete its branch (git branch -d keeps a branch that is not merged)</span>
</label>
</div>
</div>
<div class="modal-footer-cancel">
<button class="btn-toolbar btn-sm" onclick="app.cancelCloseSession()">Cancel</button>
@@ -1700,6 +1712,52 @@
</div>
</div>
<!-- Git worktrees of a case (session-ui.js openWorktreeModal; routes/worktree-routes.ts).
Opened from the case picker's "Git worktrees…" row and the phone case sheet. -->
<div class="modal" id="worktreeModal" role="dialog" aria-modal="true" aria-labelledby="worktreeModalTitle">
<div class="modal-backdrop" onclick="app.closeWorktreeModal()"></div>
<div class="modal-content worktree-modal">
<div class="modal-header">
<h3 id="worktreeModalTitle"><span>Git worktrees</span> <span class="worktree-modal-case" id="worktreeModalCase" data-i18n-skip></span></h3>
<button class="modal-close" onclick="app.closeWorktreeModal()" aria-label="Close git worktrees">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint worktree-intro">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.</p>
<div class="worktree-status" id="worktreeStatus" role="status" aria-live="polite" hidden></div>
<div class="worktree-list" id="worktreeList"></div>
<div class="form-row worktree-mode-row">
<label for="worktreeMode">Start sessions with</label>
<select id="worktreeMode" class="form-select"></select>
</div>
<fieldset class="worktree-new">
<legend>New worktree</legend>
<div class="form-row">
<label for="worktreeName">Name</label>
<input type="text" id="worktreeName" maxlength="64" placeholder="fix-login" autocomplete="off" autocapitalize="off" spellcheck="false" onkeydown="if (event.key === 'Enter') app.createWorktreeFromModal()">
<span class="form-hint">New branch worktree-&lt;name&gt; from the case's current commit, unless you pick another below.</span>
</div>
<details class="worktree-advanced">
<summary>Branch and start point</summary>
<div class="form-row">
<label for="worktreeBranch">Branch</label>
<input type="text" id="worktreeBranch" maxlength="200" placeholder="worktree-<name>" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label for="worktreeBase">Start from</label>
<input type="text" id="worktreeBase" maxlength="200" placeholder="HEAD" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">A branch, tag or commit. Default: the case's current commit (HEAD).</span>
</div>
</details>
<div class="worktree-form-error" id="worktreeFormError" role="alert" hidden></div>
</fieldset>
</div>
<div class="form-actions">
<button type="button" class="wt-btn" onclick="app.closeWorktreeModal()">Close</button>
<button type="button" class="wt-btn wt-btn-primary" id="worktreeCreateBtn" onclick="app.createWorktreeFromModal()">Create &amp; start session</button>
</div>
</div>
</div>
<!-- App Settings Modal -->
<div class="modal" id="appSettingsModal">
<div class="modal-backdrop" onclick="app.closeAppSettings()"></div>
@@ -3663,6 +3721,11 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create New Case
</button>
<!-- The selected case's git worktrees (session-ui.js openWorktreeModal). -->
<button class="btn-toolbar btn-case-create-mobile btn-case-worktrees-mobile" onclick="app.showWorktreesFromMobile()">
<span aria-hidden="true">&#x2442;</span>
Worktrees
</button>
</div>
</div>
</div>
+499 -53
View File
@@ -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 `<case path>/.claude/worktrees/<name>` 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 ?? {};
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;
// 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 ?? {};
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;
// 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<n>-<name>`): 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/<name>` 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', {
+246
View File
@@ -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;
+1
View File
@@ -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';
+43 -9
View File
@@ -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<typeof Session>[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 } : {}),
};
+291
View File
@@ -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 `<case>/.claude/worktrees/<name>` 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: <name>` (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<string[]> =>
(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<string>();
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;
/** `<repo>/.claude/worktrees`, where Codeman creates and removes worktrees. */
worktreesDir: string;
worktrees: CaseWorktreeInfo[];
truncated: boolean;
}
type Fail = ApiResponse<never>;
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<Record<string, string>>(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<string[]> } = {}
): 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<ApiResponse<CaseWorktreeList>> => {
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<ApiResponse<{ worktree: CreatedWorktree }>> => {
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<ApiResponse<{ removed: RemovedWorktree }>> => {
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 } };
}
);
}
+35
View File
@@ -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-<name>`, 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 (`<case>/.claude/worktrees/<worktree>`,
* 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 <case>/.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
+2
View File
@@ -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);
+437
View File
@@ -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 <repo>/.claude/worktrees/<valid-name>', () => {
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 <repo>/.claude/worktrees/<name> on worktree-<name>, 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',
});
});
});
+6 -3
View File
@@ -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('<div class="case-combobox-empty">No cases match</div>');
expect(toasts.sort()).toEqual([
'Could not open a new window for this dashboard',
+422
View File
@@ -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<Harness> {
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<string, unknown>, 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 `<skills dir>/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);
});
});
+18
View File
@@ -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'] },
+154
View File
@@ -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-<name>` 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<string, string>): 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-<name>|\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'
);
});
});
+188
View File
@@ -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 `<local case path>/.claude/worktrees/<valid-name>`;
* - 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<unknown>) {
const elements: Record<string, any> = {
// 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('<!doctype html><body></body>');
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<string, unknown>) =>
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();
});
});