mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-14 19:29:42 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28cbbad591 | ||
|
|
64fea4ff84 | ||
|
|
0ce6e09725 | ||
|
|
3952963bad | ||
|
|
8cffcf4be5 | ||
|
|
517e12fe1d |
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -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. |
|
||||
|
||||
@@ -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? |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 & 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">×</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-<name> 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 & 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">⑂</span>
|
||||
Worktrees
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+499
-53
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user