detached 33c980b

feat(worktrees): centralize lifecycle management (#211)

* feat(worktrees): centralize lifecycle management

* test(worktrees): canonicalize platform paths
commit
33c980b2f9afb0fa9ec49cf94d5927c5a4de1768
author
bnema · · 2026-09-16 17:20 UTC
committer
GitHub · · 2026-09-16 17:20 UTC
parent
69d07a5
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.39.0
19 files · +1282 -922
M README.md +7 -7
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seventeen core built-in tools (read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
12+- seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -268,7 +268,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
268268 - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available.
269269 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
270270 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
271-- `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.
271+- `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work.
272272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
273273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
274274 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
@@ -277,7 +277,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
277277
278278 Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.
279279
280-When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
280+When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
281281
282282 ## Modes
283283
@@ -519,7 +519,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac
519519
520520 ### `/jail`
521521
522-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
522+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
523523
524524 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
525525
@@ -901,7 +901,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
901901
902902 ## Tool rendering
903903
904-By default each tool call (bash, read, write, edit, create_worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
904+By default each tool call (bash, read, write, edit, worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
905905
906906 Set the `tool_render` key in `$ZUT_HOME/config.json`:
907907
@@ -989,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
989989 | `ctrl+d` | Exit on empty input. |
990990 | `ctrl+l` | Redraw the screen. |
991991 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
992-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, glob, lsp, and web_search outputs over ~12 lines). |
992+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, worktree, grep, glob, lsp, and web_search outputs over ~12 lines). |
993993 | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. |
994994 | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. |
995995 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -1173,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types
11731173 packages/agent/modes/ interactive tui, print, json, dialogs
11741174 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
11751175 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1176-packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox
1176+packages/agent/tools/ read, write, edit, bash, worktree, grep, glob, lsp, web search, goals, sandbox
11771177 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
11781178 packages/agent/subagents/ named profiles and resident background runtime
11791179 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
9 9
10 - one static binary.10 - one static binary.
11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.11 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- seventeen core built-in tools (read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.12+- seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries.
13 - three run modes (interactive tui, print, json).13 - three run modes (interactive tui, print, json).
14 - built-in telegram bot.14 - built-in telegram bot.
15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).15 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -268,7 +268,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
268 - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available.268 - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available.
269 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.269 - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable.
270 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).270 - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below).
271-- `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path.271+- `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work.
272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).272 - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md).
273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).273 - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md).
274 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.274 - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`.
@@ -277,7 +277,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
277 277
278 Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.278 Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost.
279 279
280-When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.280+When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress.
281 281
282 ## Modes282 ## Modes
283 283
@@ -519,7 +519,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac
519 519
520 ### `/jail`520 ### `/jail`
521 521
522-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.522+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session.
523 523
524 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.524 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
525 525
@@ -901,7 +901,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
901 901
902 ## Tool rendering902 ## Tool rendering
903 903
904-By default each tool call (bash, read, write, edit, create_worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.904+By default each tool call (bash, read, write, edit, worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame.
905 905
906 Set the `tool_render` key in `$ZUT_HOME/config.json`:906 Set the `tool_render` key in `$ZUT_HOME/config.json`:
907 907
@@ -989,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
989 | `ctrl+d` | Exit on empty input. |989 | `ctrl+d` | Exit on empty input. |
990 | `ctrl+l` | Redraw the screen. |990 | `ctrl+l` | Redraw the screen. |
991 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |991 | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. |
992-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, glob, lsp, and web_search outputs over ~12 lines). |992+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, worktree, grep, glob, lsp, and web_search outputs over ~12 lines). |
993 | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. |993 | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. |
994 | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. |994 | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. |
995 | `@` | Open the file picker. Browse files and directories in the working directory. |995 | `@` | Open the file picker. Browse files and directories in the working directory. |
@@ -1173,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types
1173 packages/agent/modes/ interactive tui, print, json, dialogs1173 packages/agent/modes/ interactive tui, print, json, dialogs
1174 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1174 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1175 packages/agent/modes/telegram/ telegram adapter, api client, daemon1175 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1176-packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox1176+packages/agent/tools/ read, write, edit, bash, worktree, grep, glob, lsp, web search, goals, sandbox
1177 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1177 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1178 packages/agent/subagents/ named profiles and resident background runtime1178 packages/agent/subagents/ named profiles and resident background runtime
1179 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)1179 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)
M docs/extensions.md +1 -1
@@ -225,7 +225,7 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225225 ```
226226
227227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `ast`, `lsp`, `web_search`,
228+`write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,
229229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231231 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
@@ -225,7 +225,7 @@ describing the tool's args (the same shape Anthropic and OpenAI accept).
225 ```225 ```
226 226
227 Tool names live in the same namespace as built-in tools (`read`,227 Tool names live in the same namespace as built-in tools (`read`,
228-`write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `ast`, `lsp`, `web_search`,228+`write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`,
229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,229 `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`,
230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.230 `skill`). Conflicts with active built-ins are silently shadowed by the built-in.
231 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,231 Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`,
M docs/zutfiles.md +8 -6
@@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape
303303
304304 ## Filesystem permissions
305305
306-Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `worktree` tools. Empty or omitted scopes deny the operation.
307307
308308 Two variables are available in filesystem scopes:
309309
@@ -373,23 +373,25 @@ Data-only agent:
373373
374374 Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope.
375375
376-`create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`.
376+The `worktree` tool's `create` action resolves its destination without prompting. A local `zut.worktrees.path` Git configuration entry (an absolute path) overrides the default for that repository. Repositories configured by an older zut release with the relative value `.worktrees` must remove it with `git config --local --unset zut.worktrees.path` before using the new default. Otherwise the tool checks out under `<default-root>/<repo-id>`, where the default root is `$ZUT_HOME/worktrees` and `<repo-id>` combines a readable repository slug with a hash of the canonical Git common directory. The same repository therefore keeps a stable directory across sessions, while repositories that share a name stay separate. The tool changes no repository files, never copies uncommitted or ignored files, and refuses an existing branch or worktree path.
377377
378-The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell:
378+The tool needs read and write scopes covering the repository root, Git metadata, and the resolved worktree destination. Because the default destination lives outside the workspace, an unconfigured repository also needs a scope covering `$ZUT_HOME/worktrees`; set `zut.worktrees.path` to a directory inside a declared scope for a fully contained layout. It also requires Bash permission for `git`, even though it invokes Git directly without a shell:
379379
380380 ```json
381381 {
382382 "permissions": {
383383 "fs": {
384- "read": ["${workspace}"],
385- "write": ["${workspace}"]
384+ "read": ["${workspace}", "/home/you/.local/state/zut/worktrees"],
385+ "write": ["${workspace}", "/home/you/.local/state/zut/worktrees"]
386386 },
387387 "bash": { "mode": "allowlist", "allow": ["git"] }
388388 }
389389 }
390390 ```
391391
392-The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox.
392+Replace the worktrees path with the resolved `$ZUT_HOME/worktrees` directory for the host, or configure `zut.worktrees.path` per repository instead.
393+
394+The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `worktree` creation as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox.
393395
394396 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.
395397
@@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape
303 303
304 ## Filesystem permissions304 ## Filesystem permissions
305 305
306-Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `create_worktree` tools. Empty or omitted scopes deny the operation.306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `worktree` tools. Empty or omitted scopes deny the operation.
307 307
308 Two variables are available in filesystem scopes:308 Two variables are available in filesystem scopes:
309 309
@@ -373,23 +373,25 @@ Data-only agent:
373 373
374 Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope.374 Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope.
375 375
376-`create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`.376+The `worktree` tool's `create` action resolves its destination without prompting. A local `zut.worktrees.path` Git configuration entry (an absolute path) overrides the default for that repository. Repositories configured by an older zut release with the relative value `.worktrees` must remove it with `git config --local --unset zut.worktrees.path` before using the new default. Otherwise the tool checks out under `<default-root>/<repo-id>`, where the default root is `$ZUT_HOME/worktrees` and `<repo-id>` combines a readable repository slug with a hash of the canonical Git common directory. The same repository therefore keeps a stable directory across sessions, while repositories that share a name stay separate. The tool changes no repository files, never copies uncommitted or ignored files, and refuses an existing branch or worktree path.
377 377
378-The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell:378+The tool needs read and write scopes covering the repository root, Git metadata, and the resolved worktree destination. Because the default destination lives outside the workspace, an unconfigured repository also needs a scope covering `$ZUT_HOME/worktrees`; set `zut.worktrees.path` to a directory inside a declared scope for a fully contained layout. It also requires Bash permission for `git`, even though it invokes Git directly without a shell:
379 379
380 ```json380 ```json
381 {381 {
382 "permissions": {382 "permissions": {
383 "fs": {383 "fs": {
384- "read": ["${workspace}"],384+ "read": ["${workspace}", "/home/you/.local/state/zut/worktrees"],
385- "write": ["${workspace}"]385+ "write": ["${workspace}", "/home/you/.local/state/zut/worktrees"]
386 },386 },
387 "bash": { "mode": "allowlist", "allow": ["git"] }387 "bash": { "mode": "allowlist", "allow": ["git"] }
388 }388 }
389 }389 }
390 ```390 ```
391 391
392-The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox.392+Replace the worktrees path with the resolved `$ZUT_HOME/worktrees` directory for the host, or configure `zut.worktrees.path` per repository instead.
393+
394+The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `worktree` creation as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox.
393 395
394 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.396 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.
395 397
M examples/extensions/mcp-bridge/bridge.go +1 -1
@@ -6,7 +6,7 @@
66 //
77 // The double underscore separates the server name from the tool name,
88 // avoiding collisions with zut's built-in tools (read, write, edit, bash,
9-// create_worktree, grep, lsp, web_search, skill). The web_search and grep
9+// worktree, grep, lsp, web_search, skill). The web_search and grep
1010 // names are reserved even when those built-ins are disabled.
1111 package main
1212
@@ -6,7 +6,7 @@
6 //6 //
7 // The double underscore separates the server name from the tool name,7 // The double underscore separates the server name from the tool name,
8 // avoiding collisions with zut's built-in tools (read, write, edit, bash,8 // avoiding collisions with zut's built-in tools (read, write, edit, bash,
9-// create_worktree, grep, lsp, web_search, skill). The web_search and grep9+// worktree, grep, lsp, web_search, skill). The web_search and grep
10 // names are reserved even when those built-ins are disabled.10 // names are reserved even when those built-ins are disabled.
11 package main11 package main
12 12
M packages/agent/build.go +27 -13
@@ -1312,8 +1312,13 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
13121312 v.Sandbox = s
13131313 case *tools.PythonTool:
13141314 v.Sandbox = s
1315- case *tools.CreateWorktreeTool:
1316- v.Sandbox = s
1315+ case *tools.WorktreeTool:
1316+ if v.Create != nil {
1317+ v.Create.Sandbox = s
1318+ }
1319+ if v.Manage != nil {
1320+ v.Manage.Sandbox = s
1321+ }
13171322 case *tools.LSPTool:
13181323 v.Sandbox = s
13191324 case *tools.GrepTool:
@@ -1336,6 +1341,12 @@ func (r Resolved) NewAgent() *core.Agent {
13361341 a.Reasoning = r.Reasoning
13371342 a.Temperature = r.Temperature
13381343 a.FastMode = r.FastMode
1344+ if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1345+ cwd := r.CWD
1346+ a.HostContext = func(ctx context.Context) string {
1347+ return tools.WorktreeInventoryContext(ctx, cwd)
1348+ }
1349+ }
13391350 return a
13401351 }
13411352
@@ -1352,16 +1363,19 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
13521363 manager = lsp.NewManagerWithOptions(options)
13531364 }
13541365 all := map[string]core.Tool{
1355- "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1356- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1357- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1358- "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1359- "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1360- "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1361- "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1362- "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1363- "update_goal": &tools.UpdateGoalTool{},
1364- "update_plan": &tools.UpdatePlanTool{},
1366+ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1367+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1368+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1369+ "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1370+ "worktree": &tools.WorktreeTool{
1371+ Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1372+ Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1373+ },
1374+ "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1375+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1376+ "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1377+ "update_goal": &tools.UpdateGoalTool{},
1378+ "update_plan": &tools.UpdatePlanTool{},
13651379 }
13661380 if webSearchAllowedForRegistry(args) {
13671381 for name, tool := range tools.NewWebTools() {
@@ -1508,7 +1522,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
15081522 return false
15091523 }
15101524
1511-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1525+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
15121526
15131527 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
15141528 var out []ToolSummary
@@ -1312,8 +1312,13 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1312 v.Sandbox = s1312 v.Sandbox = s
1313 case *tools.PythonTool:1313 case *tools.PythonTool:
1314 v.Sandbox = s1314 v.Sandbox = s
1315- case *tools.CreateWorktreeTool:1315+ case *tools.WorktreeTool:
1316- v.Sandbox = s1316+ if v.Create != nil {
1317+ v.Create.Sandbox = s
1318+ }
1319+ if v.Manage != nil {
1320+ v.Manage.Sandbox = s
1321+ }
1317 case *tools.LSPTool:1322 case *tools.LSPTool:
1318 v.Sandbox = s1323 v.Sandbox = s
1319 case *tools.GrepTool:1324 case *tools.GrepTool:
@@ -1336,6 +1341,12 @@ func (r Resolved) NewAgent() *core.Agent {
1336 a.Reasoning = r.Reasoning1341 a.Reasoning = r.Reasoning
1337 a.Temperature = r.Temperature1342 a.Temperature = r.Temperature
1338 a.FastMode = r.FastMode1343 a.FastMode = r.FastMode
1344+ if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok {
1345+ cwd := r.CWD
1346+ a.HostContext = func(ctx context.Context) string {
1347+ return tools.WorktreeInventoryContext(ctx, cwd)
1348+ }
1349+ }
1339 return a1350 return a
1340 }1351 }
1341 1352
@@ -1352,16 +1363,19 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1352 manager = lsp.NewManagerWithOptions(options)1363 manager = lsp.NewManagerWithOptions(options)
1353 }1364 }
1354 all := map[string]core.Tool{1365 all := map[string]core.Tool{
1355- "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},1366+ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1356- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},1367+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1357- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1368+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1358- "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1369+ "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1359- "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},1370+ "worktree": &tools.WorktreeTool{
1360- "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},1371+ Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")},
1361- "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},1372+ Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox},
1362- "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1373+ },
1363- "update_goal": &tools.UpdateGoalTool{},1374+ "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox},
1364- "update_plan": &tools.UpdatePlanTool{},1375+ "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1376+ "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1377+ "update_goal": &tools.UpdateGoalTool{},
1378+ "update_plan": &tools.UpdatePlanTool{},
1365 }1379 }
1366 if webSearchAllowedForRegistry(args) {1380 if webSearchAllowedForRegistry(args) {
1367 for name, tool := range tools.NewWebTools() {1381 for name, tool := range tools.NewWebTools() {
@@ -1508,7 +1522,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1508 return false1522 return false
1509 }1523 }
1510 1524
1511-var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}1525+var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"}
1512 1526
1513 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1527 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1514 var out []ToolSummary1528 var out []ToolSummary
M packages/agent/ext/ext.go +1 -1
@@ -421,7 +421,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// python, create_worktree, grep, glob, ast, lsp, web_search, web_open,
424+// python, worktree, grep, glob, ast, lsp, web_search, web_open,
425425 // web_find, web_click, schedule, update_goal, update_plan, and skill) are
426426 // silently shadowed by the built-in. Every native public-web name, plus
427427 // grep, glob, ast, python, schedule,
@@ -421,7 +421,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
421 // into the agent's registry once the extension's ready frame fires.421 // into the agent's registry once the extension's ready frame fires.
422 //422 //
423 // Conflicts with active built-in tool names (read, write, edit, bash,423 // Conflicts with active built-in tool names (read, write, edit, bash,
424-// python, create_worktree, grep, glob, ast, lsp, web_search, web_open,424+// python, worktree, grep, glob, ast, lsp, web_search, web_open,
425 // web_find, web_click, schedule, update_goal, update_plan, and skill) are425 // web_find, web_click, schedule, update_goal, update_plan, and skill) are
426 // silently shadowed by the built-in. Every native public-web name, plus426 // silently shadowed by the built-in. Every native public-web name, plus
427 // grep, glob, ast, python, schedule,427 // grep, glob, ast, python, schedule,
M packages/agent/sdk/sdk.go +1 -1
@@ -78,7 +78,7 @@ type Config struct {
7878 BaseURL string
7979
8080 // Tools is the list of tools to enable. Nil/empty enables the ordinary
81- // built-ins: read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp,
81+ // built-ins: read, write, edit, bash, python, worktree, grep, glob, ast, lsp,
8282 // update_goal, and update_plan. The public-web capability is excluded unless this list
8383 // explicitly contains "web_search"; that selector enables web_search,
8484 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
@@ -78,7 +78,7 @@ type Config struct {
78 BaseURL string78 BaseURL string
79 79
80 // Tools is the list of tools to enable. Nil/empty enables the ordinary80 // Tools is the list of tools to enable. Nil/empty enables the ordinary
81- // built-ins: read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp,81+ // built-ins: read, write, edit, bash, python, worktree, grep, glob, ast, lsp,
82 // update_goal, and update_plan. The public-web capability is excluded unless this list82 // update_goal, and update_plan. The public-web capability is excluded unless this list
83 // explicitly contains "web_search"; that selector enables web_search,83 // explicitly contains "web_search"; that selector enables web_search,
84 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.84 // web_open, web_find, and web_click. Pass NoTools=true to disable everything.
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +1 -1
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
2929 2. **Tools** — register tools the LLM itself calls. Schema is
3030 JSON Schema; zut routes the model's `tool_call` to the
3131 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/create_worktree/grep/lsp/web_search/skill).
32+ (read/write/edit/bash/worktree/grep/lsp/web_search/skill).
3333
3434 3. **Lifecycle hooks** — subscribe to events
3535 (session_start, turn_start, tool_call, turn_end,
@@ -29,7 +29,7 @@ Three things an extension can do (any combination):
29 2. **Tools** — register tools the LLM itself calls. Schema is29 2. **Tools** — register tools the LLM itself calls. Schema is
30 JSON Schema; zut routes the model's `tool_call` to the30 JSON Schema; zut routes the model's `tool_call` to the
31 extension's `tool_result`. Same lifecycle as built-in tools31 extension's `tool_result`. Same lifecycle as built-in tools
32- (read/write/edit/bash/create_worktree/grep/lsp/web_search/skill).32+ (read/write/edit/bash/worktree/grep/lsp/web_search/skill).
33 33
34 3. **Lifecycle hooks** — subscribe to events34 3. **Lifecycle hooks** — subscribe to events
35 (session_start, turn_start, tool_call, turn_end,35 (session_start, turn_start, tool_call, turn_end,
M packages/agent/tools/bash_unix.go +2 -2
@@ -57,8 +57,8 @@ func killBashProcessGroup(cmd *exec.Cmd) error {
5757 }
5858
5959 // killProcessGroup sends SIGTERM then SIGKILL to the entire process group.
60-// It is also used by the create_worktree helper, which keeps its existing
61-// graceful cancellation behavior.
60+// It is also used by the worktree Git helper, which keeps graceful
61+// cancellation behavior.
6262 func killProcessGroup(cmd *exec.Cmd) {
6363 if cmd == nil || cmd.Process == nil {
6464 return
@@ -57,8 +57,8 @@ func killBashProcessGroup(cmd *exec.Cmd) error {
57 }57 }
58 58
59 // killProcessGroup sends SIGTERM then SIGKILL to the entire process group.59 // killProcessGroup sends SIGTERM then SIGKILL to the entire process group.
60-// It is also used by the create_worktree helper, which keeps its existing60+// It is also used by the worktree Git helper, which keeps graceful
61-// graceful cancellation behavior.61+// cancellation behavior.
62 func killProcessGroup(cmd *exec.Cmd) {62 func killProcessGroup(cmd *exec.Cmd) {
63 if cmd == nil || cmd.Process == nil {63 if cmd == nil || cmd.Process == nil {
64 return64 return
M packages/agent/tools/create_worktree.go +113 -359
@@ -3,6 +3,8 @@ package tools
33 import (
44 "bytes"
55 "context"
6+ "crypto/sha256"
7+ "encoding/hex"
68 "encoding/json"
79 "errors"
810 "fmt"
@@ -19,30 +21,31 @@ import (
1921 )
2022
2123 const (
22- worktreesIgnoreEntry = "/.worktrees/"
23- worktreesUnanchoredIgnoreEntry = ".worktrees/"
24- defaultWorktreeRoot = ".worktrees"
25- worktreeConfigKey = "zut.worktrees.path"
26- maxCreateWorktreeGitOutput = 64 * 1024
27- maxCreateWorktreeIgnoreBytes = 1 << 20
24+ worktreeConfigKey = "zut.worktrees.path"
25+ maxCreateWorktreeGitOutput = 64 * 1024
26+ // worktreeRepoIDHashLength keeps the hash short enough to read but wide
27+ // enough that distinct repositories do not collide in practice.
28+ worktreeRepoIDHashLength = 12
29+ worktreeRepoSlugMaxLen = 40
2830 )
2931
3032 var createWorktreeOperationMu sync.Mutex
3133
32-// CreateWorktreeTool creates a persistent branch checkout. A repository that
33-// has neither a local worktree configuration nor an existing .worktrees
34-// directory returns a bootstrap request without changing any files.
34+// CreateWorktreeTool creates a persistent branch checkout. An unconfigured
35+// repository checks out under a stable per-repository directory beneath
36+// DefaultRoot. A local zut.worktrees.path Git configuration entry overrides
37+// that root for the repository.
3538 type CreateWorktreeTool struct {
36- CWD string
37- Sandbox *Sandbox
39+ CWD string
40+ Sandbox *Sandbox
41+ DefaultRoot string
3842
3943 previewMu sync.Mutex
4044 preview *createWorktreePreview
4145 }
4246
4347 type createWorktreeArgs struct {
44- Branch string `json:"branch"`
45- BootstrapRoot string `json:"bootstrap_root,omitempty"`
48+ Branch string `json:"branch"`
4649 }
4750
4851 const createWorktreeSchema = `{
@@ -51,10 +54,6 @@ const createWorktreeSchema = `{
5154 "branch":{
5255 "type":"string",
5356 "description":"Name of the new Git branch."
54- },
55- "bootstrap_root":{
56- "type":"string",
57- "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files."
5857 }
5958 },
6059 "required":["branch"]
@@ -62,12 +61,12 @@ const createWorktreeSchema = `{
6261
6362 func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
6463 func (t *CreateWorktreeTool) Description() string {
65- return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root."
64+ return "Create a persistent Git branch worktree under the repository's configured worktree root or zut's global worktrees root."
6665 }
6766 func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
6867
69-// Preview reports the exact bootstrap request or checkout that Execute will
70-// produce without modifying the repository.
68+// Preview reports the checkout that Execute will produce without modifying the
69+// repository or filesystem.
7170 func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
7271 plan, err := t.plan(ctx, raw)
7372 if err != nil {
@@ -87,20 +86,13 @@ func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, p
8786 if err != nil {
8887 return core.ToolResult{}, err
8988 }
90- if plan.bootstrapRequired {
91- return plan.preview, nil
92- }
9389 return executeCreateWorktreePlan(ctx, plan, progress)
9490 }
9591
9692 func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
97- configSaved := false
98- var createdWorktreeParents []string
99- if plan.setConfig {
100- if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
101- return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
102- }
103- configSaved = true
93+ createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
94+ if err != nil {
95+ return core.ToolResult{}, fmt.Errorf("create_worktree: inspect worktree directories: %w", err)
10496 }
10597 rollback := func(cause error) error {
10698 cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -111,24 +103,9 @@ func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, pro
111103 if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
112104 cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
113105 }
114- if err := plan.ignore.restore(); err != nil {
115- cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
116- }
117- if configSaved {
118- if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
119- cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
120- }
121- }
122106 return cause
123107 }
124- if err := plan.ignore.apply(); err != nil {
125- return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
126- }
127108
128- createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
129- if err != nil {
130- return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
131- }
132109 if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
133110 return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
134111 }
@@ -173,18 +150,15 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes
173150 }
174151
175152 type createWorktreePlan struct {
176- repoRoot string
177- branch string
178- base string
179- worktreeRoot string
180- worktreePath string
181- rootSource string
182- configValue string
183- setConfig bool
184- bootstrapRequired bool
185- previewText string
186- preview core.ToolResult
187- ignore worktreesIgnoreUpdate
153+ repoRoot string
154+ branch string
155+ base string
156+ worktreeRoot string
157+ worktreePath string
158+ rootSource string
159+ repoID string
160+ previewText string
161+ preview core.ToolResult
188162 }
189163
190164 func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
@@ -251,67 +225,28 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
251225 return createWorktreePlan{}, err
252226 }
253227
228+ repoID := worktreeRepoID(commonDir)
254229 configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
255230 if err != nil {
256231 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
257232 }
258- bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
259-
260- rootSource := "existing .worktrees directory"
261- configValue := ""
262- setConfig := false
263- defaultRoot := false
264233 var worktreeRoot string
234+ rootSource := "global default root"
265235 if configured {
266- worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
236+ worktreeRoot, err = resolveConfiguredWorktreeRoot(configuredRoot)
267237 if err != nil {
268238 return createWorktreePlan{}, err
269239 }
270- if bootstrapRoot != "" {
271- requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
272- if err != nil {
273- return createWorktreePlan{}, err
274- }
275- configuredCanonical, err := canonicalOrParent(worktreeRoot)
276- if err != nil {
277- return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err)
278- }
279- requestedCanonical, err := canonicalOrParent(requestedRoot)
280- if err != nil {
281- return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
282- }
283- if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical {
284- return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
285- }
286- }
287240 rootSource = "local Git config"
288241 } else {
289- defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
290- exists, err := inspectWorktreeRoot(defaultPath)
242+ defaultRoot, err := t.defaultRoot()
291243 if err != nil {
292244 return createWorktreePlan{}, err
293245 }
294- if !exists && bootstrapRoot == "" {
295- return bootstrapRequiredPlan(branch, repoRoot), nil
296- }
297- if exists {
298- if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot {
299- return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
300- }
301- worktreeRoot = defaultPath
302- defaultRoot = true
303- } else {
304- worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
305- if err != nil {
306- return createWorktreePlan{}, err
307- }
308- setConfig = true
309- rootSource = "bootstrap selection"
310- }
246+ worktreeRoot = filepath.Join(defaultRoot, repoID)
311247 }
312248
313- worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
314- if err != nil {
249+ if err := validateWorktreeRoot(worktreeRoot); err != nil {
315250 return createWorktreePlan{}, err
316251 }
317252 worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
@@ -319,24 +254,18 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
319254 if err != nil {
320255 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
321256 }
322- if !defaultRoot && isUnder(repoRoot, rootCanonical) {
323- return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
257+ if isUnder(repoRoot, rootCanonical) {
258+ return createWorktreePlan{}, errors.New("create_worktree: worktree root must be outside the repository")
324259 }
325260 pathCanonical, err := canonicalOrParent(worktreePath)
326261 if err != nil {
327262 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
328263 }
329264 if !isUnder(rootCanonical, pathCanonical) {
330- return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
265+ return createWorktreePlan{}, errors.New("create_worktree: branch path escapes the worktree root")
331266 }
332267
333- ignorePath := ""
334- accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
335- if defaultRoot {
336- ignorePath = filepath.Join(repoRoot, ".gitignore")
337- accessPaths = append(accessPaths, ignorePath)
338- }
339- if err := t.checkReadWriteAccess(accessPaths...); err != nil {
268+ if err := t.checkReadWriteAccess(repoRoot, commonDir, worktreeRoot, worktreePath); err != nil {
340269 return createWorktreePlan{}, err
341270 }
342271 if _, err := os.Lstat(worktreePath); err == nil {
@@ -352,29 +281,9 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
352281 return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
353282 }
354283
355- ignore := worktreesIgnoreUpdate{}
356- ignoreAction := "not modified (external worktree root)"
357- if defaultRoot {
358- ignore, err = newWorktreesIgnoreUpdate(ignorePath)
359- if err != nil {
360- return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
361- }
362- ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
363- if ignore.changed {
364- ignoreAction = "add " + worktreesIgnoreEntry
365- }
366- }
367-
368- displayRoot := worktreeRoot
369- if defaultRoot {
370- displayRoot = defaultWorktreeRoot
371- }
372- displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
373- configAction := ""
374- if setConfig {
375- configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
376- }
377- previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction)
284+ displayRoot := filepath.ToSlash(worktreeRoot)
285+ displayPath := filepath.ToSlash(worktreePath)
286+ previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath)
378287 return createWorktreePlan{
379288 repoRoot: repoRoot,
380289 branch: branch,
@@ -382,40 +291,72 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
382291 worktreeRoot: worktreeRoot,
383292 worktreePath: worktreePath,
384293 rootSource: rootSource,
385- configValue: configValue,
386- setConfig: setConfig,
294+ repoID: repoID,
387295 previewText: previewText,
388296 preview: core.ToolResult{
389297 Content: []provider.Content{provider.TextBlock{Text: previewText}},
390298 Details: map[string]any{
391- "state": "ready",
392- "branch": branch,
393- "base": base,
394- "worktree_root": displayRoot,
395- "root_source": rootSource,
396- "path": displayPath,
397- "bootstrap_saved": setConfig,
299+ "state": "ready",
300+ "branch": branch,
301+ "base": base,
302+ "worktree_root": displayRoot,
303+ "root_source": rootSource,
304+ "repo_id": repoID,
305+ "path": displayPath,
398306 },
399307 },
400- ignore: ignore,
401308 }, nil
402309 }
403310
404-func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
405- text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot))
406- return createWorktreePlan{
407- branch: branch,
408- bootstrapRequired: true,
409- previewText: text,
410- preview: core.ToolResult{
411- Content: []provider.Content{provider.TextBlock{Text: text}},
412- Details: map[string]any{
413- "state": "bootstrap_required",
414- "branch": branch,
415- "default_bootstrap_root": defaultWorktreeRoot,
416- },
417- },
311+func (t *CreateWorktreeTool) defaultRoot() (string, error) {
312+ root := strings.TrimSpace(t.DefaultRoot)
313+ if root == "" {
314+ return "", errors.New("create_worktree: default worktree root is not configured")
315+ }
316+ return canonicalOrParent(root)
317+}
318+
319+// worktreeRepoID derives a stable, collision-resistant directory name for the
320+// repository that owns commonDir. The name pairs a readable slug with a hash of
321+// the canonical common directory so repositories sharing a base name still get
322+// distinct roots while one repository keeps the same directory across sessions.
323+func worktreeRepoID(commonDir string) string {
324+ sum := sha256.Sum256([]byte(filepath.ToSlash(commonDir)))
325+ return worktreeRepoSlug(commonDir) + "-" + hex.EncodeToString(sum[:])[:worktreeRepoIDHashLength]
326+}
327+
328+func worktreeRepoSlug(commonDir string) string {
329+ base := filepath.Base(commonDir)
330+ if trimmed := strings.TrimSuffix(base, ".git"); trimmed != "" {
331+ base = trimmed
332+ } else {
333+ base = filepath.Base(filepath.Dir(commonDir))
334+ }
335+ var builder strings.Builder
336+ dashed := false
337+ for _, r := range base {
338+ switch {
339+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
340+ builder.WriteRune(r)
341+ dashed = false
342+ case r >= 'A' && r <= 'Z':
343+ builder.WriteRune(r + ('a' - 'A'))
344+ dashed = false
345+ default:
346+ if !dashed {
347+ builder.WriteByte('-')
348+ dashed = true
349+ }
350+ }
418351 }
352+ slug := strings.Trim(builder.String(), "-._")
353+ if len(slug) > worktreeRepoSlugMaxLen {
354+ slug = strings.Trim(slug[:worktreeRepoSlugMaxLen], "-._")
355+ }
356+ if slug == "" {
357+ slug = "repo"
358+ }
359+ return slug
419360 }
420361
421362 func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
@@ -476,95 +417,42 @@ func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
476417 return nil
477418 }
478419
479-func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
420+func resolveConfiguredWorktreeRoot(value string) (string, error) {
480421 value = strings.TrimSpace(value)
481- if value == defaultWorktreeRoot {
482- return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
422+ if value == "" {
423+ return "", errors.New("create_worktree: local worktree configuration is empty")
483424 }
484425 if !filepath.IsAbs(value) {
485- return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
426+ return "", errors.New("create_worktree: local worktree configuration must be an absolute path; remove a legacy relative value with `git config --local --unset zut.worktrees.path`")
486427 }
487- return value, false, nil
428+ return filepath.Clean(value), nil
488429 }
489430
490-func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
491- if value == defaultWorktreeRoot {
492- return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
493- }
494- if !filepath.IsAbs(value) {
495- return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
496- }
497- root, err := canonicalOrParent(value)
498- if err != nil {
499- return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
500- }
501- if isUnder(repoRoot, root) {
502- return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
503- }
504- return root, false, root, nil
505-}
506-
507-func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
431+func validateWorktreeRoot(path string) error {
508432 info, err := os.Lstat(path)
509433 if os.IsNotExist(err) {
510- return path, nil
511- }
512- if err != nil {
513- return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
514- }
515- if info.Mode()&os.ModeSymlink != 0 {
516- return "", errors.New("create_worktree: worktree root must not be a symbolic link")
517- }
518- if !info.IsDir() {
519- return "", errors.New("create_worktree: worktree root exists but is not a directory")
520- }
521- if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
522- return "", errors.New("create_worktree: invalid default worktree root")
523- }
524- return path, nil
525-}
526-
527-func inspectWorktreeRoot(path string) (bool, error) {
528- info, err := os.Lstat(path)
529- if os.IsNotExist(err) {
530- return false, nil
434+ return nil
531435 }
532436 if err != nil {
533- return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
437+ return fmt.Errorf("create_worktree: inspect worktree root: %w", err)
534438 }
535439 if info.Mode()&os.ModeSymlink != 0 {
536- return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
440+ return errors.New("create_worktree: worktree root must not be a symbolic link")
537441 }
538442 if !info.IsDir() {
539- return false, errors.New("create_worktree: .worktrees exists but is not a directory")
443+ return errors.New("create_worktree: worktree root exists but is not a directory")
540444 }
541- return true, nil
542-}
543-
544-func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
545- if defaultRoot {
546- relative, err := filepath.Rel(repoRoot, path)
547- if err == nil {
548- return filepath.ToSlash(relative)
549- }
550- }
551- return filepath.ToSlash(path)
445+ return nil
552446 }
553447
554448 func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
555- return a.bootstrapRequired == b.bootstrapRequired &&
556- a.repoRoot == b.repoRoot &&
449+ return a.repoRoot == b.repoRoot &&
557450 a.branch == b.branch &&
558451 a.base == b.base &&
559452 a.worktreeRoot == b.worktreeRoot &&
560453 a.worktreePath == b.worktreePath &&
561454 a.rootSource == b.rootSource &&
562- a.configValue == b.configValue &&
563- a.setConfig == b.setConfig &&
564- a.ignore.path == b.ignore.path &&
565- a.ignore.existed == b.ignore.existed &&
566- a.ignore.changed == b.ignore.changed &&
567- bytes.Equal(a.ignore.before, b.ignore.before)
455+ a.repoID == b.repoID
568456 }
569457
570458 func missingWorktreeParents(worktreePath string) ([]string, error) {
@@ -601,21 +489,6 @@ func removeCreatedWorktreeParents(parents []string) error {
601489 return nil
602490 }
603491
604-func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
605- value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
606- if err != nil {
607- return err
608- }
609- if !configured {
610- return nil
611- }
612- if value != p.configValue {
613- return errors.New("local worktree configuration changed during creation")
614- }
615- _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
616- return err
617-}
618-
619492 func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
620493 registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
621494 if err != nil {
@@ -803,122 +676,3 @@ func createWorktreeGitEnv() []string {
803676 "SSH_ASKPASS=",
804677 )
805678 }
806-
807-type worktreesIgnoreUpdate struct {
808- path string
809- before []byte
810- after []byte
811- existed bool
812- mode os.FileMode
813- changed bool
814-}
815-
816-func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
817- contents, mode, existed, err := readWorktreesIgnore(path)
818- if err != nil {
819- return worktreesIgnoreUpdate{}, err
820- }
821- for _, line := range strings.Split(string(contents), "\n") {
822- switch strings.TrimSuffix(line, "\r") {
823- case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
824- return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
825- }
826- }
827- lineEnding := "\n"
828- if bytes.Contains(contents, []byte("\r\n")) {
829- lineEnding = "\r\n"
830- }
831- after := append([]byte(nil), contents...)
832- if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
833- after = append(after, lineEnding...)
834- }
835- after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
836- return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
837-}
838-
839-func (u worktreesIgnoreUpdate) apply() error {
840- if !u.changed {
841- return nil
842- }
843- current, _, existed, err := readWorktreesIgnore(u.path)
844- if err != nil {
845- return err
846- }
847- if existed != u.existed || !bytes.Equal(current, u.before) {
848- return errors.New(".gitignore changed since preview; inspect it and retry")
849- }
850- return writeWorktreesIgnore(u.path, u.after, u.mode)
851-}
852-
853-func (u worktreesIgnoreUpdate) restore() error {
854- if !u.changed {
855- return nil
856- }
857- current, _, existed, err := readWorktreesIgnore(u.path)
858- if err != nil {
859- return err
860- }
861- if !existed || !bytes.Equal(current, u.after) {
862- return nil
863- }
864- if !u.existed {
865- if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
866- return err
867- }
868- return nil
869- }
870- return writeWorktreesIgnore(u.path, u.before, u.mode)
871-}
872-
873-func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
874- info, err := os.Lstat(path)
875- if os.IsNotExist(err) {
876- return nil, 0o644, false, nil
877- }
878- if err != nil {
879- return nil, 0, false, err
880- }
881- if info.Mode()&os.ModeSymlink != 0 {
882- return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
883- }
884- if !info.Mode().IsRegular() {
885- return nil, 0, false, errors.New(".gitignore must be a regular file")
886- }
887- if info.Size() > maxCreateWorktreeIgnoreBytes {
888- return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
889- }
890- file, err := os.Open(path)
891- if err != nil {
892- return nil, 0, false, err
893- }
894- defer file.Close()
895- contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
896- if err != nil {
897- return nil, 0, false, err
898- }
899- if len(contents) > maxCreateWorktreeIgnoreBytes {
900- return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
901- }
902- return contents, info.Mode().Perm(), true, nil
903-}
904-
905-func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
906- file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
907- if err != nil {
908- return err
909- }
910- tempPath := file.Name()
911- defer os.Remove(tempPath)
912- if err := file.Chmod(mode); err != nil {
913- _ = file.Close()
914- return err
915- }
916- if _, err := file.Write(contents); err != nil {
917- _ = file.Close()
918- return err
919- }
920- if err := file.Close(); err != nil {
921- return err
922- }
923- return os.Rename(tempPath, path)
924-}
@@ -3,6 +3,8 @@ package tools
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "crypto/sha256"
7+ "encoding/hex"
6 "encoding/json"8 "encoding/json"
7 "errors"9 "errors"
8 "fmt"10 "fmt"
@@ -19,30 +21,31 @@ import (
19 )21 )
20 22
21 const (23 const (
22- worktreesIgnoreEntry = "/.worktrees/"24+ worktreeConfigKey = "zut.worktrees.path"
23- worktreesUnanchoredIgnoreEntry = ".worktrees/"25+ maxCreateWorktreeGitOutput = 64 * 1024
24- defaultWorktreeRoot = ".worktrees"26+ // worktreeRepoIDHashLength keeps the hash short enough to read but wide
25- worktreeConfigKey = "zut.worktrees.path"27+ // enough that distinct repositories do not collide in practice.
26- maxCreateWorktreeGitOutput = 64 * 102428+ worktreeRepoIDHashLength = 12
27- maxCreateWorktreeIgnoreBytes = 1 << 2029+ worktreeRepoSlugMaxLen = 40
28 )30 )
29 31
30 var createWorktreeOperationMu sync.Mutex32 var createWorktreeOperationMu sync.Mutex
31 33
32-// CreateWorktreeTool creates a persistent branch checkout. A repository that34+// CreateWorktreeTool creates a persistent branch checkout. An unconfigured
33-// has neither a local worktree configuration nor an existing .worktrees35+// repository checks out under a stable per-repository directory beneath
34-// directory returns a bootstrap request without changing any files.36+// DefaultRoot. A local zut.worktrees.path Git configuration entry overrides
37+// that root for the repository.
35 type CreateWorktreeTool struct {38 type CreateWorktreeTool struct {
36- CWD string39+ CWD string
37- Sandbox *Sandbox40+ Sandbox *Sandbox
41+ DefaultRoot string
38 42
39 previewMu sync.Mutex43 previewMu sync.Mutex
40 preview *createWorktreePreview44 preview *createWorktreePreview
41 }45 }
42 46
43 type createWorktreeArgs struct {47 type createWorktreeArgs struct {
44- Branch string `json:"branch"`48+ Branch string `json:"branch"`
45- BootstrapRoot string `json:"bootstrap_root,omitempty"`
46 }49 }
47 50
48 const createWorktreeSchema = `{51 const createWorktreeSchema = `{
@@ -51,10 +54,6 @@ const createWorktreeSchema = `{
51 "branch":{54 "branch":{
52 "type":"string",55 "type":"string",
53 "description":"Name of the new Git branch."56 "description":"Name of the new Git branch."
54- },
55- "bootstrap_root":{
56- "type":"string",
57- "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files."
58 }57 }
59 },58 },
60 "required":["branch"]59 "required":["branch"]
@@ -62,12 +61,12 @@ const createWorktreeSchema = `{
62 61
63 func (t *CreateWorktreeTool) Name() string { return "create_worktree" }62 func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
64 func (t *CreateWorktreeTool) Description() string {63 func (t *CreateWorktreeTool) Description() string {
65- return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root."64+ return "Create a persistent Git branch worktree under the repository's configured worktree root or zut's global worktrees root."
66 }65 }
67 func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }66 func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
68 67
69-// Preview reports the exact bootstrap request or checkout that Execute will68+// Preview reports the checkout that Execute will produce without modifying the
70-// produce without modifying the repository.69+// repository or filesystem.
71 func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {70 func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
72 plan, err := t.plan(ctx, raw)71 plan, err := t.plan(ctx, raw)
73 if err != nil {72 if err != nil {
@@ -87,20 +86,13 @@ func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, p
87 if err != nil {86 if err != nil {
88 return core.ToolResult{}, err87 return core.ToolResult{}, err
89 }88 }
90- if plan.bootstrapRequired {
91- return plan.preview, nil
92- }
93 return executeCreateWorktreePlan(ctx, plan, progress)89 return executeCreateWorktreePlan(ctx, plan, progress)
94 }90 }
95 91
96 func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {92 func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
97- configSaved := false93+ createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
98- var createdWorktreeParents []string94+ if err != nil {
99- if plan.setConfig {95+ return core.ToolResult{}, fmt.Errorf("create_worktree: inspect worktree directories: %w", err)
100- if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
101- return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
102- }
103- configSaved = true
104 }96 }
105 rollback := func(cause error) error {97 rollback := func(cause error) error {
106 cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)98 cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -111,24 +103,9 @@ func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, pro
111 if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {103 if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
112 cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)104 cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
113 }105 }
114- if err := plan.ignore.restore(); err != nil {
115- cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
116- }
117- if configSaved {
118- if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
119- cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
120- }
121- }
122 return cause106 return cause
123 }107 }
124- if err := plan.ignore.apply(); err != nil {
125- return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
126- }
127 108
128- createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
129- if err != nil {
130- return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
131- }
132 if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {109 if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
133 return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))110 return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
134 }111 }
@@ -173,18 +150,15 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes
173 }150 }
174 151
175 type createWorktreePlan struct {152 type createWorktreePlan struct {
176- repoRoot string153+ repoRoot string
177- branch string154+ branch string
178- base string155+ base string
179- worktreeRoot string156+ worktreeRoot string
180- worktreePath string157+ worktreePath string
181- rootSource string158+ rootSource string
182- configValue string159+ repoID string
183- setConfig bool160+ previewText string
184- bootstrapRequired bool161+ preview core.ToolResult
185- previewText string
186- preview core.ToolResult
187- ignore worktreesIgnoreUpdate
188 }162 }
189 163
190 func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {164 func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
@@ -251,67 +225,28 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
251 return createWorktreePlan{}, err225 return createWorktreePlan{}, err
252 }226 }
253 227
228+ repoID := worktreeRepoID(commonDir)
254 configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)229 configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
255 if err != nil {230 if err != nil {
256 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)231 return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
257 }232 }
258- bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
259-
260- rootSource := "existing .worktrees directory"
261- configValue := ""
262- setConfig := false
263- defaultRoot := false
264 var worktreeRoot string233 var worktreeRoot string
234+ rootSource := "global default root"
265 if configured {235 if configured {
266- worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)236+ worktreeRoot, err = resolveConfiguredWorktreeRoot(configuredRoot)
267 if err != nil {237 if err != nil {
268 return createWorktreePlan{}, err238 return createWorktreePlan{}, err
269 }239 }
270- if bootstrapRoot != "" {
271- requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
272- if err != nil {
273- return createWorktreePlan{}, err
274- }
275- configuredCanonical, err := canonicalOrParent(worktreeRoot)
276- if err != nil {
277- return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err)
278- }
279- requestedCanonical, err := canonicalOrParent(requestedRoot)
280- if err != nil {
281- return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
282- }
283- if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical {
284- return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
285- }
286- }
287 rootSource = "local Git config"240 rootSource = "local Git config"
288 } else {241 } else {
289- defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)242+ defaultRoot, err := t.defaultRoot()
290- exists, err := inspectWorktreeRoot(defaultPath)
291 if err != nil {243 if err != nil {
292 return createWorktreePlan{}, err244 return createWorktreePlan{}, err
293 }245 }
294- if !exists && bootstrapRoot == "" {246+ worktreeRoot = filepath.Join(defaultRoot, repoID)
295- return bootstrapRequiredPlan(branch, repoRoot), nil
296- }
297- if exists {
298- if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot {
299- return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
300- }
301- worktreeRoot = defaultPath
302- defaultRoot = true
303- } else {
304- worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
305- if err != nil {
306- return createWorktreePlan{}, err
307- }
308- setConfig = true
309- rootSource = "bootstrap selection"
310- }
311 }247 }
312 248
313- worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)249+ if err := validateWorktreeRoot(worktreeRoot); err != nil {
314- if err != nil {
315 return createWorktreePlan{}, err250 return createWorktreePlan{}, err
316 }251 }
317 worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))252 worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
@@ -319,24 +254,18 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
319 if err != nil {254 if err != nil {
320 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)255 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
321 }256 }
322- if !defaultRoot && isUnder(repoRoot, rootCanonical) {257+ if isUnder(repoRoot, rootCanonical) {
323- return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")258+ return createWorktreePlan{}, errors.New("create_worktree: worktree root must be outside the repository")
324 }259 }
325 pathCanonical, err := canonicalOrParent(worktreePath)260 pathCanonical, err := canonicalOrParent(worktreePath)
326 if err != nil {261 if err != nil {
327 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)262 return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
328 }263 }
329 if !isUnder(rootCanonical, pathCanonical) {264 if !isUnder(rootCanonical, pathCanonical) {
330- return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")265+ return createWorktreePlan{}, errors.New("create_worktree: branch path escapes the worktree root")
331 }266 }
332 267
333- ignorePath := ""268+ if err := t.checkReadWriteAccess(repoRoot, commonDir, worktreeRoot, worktreePath); err != nil {
334- accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
335- if defaultRoot {
336- ignorePath = filepath.Join(repoRoot, ".gitignore")
337- accessPaths = append(accessPaths, ignorePath)
338- }
339- if err := t.checkReadWriteAccess(accessPaths...); err != nil {
340 return createWorktreePlan{}, err269 return createWorktreePlan{}, err
341 }270 }
342 if _, err := os.Lstat(worktreePath); err == nil {271 if _, err := os.Lstat(worktreePath); err == nil {
@@ -352,29 +281,9 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
352 return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)281 return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
353 }282 }
354 283
355- ignore := worktreesIgnoreUpdate{}284+ displayRoot := filepath.ToSlash(worktreeRoot)
356- ignoreAction := "not modified (external worktree root)"285+ displayPath := filepath.ToSlash(worktreePath)
357- if defaultRoot {286+ previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath)
358- ignore, err = newWorktreesIgnoreUpdate(ignorePath)
359- if err != nil {
360- return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
361- }
362- ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
363- if ignore.changed {
364- ignoreAction = "add " + worktreesIgnoreEntry
365- }
366- }
367-
368- displayRoot := worktreeRoot
369- if defaultRoot {
370- displayRoot = defaultWorktreeRoot
371- }
372- displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
373- configAction := ""
374- if setConfig {
375- configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
376- }
377- previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction)
378 return createWorktreePlan{287 return createWorktreePlan{
379 repoRoot: repoRoot,288 repoRoot: repoRoot,
380 branch: branch,289 branch: branch,
@@ -382,40 +291,72 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
382 worktreeRoot: worktreeRoot,291 worktreeRoot: worktreeRoot,
383 worktreePath: worktreePath,292 worktreePath: worktreePath,
384 rootSource: rootSource,293 rootSource: rootSource,
385- configValue: configValue,294+ repoID: repoID,
386- setConfig: setConfig,
387 previewText: previewText,295 previewText: previewText,
388 preview: core.ToolResult{296 preview: core.ToolResult{
389 Content: []provider.Content{provider.TextBlock{Text: previewText}},297 Content: []provider.Content{provider.TextBlock{Text: previewText}},
390 Details: map[string]any{298 Details: map[string]any{
391- "state": "ready",299+ "state": "ready",
392- "branch": branch,300+ "branch": branch,
393- "base": base,301+ "base": base,
394- "worktree_root": displayRoot,302+ "worktree_root": displayRoot,
395- "root_source": rootSource,303+ "root_source": rootSource,
396- "path": displayPath,304+ "repo_id": repoID,
397- "bootstrap_saved": setConfig,305+ "path": displayPath,
398 },306 },
399 },307 },
400- ignore: ignore,
401 }, nil308 }, nil
402 }309 }
403 310
404-func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {311+func (t *CreateWorktreeTool) defaultRoot() (string, error) {
405- text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot))312+ root := strings.TrimSpace(t.DefaultRoot)
406- return createWorktreePlan{313+ if root == "" {
407- branch: branch,314+ return "", errors.New("create_worktree: default worktree root is not configured")
408- bootstrapRequired: true,315+ }
409- previewText: text,316+ return canonicalOrParent(root)
410- preview: core.ToolResult{317+}
411- Content: []provider.Content{provider.TextBlock{Text: text}},318+
412- Details: map[string]any{319+// worktreeRepoID derives a stable, collision-resistant directory name for the
413- "state": "bootstrap_required",320+// repository that owns commonDir. The name pairs a readable slug with a hash of
414- "branch": branch,321+// the canonical common directory so repositories sharing a base name still get
415- "default_bootstrap_root": defaultWorktreeRoot,322+// distinct roots while one repository keeps the same directory across sessions.
416- },323+func worktreeRepoID(commonDir string) string {
417- },324+ sum := sha256.Sum256([]byte(filepath.ToSlash(commonDir)))
325+ return worktreeRepoSlug(commonDir) + "-" + hex.EncodeToString(sum[:])[:worktreeRepoIDHashLength]
326+}
327+
328+func worktreeRepoSlug(commonDir string) string {
329+ base := filepath.Base(commonDir)
330+ if trimmed := strings.TrimSuffix(base, ".git"); trimmed != "" {
331+ base = trimmed
332+ } else {
333+ base = filepath.Base(filepath.Dir(commonDir))
334+ }
335+ var builder strings.Builder
336+ dashed := false
337+ for _, r := range base {
338+ switch {
339+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
340+ builder.WriteRune(r)
341+ dashed = false
342+ case r >= 'A' && r <= 'Z':
343+ builder.WriteRune(r + ('a' - 'A'))
344+ dashed = false
345+ default:
346+ if !dashed {
347+ builder.WriteByte('-')
348+ dashed = true
349+ }
350+ }
418 }351 }
352+ slug := strings.Trim(builder.String(), "-._")
353+ if len(slug) > worktreeRepoSlugMaxLen {
354+ slug = strings.Trim(slug[:worktreeRepoSlugMaxLen], "-._")
355+ }
356+ if slug == "" {
357+ slug = "repo"
358+ }
359+ return slug
419 }360 }
420 361
421 func (t *CreateWorktreeTool) accessibleCWD() (string, error) {362 func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
@@ -476,95 +417,42 @@ func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
476 return nil417 return nil
477 }418 }
478 419
479-func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {420+func resolveConfiguredWorktreeRoot(value string) (string, error) {
480 value = strings.TrimSpace(value)421 value = strings.TrimSpace(value)
481- if value == defaultWorktreeRoot {422+ if value == "" {
482- return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil423+ return "", errors.New("create_worktree: local worktree configuration is empty")
483 }424 }
484 if !filepath.IsAbs(value) {425 if !filepath.IsAbs(value) {
485- return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")426+ return "", errors.New("create_worktree: local worktree configuration must be an absolute path; remove a legacy relative value with `git config --local --unset zut.worktrees.path`")
486 }427 }
487- return value, false, nil428+ return filepath.Clean(value), nil
488 }429 }
489 430
490-func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {431+func validateWorktreeRoot(path string) error {
491- if value == defaultWorktreeRoot {
492- return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
493- }
494- if !filepath.IsAbs(value) {
495- return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
496- }
497- root, err := canonicalOrParent(value)
498- if err != nil {
499- return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
500- }
501- if isUnder(repoRoot, root) {
502- return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
503- }
504- return root, false, root, nil
505-}
506-
507-func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
508 info, err := os.Lstat(path)432 info, err := os.Lstat(path)
509 if os.IsNotExist(err) {433 if os.IsNotExist(err) {
510- return path, nil434+ return nil
511- }
512- if err != nil {
513- return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
514- }
515- if info.Mode()&os.ModeSymlink != 0 {
516- return "", errors.New("create_worktree: worktree root must not be a symbolic link")
517- }
518- if !info.IsDir() {
519- return "", errors.New("create_worktree: worktree root exists but is not a directory")
520- }
521- if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
522- return "", errors.New("create_worktree: invalid default worktree root")
523- }
524- return path, nil
525-}
526-
527-func inspectWorktreeRoot(path string) (bool, error) {
528- info, err := os.Lstat(path)
529- if os.IsNotExist(err) {
530- return false, nil
531 }435 }
532 if err != nil {436 if err != nil {
533- return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)437+ return fmt.Errorf("create_worktree: inspect worktree root: %w", err)
534 }438 }
535 if info.Mode()&os.ModeSymlink != 0 {439 if info.Mode()&os.ModeSymlink != 0 {
536- return false, errors.New("create_worktree: .worktrees must not be a symbolic link")440+ return errors.New("create_worktree: worktree root must not be a symbolic link")
537 }441 }
538 if !info.IsDir() {442 if !info.IsDir() {
539- return false, errors.New("create_worktree: .worktrees exists but is not a directory")443+ return errors.New("create_worktree: worktree root exists but is not a directory")
540 }444 }
541- return true, nil445+ return nil
542-}
543-
544-func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
545- if defaultRoot {
546- relative, err := filepath.Rel(repoRoot, path)
547- if err == nil {
548- return filepath.ToSlash(relative)
549- }
550- }
551- return filepath.ToSlash(path)
552 }446 }
553 447
554 func sameCreateWorktreePlan(a, b createWorktreePlan) bool {448 func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
555- return a.bootstrapRequired == b.bootstrapRequired &&449+ return a.repoRoot == b.repoRoot &&
556- a.repoRoot == b.repoRoot &&
557 a.branch == b.branch &&450 a.branch == b.branch &&
558 a.base == b.base &&451 a.base == b.base &&
559 a.worktreeRoot == b.worktreeRoot &&452 a.worktreeRoot == b.worktreeRoot &&
560 a.worktreePath == b.worktreePath &&453 a.worktreePath == b.worktreePath &&
561 a.rootSource == b.rootSource &&454 a.rootSource == b.rootSource &&
562- a.configValue == b.configValue &&455+ a.repoID == b.repoID
563- a.setConfig == b.setConfig &&
564- a.ignore.path == b.ignore.path &&
565- a.ignore.existed == b.ignore.existed &&
566- a.ignore.changed == b.ignore.changed &&
567- bytes.Equal(a.ignore.before, b.ignore.before)
568 }456 }
569 457
570 func missingWorktreeParents(worktreePath string) ([]string, error) {458 func missingWorktreeParents(worktreePath string) ([]string, error) {
@@ -601,21 +489,6 @@ func removeCreatedWorktreeParents(parents []string) error {
601 return nil489 return nil
602 }490 }
603 491
604-func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
605- value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
606- if err != nil {
607- return err
608- }
609- if !configured {
610- return nil
611- }
612- if value != p.configValue {
613- return errors.New("local worktree configuration changed during creation")
614- }
615- _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
616- return err
617-}
618-
619 func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {492 func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
620 registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)493 registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
621 if err != nil {494 if err != nil {
@@ -803,122 +676,3 @@ func createWorktreeGitEnv() []string {
803 "SSH_ASKPASS=",676 "SSH_ASKPASS=",
804 )677 )
805 }678 }
806-
807-type worktreesIgnoreUpdate struct {
808- path string
809- before []byte
810- after []byte
811- existed bool
812- mode os.FileMode
813- changed bool
814-}
815-
816-func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
817- contents, mode, existed, err := readWorktreesIgnore(path)
818- if err != nil {
819- return worktreesIgnoreUpdate{}, err
820- }
821- for _, line := range strings.Split(string(contents), "\n") {
822- switch strings.TrimSuffix(line, "\r") {
823- case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
824- return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
825- }
826- }
827- lineEnding := "\n"
828- if bytes.Contains(contents, []byte("\r\n")) {
829- lineEnding = "\r\n"
830- }
831- after := append([]byte(nil), contents...)
832- if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
833- after = append(after, lineEnding...)
834- }
835- after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
836- return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
837-}
838-
839-func (u worktreesIgnoreUpdate) apply() error {
840- if !u.changed {
841- return nil
842- }
843- current, _, existed, err := readWorktreesIgnore(u.path)
844- if err != nil {
845- return err
846- }
847- if existed != u.existed || !bytes.Equal(current, u.before) {
848- return errors.New(".gitignore changed since preview; inspect it and retry")
849- }
850- return writeWorktreesIgnore(u.path, u.after, u.mode)
851-}
852-
853-func (u worktreesIgnoreUpdate) restore() error {
854- if !u.changed {
855- return nil
856- }
857- current, _, existed, err := readWorktreesIgnore(u.path)
858- if err != nil {
859- return err
860- }
861- if !existed || !bytes.Equal(current, u.after) {
862- return nil
863- }
864- if !u.existed {
865- if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
866- return err
867- }
868- return nil
869- }
870- return writeWorktreesIgnore(u.path, u.before, u.mode)
871-}
872-
873-func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
874- info, err := os.Lstat(path)
875- if os.IsNotExist(err) {
876- return nil, 0o644, false, nil
877- }
878- if err != nil {
879- return nil, 0, false, err
880- }
881- if info.Mode()&os.ModeSymlink != 0 {
882- return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
883- }
884- if !info.Mode().IsRegular() {
885- return nil, 0, false, errors.New(".gitignore must be a regular file")
886- }
887- if info.Size() > maxCreateWorktreeIgnoreBytes {
888- return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
889- }
890- file, err := os.Open(path)
891- if err != nil {
892- return nil, 0, false, err
893- }
894- defer file.Close()
895- contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
896- if err != nil {
897- return nil, 0, false, err
898- }
899- if len(contents) > maxCreateWorktreeIgnoreBytes {
900- return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
901- }
902- return contents, info.Mode().Perm(), true, nil
903-}
904-
905-func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
906- file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
907- if err != nil {
908- return err
909- }
910- tempPath := file.Name()
911- defer os.Remove(tempPath)
912- if err := file.Chmod(mode); err != nil {
913- _ = file.Close()
914- return err
915- }
916- if _, err := file.Write(contents); err != nil {
917- _ = file.Close()
918- return err
919- }
920- if err := file.Close(); err != nil {
921- return err
922- }
923- return os.Rename(tempPath, path)
924-}
M packages/agent/tools/create_worktree_test.go +416 -526
@@ -15,179 +15,158 @@ import (
1515 "github.com/bnema/zut/packages/provider"
1616 )
1717
18-func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
18+func TestCreateWorktreeUsesGlobalDefaultRoot(t *testing.T) {
1919 if _, err := exec.LookPath("git"); err != nil {
2020 t.Skip("git is required")
2121 }
2222 repo := initWorktreeTestRepo(t)
23- nested := filepath.Join(repo, "nested", "dir")
24- if err := os.MkdirAll(nested, 0o755); err != nil {
25- t.Fatal(err)
26- }
27- if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil {
28- t.Fatal(err)
29- }
23+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
3024 branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31- sandbox := NewSandbox(repo)
32- sandbox.Lock()
25+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
3326
34- tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
35- args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
27+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/default-root"}`), nil)
3628 if err != nil {
3729 t.Fatal(err)
3830 }
39- if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40- t.Fatal(err)
41- }
4231
43- worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")
32+ repoID := testWorktreeRepoID(t, repo)
33+ worktree := filepath.Join(defaultRoot, repoID, "feature", "default-root")
4434 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
4535 t.Fatalf("worktree %q was not created: %v", worktree, err)
4636 }
47- if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" {
48- t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout")
37+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/default-root" {
38+ t.Fatalf("worktree branch = %q, want %q", got, "feature/default-root")
4939 }
5040 if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
5141 t.Fatalf("source branch changed from %q to %q", branchBefore, got)
5242 }
53- ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
54- if err != nil {
55- t.Fatal(err)
43+ details := result.Details.(map[string]any)
44+ if state, _ := details["state"].(string); state != "created" {
45+ t.Fatalf("state = %q, want created", state)
5646 }
57- if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {
58- t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)
47+ if source, _ := details["root_source"].(string); source != "global default root" {
48+ t.Fatalf("root source = %q", source)
5949 }
60- if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61- t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
50+ if id, _ := details["repo_id"].(string); id != repoID {
51+ t.Fatalf("repo_id = %q, want %q", id, repoID)
6252 }
63- followupArgs := mustJSON(t, map[string]string{
64- "branch": "feature/default-followup",
65- "bootstrap_root": defaultWorktreeRoot,
66- })
67- if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
53+ wantRoot, err := filepath.EvalSymlinks(filepath.Join(defaultRoot, repoID))
54+ if err != nil {
6855 t.Fatal(err)
6956 }
70- if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil {
71- t.Fatal(err)
57+ if got, _ := details["worktree_root"].(string); got != filepath.ToSlash(wantRoot) {
58+ t.Fatalf("worktree_root = %q, want %q", got, filepath.ToSlash(wantRoot))
59+ }
60+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
61+ t.Fatalf("default root saved local config: %#v", got)
7262 }
73- if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil {
74- t.Fatalf("configured default root was not reused: %v", err)
63+ for _, path := range []string{".worktrees", ".gitignore"} {
64+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
65+ t.Fatalf("default root created repository file %s: %v", path, statErr)
66+ }
7567 }
7668 }
7769
78-func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
70+func TestCreateWorktreeDefaultRootIsStableAcrossInvocations(t *testing.T) {
7971 if _, err := exec.LookPath("git"); err != nil {
8072 t.Skip("git is required")
8173 }
8274 repo := initWorktreeTestRepo(t)
83- tool := newCreateWorktreeTestTool(repo)
84-
85- result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)
86- if err != nil {
75+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
76+ nested := filepath.Join(repo, "nested", "dir")
77+ if err := os.MkdirAll(nested, 0o755); err != nil {
8778 t.Fatal(err)
8879 }
89- text := result.Content[0].(provider.TextBlock).Text
90- if !strings.Contains(text, "Worktree bootstrap is required") {
91- t.Fatalf("bootstrap result = %q", text)
80+ repoID := testWorktreeRepoID(t, repo)
81+
82+ nestedTool := newCreateWorktreeTestTool(t, nested, defaultRoot)
83+ if _, err := nestedTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-nested"}`), nil); err != nil {
84+ t.Fatal(err)
9285 }
93- if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {
94- t.Fatalf("bootstrap state = %q", state)
86+ rootTool := newCreateWorktreeTestTool(t, repo, defaultRoot)
87+ if _, err := rootTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-root"}`), nil); err != nil {
88+ t.Fatal(err)
9589 }
96- for _, path := range []string{".worktrees", ".gitignore"} {
97- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
98- t.Fatalf("bootstrap created %s: %v", path, statErr)
90+
91+ for _, branch := range []string{"feature/from-nested", "feature/from-root"} {
92+ worktree := filepath.Join(defaultRoot, repoID, filepath.FromSlash(branch))
93+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
94+ t.Fatalf("worktree %q was not created: %v", worktree, err)
9995 }
10096 }
101- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
102- t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
103- }
104- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
105- t.Fatalf("bootstrap created branch: %#v", got)
106- }
10797 }
10898
109-func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) {
99+func TestCreateWorktreeDefaultRootSlugIsReadable(t *testing.T) {
110100 if _, err := exec.LookPath("git"); err != nil {
111101 t.Skip("git is required")
112102 }
113- repo := initWorktreeTestRepo(t)
114- if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
103+ parent := t.TempDir()
104+ repo := filepath.Join(parent, "My Project!")
105+ if err := os.Mkdir(repo, 0o755); err != nil {
115106 t.Fatal(err)
116107 }
117- tool := newCreateWorktreeTestTool(repo)
118- args := mustJSON(t, map[string]string{
119- "branch": "feature/existing-default-root",
120- "bootstrap_root": defaultWorktreeRoot,
121- })
108+ initWorktreeTestRepoAt(t, repo)
109+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
110+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
122111
123- if _, err := tool.Preview(context.Background(), args); err != nil {
112+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/slug"}`), nil); err != nil {
124113 t.Fatal(err)
125114 }
126- result, err := tool.Execute(context.Background(), args, nil)
115+ entries, err := os.ReadDir(defaultRoot)
127116 if err != nil {
128117 t.Fatal(err)
129118 }
130- if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil {
131- t.Fatalf("worktree was not created: %v", err)
132- }
133- if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" {
134- t.Fatalf("root source = %q", source)
119+ if len(entries) != 1 {
120+ t.Fatalf("default root entries = %d, want 1", len(entries))
135121 }
136- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
137- t.Fatalf("existing default root saved config: %#v", got)
122+ if name := entries[0].Name(); !strings.HasPrefix(name, "my-project-") {
123+ t.Fatalf("repo directory %q does not carry a readable slug", name)
138124 }
139125 }
140126
141-func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) {
127+func TestCreateWorktreeSeparatesSameNamedRepositories(t *testing.T) {
142128 if _, err := exec.LookPath("git"); err != nil {
143129 t.Skip("git is required")
144130 }
145- repo := initWorktreeTestRepo(t)
146- if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
147- t.Fatal(err)
131+ parent := t.TempDir()
132+ repoA := filepath.Join(parent, "a", "shared")
133+ repoB := filepath.Join(parent, "b", "shared")
134+ for _, repo := range []string{repoA, repoB} {
135+ if err := os.MkdirAll(repo, 0o755); err != nil {
136+ t.Fatal(err)
137+ }
138+ initWorktreeTestRepoAt(t, repo)
148139 }
149- tool := newCreateWorktreeTestTool(repo)
140+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
150141
151- _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{
152- "branch": "feature/conflicting-bootstrap",
153- "bootstrap_root": t.TempDir(),
154- }), nil)
155- if err == nil || !strings.Contains(err.Error(), "already established") {
156- t.Fatalf("error = %v, want established-root error", err)
157- }
158- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
159- t.Fatalf("conflicting bootstrap saved config: %#v", got)
142+ toolA := newCreateWorktreeTestTool(t, repoA, defaultRoot)
143+ if _, err := toolA.Execute(context.Background(), json.RawMessage(`{"branch":"feature/a"}`), nil); err != nil {
144+ t.Fatal(err)
160145 }
161- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {
162- t.Fatalf("conflicting bootstrap created branch: %#v", got)
146+ toolB := newCreateWorktreeTestTool(t, repoB, defaultRoot)
147+ if _, err := toolB.Execute(context.Background(), json.RawMessage(`{"branch":"feature/b"}`), nil); err != nil {
148+ t.Fatal(err)
163149 }
164-}
165150
166-func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) {
167- if _, err := exec.LookPath("git"); err != nil {
168- t.Skip("git is required")
151+ if idA, idB := testWorktreeRepoID(t, repoA), testWorktreeRepoID(t, repoB); idA == idB {
152+ t.Fatalf("same-named repositories share repo ID %q", idA)
169153 }
170- repo := initWorktreeTestRepo(t)
171- gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot)
172- tool := newCreateWorktreeTestTool(repo)
173- args := mustJSON(t, map[string]string{
174- "branch": "feature/conflicting-configured-bootstrap",
175- "bootstrap_root": t.TempDir(),
176- })
177-
178- if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") {
179- t.Fatalf("preview error = %v, want configured-root error", err)
154+ entries, err := os.ReadDir(defaultRoot)
155+ if err != nil {
156+ t.Fatal(err)
180157 }
181- _, err := tool.Execute(context.Background(), args, nil)
182- if err == nil || !strings.Contains(err.Error(), "already configured") {
183- t.Fatalf("error = %v, want configured-root error", err)
158+ if len(entries) != 2 {
159+ t.Fatalf("default root entries = %d, want 2", len(entries))
184160 }
185- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 {
186- t.Fatalf("conflicting bootstrap created branch: %#v", got)
161+ for repo, branch := range map[string]string{repoA: "feature/a", repoB: "feature/b"} {
162+ worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), filepath.FromSlash(branch))
163+ if _, err := os.Stat(worktree); err != nil {
164+ t.Fatalf("worktree %q was not created: %v", worktree, err)
165+ }
187166 }
188167 }
189168
190-func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
169+func TestCreateWorktreeConfiguredRootOverridesDefaultRoot(t *testing.T) {
191170 if _, err := exec.LookPath("git"); err != nil {
192171 t.Skip("git is required")
193172 }
@@ -196,10 +175,11 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing
196175 if err != nil {
197176 t.Fatal(err)
198177 }
199- tool := newCreateWorktreeTestTool(repo)
200- args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})
178+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
179+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, externalRoot)
180+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
201181
202- result, err := tool.Execute(context.Background(), args, nil)
182+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external"}`), nil)
203183 if err != nil {
204184 t.Fatal(err)
205185 }
@@ -210,51 +190,119 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing
210190 if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
211191 t.Fatalf("external branch = %q", got)
212192 }
213- if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {
214- t.Fatalf("saved external root = %q, want %q", got, externalRoot)
193+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
194+ t.Fatalf("root source = %q", source)
215195 }
216- if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
217- t.Fatalf("external bootstrap changed .gitignore: %v", err)
196+ if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) {
197+ t.Fatalf("configured root still touched the default root: %v", err)
218198 }
219- if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {
220- t.Fatalf("root source = %q", source)
199+}
200+
201+func TestCreateWorktreeRejectsRelativeConfiguredRoot(t *testing.T) {
202+ if _, err := exec.LookPath("git"); err != nil {
203+ t.Skip("git is required")
221204 }
222- if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
223- t.Fatalf("state = %q, want created", state)
205+ repo := initWorktreeTestRepo(t)
206+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, ".worktrees")
207+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
208+
209+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/relative"}`), nil)
210+ if err == nil || !strings.Contains(err.Error(), "absolute path") {
211+ t.Fatalf("error = %v, want absolute-path error", err)
224212 }
225- followupArgs := mustJSON(t, map[string]string{
226- "branch": "feature/external-followup",
227- "bootstrap_root": externalRoot,
228- })
229- if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
230- t.Fatal(err)
213+ if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
214+ t.Fatalf("relative configured root created directory: %v", statErr)
231215 }
232- followup, err := tool.Execute(context.Background(), followupArgs, nil)
233- if err != nil {
234- t.Fatal(err)
216+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/relative"); got.exitCode != 1 {
217+ t.Fatalf("relative configured root created branch: %#v", got)
218+ }
219+}
220+
221+func TestCreateWorktreeRejectsConfiguredRootResolvingInsideRepository(t *testing.T) {
222+ if _, err := exec.LookPath("git"); err != nil {
223+ t.Skip("git is required")
235224 }
236- if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {
237- t.Fatalf("configured external root was not reused: %v", err)
225+ repo := initWorktreeTestRepo(t)
226+ link := filepath.Join(t.TempDir(), "repository-link")
227+ if err := os.Symlink(repo, link); err != nil {
228+ t.Skipf("symbolic links are unavailable: %v", err)
238229 }
239- if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
240- t.Fatalf("follow-up root source = %q", source)
230+ root := filepath.Join(link, "external-worktrees")
231+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
232+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
233+
234+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
235+ if err == nil || !strings.Contains(err.Error(), "must be outside the repository") {
236+ t.Fatalf("error = %v, want outside-repository error", err)
241237 }
242238 }
243239
244-func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {
240+func TestCreateWorktreeRequiresDefaultRoot(t *testing.T) {
245241 if _, err := exec.LookPath("git"); err != nil {
246242 t.Skip("git is required")
247243 }
248244 repo := initWorktreeTestRepo(t)
249- tool := newCreateWorktreeTestTool(repo)
250- for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {
251- _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)
252- if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {
253- t.Fatalf("bootstrap_root %q error = %v", root, err)
254- }
245+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo)}
246+
247+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-default-root"}`), nil)
248+ if err == nil || !strings.Contains(err.Error(), "default worktree root is not configured") {
249+ t.Fatalf("error = %v, want default-root error", err)
255250 }
256- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
257- t.Fatalf("invalid bootstrap saved config: %#v", got)
251+}
252+
253+func TestCreateWorktreeSchemaDropsBootstrapRoot(t *testing.T) {
254+ schema := string((&CreateWorktreeTool{}).Schema())
255+ if strings.Contains(schema, "bootstrap_root") {
256+ t.Fatalf("schema still exposes bootstrap_root:\n%s", schema)
257+ }
258+ var parsed struct {
259+ Properties map[string]json.RawMessage `json:"properties"`
260+ Required []string `json:"required"`
261+ }
262+ if err := json.Unmarshal([]byte(schema), &parsed); err != nil {
263+ t.Fatal(err)
264+ }
265+ if _, ok := parsed.Properties["branch"]; !ok {
266+ t.Fatalf("schema lacks branch: %s", schema)
267+ }
268+ if len(parsed.Properties) != 1 {
269+ t.Fatalf("schema properties = %v, want only branch", parsed.Properties)
270+ }
271+ if len(parsed.Required) != 1 || parsed.Required[0] != "branch" {
272+ t.Fatalf("schema required = %v", parsed.Required)
273+ }
274+}
275+
276+func TestWorktreeRepoIDIsReadableStableAndDistinct(t *testing.T) {
277+ tests := []struct {
278+ name string
279+ commonDir string
280+ slug string
281+ }{
282+ {name: "worktree metadata", commonDir: filepath.Join("home", "u", "src", "my-repo", ".git"), slug: "my-repo"},
283+ {name: "bare repository", commonDir: filepath.Join("srv", "git", "tools.git"), slug: "tools"},
284+ {name: "unusual characters", commonDir: filepath.Join("home", "u", "My Project!", ".git"), slug: "my-project"},
285+ {name: "empty slug", commonDir: filepath.Join("home", "@@@", ".git"), slug: "repo"},
286+ }
287+ for _, test := range tests {
288+ t.Run(test.name, func(t *testing.T) {
289+ id := worktreeRepoID(test.commonDir)
290+ if want := test.slug + "-"; !strings.HasPrefix(id, want) {
291+ t.Fatalf("repo ID = %q, want prefix %q", id, want)
292+ }
293+ if got := len(id) - len(test.slug) - 1; got != worktreeRepoIDHashLength {
294+ t.Fatalf("repo ID hash length = %d, want %d", got, worktreeRepoIDHashLength)
295+ }
296+ if again := worktreeRepoID(test.commonDir); again != id {
297+ t.Fatalf("repo ID is not stable: %q then %q", id, again)
298+ }
299+ })
300+ }
301+ if a, b := worktreeRepoID(filepath.Join("a", "shared", ".git")), worktreeRepoID(filepath.Join("b", "shared", ".git")); a == b {
302+ t.Fatalf("distinct repositories share repo ID %q", a)
303+ }
304+ if a, b := worktreeRepoID(filepath.Join("a", "Shared", ".git")), worktreeRepoID(filepath.Join("a", "shared", ".git")); a == b {
305+ t.Fatalf("case-different repositories share repo ID %q", a)
258306 }
259307 }
260308
@@ -263,7 +311,7 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
263311 t.Skip("git is required")
264312 }
265313 repo := initWorktreeTestRepo(t)
266- tool := newCreateWorktreeTestTool(repo)
314+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
267315 for _, branch := range []string{"-option", "topic@{1}"} {
268316 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
269317 if err == nil || !strings.Contains(err.Error(), "invalid branch") {
@@ -272,22 +320,43 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
272320 }
273321 }
274322
275-func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {
323+func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
324+ dir := t.TempDir()
325+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
326+ tool := newCreateWorktreeTestTool(t, dir, defaultRoot)
327+
328+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
329+ if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
330+ t.Fatalf("error = %v, want repository error", err)
331+ }
332+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
333+ t.Fatalf("non-repository call created the default root: %v", statErr)
334+ }
335+}
336+
337+func TestCreateWorktreeRequiresSandbox(t *testing.T) {
338+ tool := &CreateWorktreeTool{CWD: t.TempDir(), DefaultRoot: filepath.Join(t.TempDir(), "worktrees")}
339+
340+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
341+ if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
342+ t.Fatalf("error = %v, want sandbox error", err)
343+ }
344+}
345+
346+func TestCreateWorktreeRejectsExistingBranch(t *testing.T) {
276347 if _, err := exec.LookPath("git"); err != nil {
277348 t.Skip("git is required")
278349 }
279350 repo := initWorktreeTestRepo(t)
280- link := filepath.Join(t.TempDir(), "repository-link")
281- if err := os.Symlink(repo, link); err != nil {
282- t.Skipf("symbolic links are unavailable: %v", err)
351+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
352+ args := json.RawMessage(`{"branch":"feature/existing"}`)
353+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
354+ t.Fatal(err)
283355 }
284- root := filepath.Join(link, "external-worktrees")
285- gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
286- tool := newCreateWorktreeTestTool(repo)
287356
288- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
289- if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {
290- t.Fatalf("error = %v, want external-root error", err)
357+ _, err := tool.Execute(context.Background(), args, nil)
358+ if err == nil || !strings.Contains(err.Error(), "already exists") {
359+ t.Fatalf("error = %v, want collision error", err)
291360 }
292361 }
293362
@@ -299,21 +368,19 @@ func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
299368 t.Skip("git is required")
300369 }
301370 repo := initWorktreeTestRepo(t)
302- if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
303- t.Fatal(err)
304- }
305371 sentinel := filepath.Join(repo, "hook-ran")
306372 hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
307373 script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
308374 if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
309375 t.Fatal(err)
310376 }
311- tool := newCreateWorktreeTestTool(repo)
377+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
378+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
312379
313380 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
314381 t.Fatal(err)
315382 }
316- worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")
383+ worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "no-hook")
317384 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
318385 t.Fatalf("worktree %q was not created: %v", worktree, err)
319386 }
@@ -327,7 +394,7 @@ func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
327394 t.Skip("git is required")
328395 }
329396 repo := initWorktreeTestRepo(t)
330- worktreePath := filepath.Join(repo, ".worktrees", "partial")
397+ worktreePath := filepath.Join(t.TempDir(), "worktrees", "partial")
331398 if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
332399 t.Fatal(err)
333400 }
@@ -357,220 +424,264 @@ func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *test
357424 }
358425 repo := initWorktreeTestRepo(t)
359426 branch := "feature/checkout-failure"
427+ root := filepath.Join(t.TempDir(), "worktrees", "repo-id")
360428 plan := createWorktreePlan{
361429 repoRoot: repo,
362430 branch: branch,
363431 base: "not-a-commit",
364- worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),
365- configValue: defaultWorktreeRoot,
366- setConfig: true,
432+ worktreeRoot: root,
433+ worktreePath: filepath.Join(root, "feature", "checkout-failure"),
434+ rootSource: "global default root",
367435 }
368436
369437 _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
370438 if err == nil || !strings.Contains(err.Error(), "create worktree") {
371439 t.Fatalf("error = %v, want worktree creation error", err)
372440 }
373- if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {
441+ if _, err := os.Stat(root); !os.IsNotExist(err) {
374442 t.Fatalf("failed checkout left worktree directories: %v", err)
375443 }
376- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
377- t.Fatalf("failed checkout kept local config: %#v", got)
378- }
379444 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
380445 t.Fatalf("failed checkout created branch: %#v", got)
381446 }
382447 }
383448
384-func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {
449+func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
385450 if _, err := exec.LookPath("git"); err != nil {
386451 t.Skip("git is required")
387452 }
388453 repo := initWorktreeTestRepo(t)
389- ignorePath := filepath.Join(repo, ".gitignore")
390- before := []byte("generated/\n")
391- if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
454+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
455+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
456+ args := json.RawMessage(`{"branch":"feature/preview"}`)
457+
458+ preview, err := tool.Preview(context.Background(), args)
459+ if err != nil {
392460 t.Fatal(err)
393461 }
394- if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {
395- t.Fatal(err)
462+ text := preview.Content[0].(provider.TextBlock).Text
463+ for _, want := range []string{"create worktree", "feature/preview", "global default root", testWorktreeRepoID(t, repo)} {
464+ if !strings.Contains(text, want) {
465+ t.Fatalf("preview missing %q:\n%s", want, text)
466+ }
396467 }
397- base := gitTestOutput(t, repo, "rev-parse", "HEAD")
398- plan := createWorktreePlan{
399- repoRoot: repo,
400- branch: "feature/ignore-failure",
401- base: base,
402- worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
403- configValue: defaultWorktreeRoot,
404- setConfig: true,
405- ignore: worktreesIgnoreUpdate{
406- path: ignorePath,
407- before: before,
408- after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
409- existed: true,
410- mode: 0o644,
411- changed: true,
412- },
468+ if state, _ := preview.Details.(map[string]any)["state"].(string); state != "ready" {
469+ t.Fatalf("preview state = %q, want ready", state)
413470 }
414-
415- _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
416- if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
417- t.Fatalf("error = %v, want stale .gitignore error", err)
471+ if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) {
472+ t.Fatalf("preview created the default root: %v", err)
418473 }
419- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
420- t.Fatalf("failed bootstrap kept local config: %#v", got)
474+ for _, path := range []string{".worktrees", ".gitignore"} {
475+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
476+ t.Fatalf("preview created repository file %s: %v", path, statErr)
477+ }
478+ }
479+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
480+ if err := cmd.Run(); err == nil {
481+ t.Fatal("preview created the branch")
482+ }
483+}
484+
485+func TestCreateWorktreePreviewThenExecuteCreatesWorktree(t *testing.T) {
486+ if _, err := exec.LookPath("git"); err != nil {
487+ t.Skip("git is required")
421488 }
422- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {
423- t.Fatalf("failed bootstrap created branch: %#v", got)
489+ repo := initWorktreeTestRepo(t)
490+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
491+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
492+ args := json.RawMessage(`{"branch":"feature/previewed"}`)
493+
494+ if _, err := tool.Preview(context.Background(), args); err != nil {
495+ t.Fatal(err)
424496 }
425- contents, err := os.ReadFile(ignorePath)
497+ result, err := tool.Execute(context.Background(), args, nil)
426498 if err != nil {
427499 t.Fatal(err)
428500 }
429- if got, want := string(contents), "changed by another process\n"; got != want {
430- t.Fatalf(".gitignore = %q, want %q", got, want)
501+ worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "previewed")
502+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
503+ t.Fatalf("worktree %q was not created: %v", worktree, err)
504+ }
505+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
506+ t.Fatalf("state = %q, want created", state)
431507 }
432508 }
433509
434-func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {
510+func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
435511 if _, err := exec.LookPath("git"); err != nil {
436512 t.Skip("git is required")
437513 }
438- for _, test := range []struct {
439- name string
440- entry string
441- }{
442- {name: "root-anchored", entry: worktreesIgnoreEntry},
443- {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444- } {
445- t.Run(test.name, func(t *testing.T) {
446- repo := initWorktreeTestRepo(t)
447- ignorePath := filepath.Join(repo, ".gitignore")
448- before := []byte(test.entry + "\n")
449- if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450- t.Fatal(err)
451- }
452- tool := newCreateWorktreeTestTool(repo)
453- args := mustJSON(t, map[string]string{
454- "branch": "feature/" + test.name + "-ignore",
455- "bootstrap_root": defaultWorktreeRoot,
456- })
457-
458- result, err := tool.Execute(context.Background(), args, nil)
459- if err != nil {
460- t.Fatal(err)
461- }
462- text := result.Content[0].(provider.TextBlock).Text
463- if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464- t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465- }
466- ignore, err := os.ReadFile(ignorePath)
467- if err != nil {
468- t.Fatal(err)
469- }
470- if string(ignore) != string(before) {
471- t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472- }
473- })
514+ repo := initWorktreeTestRepo(t)
515+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
516+ args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
517+ if _, err := tool.Preview(context.Background(), args); err != nil {
518+ t.Fatal(err)
474519 }
475-}
476-
477-func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {
478- for _, entry := range []string{
479- "!.worktrees/\n",
480- "nested/.worktrees/\n",
481- "#.worktrees/\n",
482- ".worktrees-other/\n",
483- } {
484- t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485- ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486- if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487- t.Fatal(err)
488- }
520+ if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
521+ t.Fatal(err)
522+ }
523+ gitTestOutput(t, repo, "add", "after-preview.txt")
524+ gitTestOutput(t, repo, "commit", "-qm", "change after preview")
489525
490- update, err := newWorktreesIgnoreUpdate(ignorePath)
491- if err != nil {
492- t.Fatal(err)
493- }
494- if !update.changed {
495- t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))
496- }
497- if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498- t.Fatalf("updated .gitignore = %q, want %q", got, want)
499- }
500- })
526+ _, err := tool.Execute(context.Background(), args, nil)
527+ if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
528+ t.Fatalf("error = %v, want stale-preview error", err)
529+ }
530+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
531+ t.Fatalf("stale preview created branch: %#v", got)
501532 }
502533 }
503534
504-func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
535+func TestCreateWorktreeRespectsJail(t *testing.T) {
505536 if _, err := exec.LookPath("git"); err != nil {
506537 t.Skip("git is required")
507538 }
508539 repo := initWorktreeTestRepo(t)
509- ignorePath := filepath.Join(repo, ".gitignore")
510- if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
540+ nested := filepath.Join(repo, "nested")
541+ if err := os.Mkdir(nested, 0o755); err != nil {
511542 t.Fatal(err)
512543 }
513- tool := newCreateWorktreeTestTool(repo)
544+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
545+ sandbox := NewSandbox(nested)
546+ sandbox.Lock()
547+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: defaultRoot}
514548
515- if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
549+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
550+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
551+ t.Fatalf("error = %v, want jailed error", err)
552+ }
553+ for _, path := range []string{filepath.Join(repo, ".worktrees"), filepath.Join(repo, ".gitignore")} {
554+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
555+ t.Fatalf("jailed call created %s: %v", path, statErr)
556+ }
557+ }
558+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
559+ t.Fatalf("jailed call created the default root: %v", statErr)
560+ }
561+}
562+
563+func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
564+ if runtime.GOOS == "windows" {
565+ t.Skip("Git invocation fixture uses a POSIX shell")
566+ }
567+ if _, err := exec.LookPath("git"); err != nil {
568+ t.Skip("git is required")
569+ }
570+ repo := initWorktreeTestRepo(t)
571+ nested := filepath.Join(repo, "nested")
572+ if err := os.Mkdir(nested, 0o755); err != nil {
516573 t.Fatal(err)
517574 }
518- ignore, err := os.ReadFile(ignorePath)
519- if err != nil {
575+ sentinel := filepath.Join(repo, "git-was-invoked")
576+ binDir := t.TempDir()
577+ fakeGit := filepath.Join(binDir, "git")
578+ if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
520579 t.Fatal(err)
521580 }
522- if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
523- t.Fatalf(".gitignore = %q, want %q", got, want)
581+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
582+ sandbox := NewSandbox(nested)
583+ sandbox.Lock()
584+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: filepath.Join(t.TempDir(), "worktrees")}
585+
586+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
587+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
588+ t.Fatalf("error = %v, want jailed error", err)
589+ }
590+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
591+ t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
524592 }
525593 }
526594
527-func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
595+func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
528596 if _, err := exec.LookPath("git"); err != nil {
529597 t.Skip("git is required")
530598 }
531599 repo := initWorktreeTestRepo(t)
532- target := filepath.Join(repo, "ignore-target")
533- if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
534- t.Fatal(err)
600+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
601+ sandbox := NewSandbox(repo)
602+ permissions := &PermissionSet{}
603+ permissions.Bash.Mode = "allowlist"
604+ permissions.Bash.Allow = []string{"git"}
605+ sandbox.SetPermissions(permissions)
606+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
607+
608+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
609+ if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
610+ t.Fatalf("error = %v, want filesystem permission error", err)
535611 }
536- ignorePath := filepath.Join(repo, ".gitignore")
537- if err := os.Symlink(target, ignorePath); err != nil {
538- t.Skipf("symbolic links are unavailable: %v", err)
612+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
613+ t.Fatalf("restricted call created the default root: %v", statErr)
539614 }
540- tool := newCreateWorktreeTestTool(repo)
615+}
541616
542- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
543- if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
544- t.Fatalf("error = %v, want symbolic-link error", err)
617+func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
618+ if _, err := exec.LookPath("git"); err != nil {
619+ t.Skip("git is required")
545620 }
546- info, err := os.Lstat(ignorePath)
547- if err != nil {
548- t.Fatal(err)
621+ repo := initWorktreeTestRepo(t)
622+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
623+ sandbox := NewSandbox(repo)
624+ permissions := &PermissionSet{}
625+ permissions.FS.Read = []string{repo}
626+ permissions.FS.Write = []string{repo}
627+ sandbox.SetPermissions(permissions)
628+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
629+
630+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
631+ if err == nil || !strings.Contains(err.Error(), "no bash permission") {
632+ t.Fatalf("error = %v, want git permission error", err)
549633 }
550- if info.Mode()&os.ModeSymlink == 0 {
551- t.Fatal("tool replaced .gitignore symbolic link")
634+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
635+ t.Fatalf("restricted call created the default root: %v", statErr)
552636 }
553- if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
554- t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
637+}
638+
639+func TestCreateWorktreeWritesOutsideDeclaredScopesAreDenied(t *testing.T) {
640+ if _, err := exec.LookPath("git"); err != nil {
641+ t.Skip("git is required")
642+ }
643+ repo := initWorktreeTestRepo(t)
644+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
645+ sandbox := NewSandbox(repo)
646+ permissions := &PermissionSet{}
647+ permissions.FS.Read = []string{repo}
648+ permissions.FS.Write = []string{repo}
649+ permissions.Bash.Mode = "allowlist"
650+ permissions.Bash.Allow = []string{"git"}
651+ sandbox.SetPermissions(permissions)
652+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
653+
654+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/out-of-scope"}`), nil)
655+ if err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
656+ t.Fatalf("error = %v, want scope-denied error", err)
657+ }
658+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
659+ t.Fatalf("denied call created the default root: %v", statErr)
555660 }
556661 }
557662
558-func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
663+func TestCreateWorktreeRejectsInvalidWorktreePathBeforeCheckout(t *testing.T) {
559664 if _, err := exec.LookPath("git"); err != nil {
560665 t.Skip("git is required")
561666 }
562667 repo := initWorktreeTestRepo(t)
563- if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
668+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
669+ repoRoot := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo))
670+ if err := os.MkdirAll(repoRoot, 0o755); err != nil {
671+ t.Fatal(err)
672+ }
673+ if err := os.WriteFile(filepath.Join(repoRoot, "blocked"), []byte("not a directory"), 0o644); err != nil {
564674 t.Fatal(err)
565675 }
566- tool := newCreateWorktreeTestTool(repo)
676+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
567677
568- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
569- if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
570- t.Fatalf("error = %v, want regular-file error", err)
678+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
679+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
680+ t.Fatalf("error = %v, want non-directory path error", err)
571681 }
572- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
573- t.Fatalf("non-regular ignore saved config: %#v", got)
682+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
683+ if err := cmd.Run(); err == nil {
684+ t.Fatal("invalid path created the branch")
574685 }
575686 }
576687
@@ -664,255 +775,34 @@ func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
664775 }
665776 }
666777
667-func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
668- if _, err := exec.LookPath("git"); err != nil {
669- t.Skip("git is required")
670- }
671- repo := initWorktreeTestRepo(t)
672- tool := newCreateWorktreeTestTool(repo)
673- args := json.RawMessage(`{"branch":"feature/preview"}`)
674-
675- preview, err := tool.Preview(context.Background(), args)
676- if err != nil {
677- t.Fatal(err)
678- }
679- text := preview.Content[0].(provider.TextBlock).Text
680- for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
681- if !strings.Contains(text, want) {
682- t.Fatalf("preview missing %q:\n%s", want, text)
683- }
684- }
685- if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
686- t.Fatalf("preview created .worktrees: %v", err)
687- }
688- if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
689- t.Fatalf("preview created .gitignore: %v", err)
690- }
691- cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
692- if err := cmd.Run(); err == nil {
693- t.Fatal("preview created the branch")
694- }
695-}
696-
697-func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
698- if _, err := exec.LookPath("git"); err != nil {
699- t.Skip("git is required")
700- }
701- repo := initWorktreeTestRepo(t)
702- if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
703- t.Fatal(err)
704- }
705- tool := newCreateWorktreeTestTool(repo)
706- args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
707- if _, err := tool.Preview(context.Background(), args); err != nil {
708- t.Fatal(err)
709- }
710- if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
711- t.Fatal(err)
712- }
713- gitTestOutput(t, repo, "add", "after-preview.txt")
714- gitTestOutput(t, repo, "commit", "-qm", "change after preview")
715-
716- _, err := tool.Execute(context.Background(), args, nil)
717- if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
718- t.Fatalf("error = %v, want stale-preview error", err)
719- }
720- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
721- t.Fatalf("stale preview created branch: %#v", got)
722- }
723-}
724-
725-func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
726- dir := t.TempDir()
727- tool := newCreateWorktreeTestTool(dir)
728-
729- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
730- if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
731- t.Fatalf("error = %v, want repository error", err)
732- }
733- for _, path := range []string{".worktrees", ".gitignore"} {
734- if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
735- t.Fatalf("non-repository call created %s: %v", path, statErr)
736- }
737- }
738-}
739-
740-func TestCreateWorktreeRequiresSandbox(t *testing.T) {
741- tool := &CreateWorktreeTool{CWD: t.TempDir()}
742-
743- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
744- if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
745- t.Fatalf("error = %v, want sandbox error", err)
746- }
778+func newCreateWorktreeTestTool(t *testing.T, cwd, defaultRoot string) *CreateWorktreeTool {
779+ t.Helper()
780+ return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd), DefaultRoot: defaultRoot}
747781 }
748782
749-func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
750- if _, err := exec.LookPath("git"); err != nil {
751- t.Skip("git is required")
752- }
753- repo := initWorktreeTestRepo(t)
754- tool := newCreateWorktreeTestTool(repo)
755- bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
756- if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
757- t.Fatal(err)
758- }
759- ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
783+func testWorktreeRepoID(t *testing.T, repo string) string {
784+ t.Helper()
785+ real, err := canonical(repo)
760786 if err != nil {
761787 t.Fatal(err)
762788 }
763-
764- _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
765- if err == nil || !strings.Contains(err.Error(), "already exists") {
766- t.Fatalf("error = %v, want collision error", err)
767- }
768- ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
789+ commonDir, err := canonical(filepath.Join(real, ".git"))
769790 if err != nil {
770791 t.Fatal(err)
771792 }
772- if string(ignoreAfter) != string(ignoreBefore) {
773- t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
774- }
775-}
776-
777-func TestCreateWorktreeRespectsJail(t *testing.T) {
778- if _, err := exec.LookPath("git"); err != nil {
779- t.Skip("git is required")
780- }
781- repo := initWorktreeTestRepo(t)
782- nested := filepath.Join(repo, "nested")
783- if err := os.Mkdir(nested, 0o755); err != nil {
784- t.Fatal(err)
785- }
786- sandbox := NewSandbox(nested)
787- sandbox.Lock()
788- tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
789-
790- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
791- if err == nil || !strings.Contains(err.Error(), "jailed:") {
792- t.Fatalf("error = %v, want jailed error", err)
793- }
794- for _, path := range []string{".worktrees", ".gitignore"} {
795- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
796- t.Fatalf("jailed call created %s: %v", path, statErr)
797- }
798- }
799-}
800-
801-func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
802- if runtime.GOOS == "windows" {
803- t.Skip("Git invocation fixture uses a POSIX shell")
804- }
805- if _, err := exec.LookPath("git"); err != nil {
806- t.Skip("git is required")
807- }
808- repo := initWorktreeTestRepo(t)
809- nested := filepath.Join(repo, "nested")
810- if err := os.Mkdir(nested, 0o755); err != nil {
811- t.Fatal(err)
812- }
813- sentinel := filepath.Join(repo, "git-was-invoked")
814- binDir := t.TempDir()
815- fakeGit := filepath.Join(binDir, "git")
816- if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
817- t.Fatal(err)
818- }
819- t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
820- sandbox := NewSandbox(nested)
821- sandbox.Lock()
822- tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
823-
824- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
825- if err == nil || !strings.Contains(err.Error(), "jailed:") {
826- t.Fatalf("error = %v, want jailed error", err)
827- }
828- if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
829- t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
830- }
831-}
832-
833-func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
834- if _, err := exec.LookPath("git"); err != nil {
835- t.Skip("git is required")
836- }
837- repo := initWorktreeTestRepo(t)
838- sandbox := NewSandbox(repo)
839- permissions := &PermissionSet{}
840- permissions.Bash.Mode = "allowlist"
841- permissions.Bash.Allow = []string{"git"}
842- sandbox.SetPermissions(permissions)
843- tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
844-
845- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
846- if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
847- t.Fatalf("error = %v, want filesystem permission error", err)
848- }
849- for _, path := range []string{".worktrees", ".gitignore"} {
850- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
851- t.Fatalf("restricted call created %s: %v", path, statErr)
852- }
853- }
854-}
855-
856-func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
857- if _, err := exec.LookPath("git"); err != nil {
858- t.Skip("git is required")
859- }
860- repo := initWorktreeTestRepo(t)
861- sandbox := NewSandbox(repo)
862- permissions := &PermissionSet{}
863- permissions.FS.Read = []string{repo}
864- permissions.FS.Write = []string{repo}
865- sandbox.SetPermissions(permissions)
866- tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
867-
868- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
869- if err == nil || !strings.Contains(err.Error(), "no bash permission") {
870- t.Fatalf("error = %v, want git permission error", err)
871- }
872- for _, path := range []string{".worktrees", ".gitignore"} {
873- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
874- t.Fatalf("restricted call created %s: %v", path, statErr)
875- }
876- }
877-}
878-
879-func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
880- if _, err := exec.LookPath("git"); err != nil {
881- t.Skip("git is required")
882- }
883- repo := initWorktreeTestRepo(t)
884- worktrees := filepath.Join(repo, ".worktrees")
885- if err := os.Mkdir(worktrees, 0o755); err != nil {
886- t.Fatal(err)
887- }
888- if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
889- t.Fatal(err)
890- }
891- tool := newCreateWorktreeTestTool(repo)
892-
893- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
894- if err == nil || !strings.Contains(err.Error(), "not a directory") {
895- t.Fatalf("error = %v, want non-directory path error", err)
896- }
897- if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
898- t.Fatalf("invalid path left .gitignore behind: %v", statErr)
899- }
900- cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
901- if err := cmd.Run(); err == nil {
902- t.Fatal("invalid path created the branch")
903- }
793+ return worktreeRepoID(commonDir)
904794 }
905795
906-func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
907- return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
796+func initWorktreeTestRepo(t *testing.T) string {
797+ t.Helper()
798+ return initWorktreeTestRepoAt(t, t.TempDir())
908799 }
909800
910-func initWorktreeTestRepo(t *testing.T) string {
801+func initWorktreeTestRepoAt(t *testing.T, repo string) string {
911802 t.Helper()
912803 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
913804 t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
914805 t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
915- repo := t.TempDir()
916806 gitTestOutput(t, repo, "init", "-q")
917807 gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
918808 gitTestOutput(t, repo, "config", "user.name", "Test User")
@@ -15,179 +15,158 @@ import (
15 "github.com/bnema/zut/packages/provider"15 "github.com/bnema/zut/packages/provider"
16 )16 )
17 17
18-func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {18+func TestCreateWorktreeUsesGlobalDefaultRoot(t *testing.T) {
19 if _, err := exec.LookPath("git"); err != nil {19 if _, err := exec.LookPath("git"); err != nil {
20 t.Skip("git is required")20 t.Skip("git is required")
21 }21 }
22 repo := initWorktreeTestRepo(t)22 repo := initWorktreeTestRepo(t)
23- nested := filepath.Join(repo, "nested", "dir")23+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
24- if err := os.MkdirAll(nested, 0o755); err != nil {
25- t.Fatal(err)
26- }
27- if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil {
28- t.Fatal(err)
29- }
30 branchBefore := gitTestOutput(t, repo, "branch", "--show-current")24 branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31- sandbox := NewSandbox(repo)25+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
32- sandbox.Lock()
33 26
34- tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}27+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/default-root"}`), nil)
35- args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
36 if err != nil {28 if err != nil {
37 t.Fatal(err)29 t.Fatal(err)
38 }30 }
39- if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40- t.Fatal(err)
41- }
42 31
43- worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")32+ repoID := testWorktreeRepoID(t, repo)
33+ worktree := filepath.Join(defaultRoot, repoID, "feature", "default-root")
44 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {34 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
45 t.Fatalf("worktree %q was not created: %v", worktree, err)35 t.Fatalf("worktree %q was not created: %v", worktree, err)
46 }36 }
47- if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" {37+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/default-root" {
48- t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout")38+ t.Fatalf("worktree branch = %q, want %q", got, "feature/default-root")
49 }39 }
50 if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {40 if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
51 t.Fatalf("source branch changed from %q to %q", branchBefore, got)41 t.Fatalf("source branch changed from %q to %q", branchBefore, got)
52 }42 }
53- ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))43+ details := result.Details.(map[string]any)
54- if err != nil {44+ if state, _ := details["state"].(string); state != "created" {
55- t.Fatal(err)45+ t.Fatalf("state = %q, want created", state)
56 }46 }
57- if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {47+ if source, _ := details["root_source"].(string); source != "global default root" {
58- t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)48+ t.Fatalf("root source = %q", source)
59 }49 }
60- if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {50+ if id, _ := details["repo_id"].(string); id != repoID {
61- t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)51+ t.Fatalf("repo_id = %q, want %q", id, repoID)
62 }52 }
63- followupArgs := mustJSON(t, map[string]string{53+ wantRoot, err := filepath.EvalSymlinks(filepath.Join(defaultRoot, repoID))
64- "branch": "feature/default-followup",54+ if err != nil {
65- "bootstrap_root": defaultWorktreeRoot,
66- })
67- if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
68 t.Fatal(err)55 t.Fatal(err)
69 }56 }
70- if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil {57+ if got, _ := details["worktree_root"].(string); got != filepath.ToSlash(wantRoot) {
71- t.Fatal(err)58+ t.Fatalf("worktree_root = %q, want %q", got, filepath.ToSlash(wantRoot))
59+ }
60+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
61+ t.Fatalf("default root saved local config: %#v", got)
72 }62 }
73- if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil {63+ for _, path := range []string{".worktrees", ".gitignore"} {
74- t.Fatalf("configured default root was not reused: %v", err)64+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
65+ t.Fatalf("default root created repository file %s: %v", path, statErr)
66+ }
75 }67 }
76 }68 }
77 69
78-func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {70+func TestCreateWorktreeDefaultRootIsStableAcrossInvocations(t *testing.T) {
79 if _, err := exec.LookPath("git"); err != nil {71 if _, err := exec.LookPath("git"); err != nil {
80 t.Skip("git is required")72 t.Skip("git is required")
81 }73 }
82 repo := initWorktreeTestRepo(t)74 repo := initWorktreeTestRepo(t)
83- tool := newCreateWorktreeTestTool(repo)75+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
84-76+ nested := filepath.Join(repo, "nested", "dir")
85- result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)77+ if err := os.MkdirAll(nested, 0o755); err != nil {
86- if err != nil {
87 t.Fatal(err)78 t.Fatal(err)
88 }79 }
89- text := result.Content[0].(provider.TextBlock).Text80+ repoID := testWorktreeRepoID(t, repo)
90- if !strings.Contains(text, "Worktree bootstrap is required") {81+
91- t.Fatalf("bootstrap result = %q", text)82+ nestedTool := newCreateWorktreeTestTool(t, nested, defaultRoot)
83+ if _, err := nestedTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-nested"}`), nil); err != nil {
84+ t.Fatal(err)
92 }85 }
93- if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {86+ rootTool := newCreateWorktreeTestTool(t, repo, defaultRoot)
94- t.Fatalf("bootstrap state = %q", state)87+ if _, err := rootTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-root"}`), nil); err != nil {
88+ t.Fatal(err)
95 }89 }
96- for _, path := range []string{".worktrees", ".gitignore"} {90+
97- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {91+ for _, branch := range []string{"feature/from-nested", "feature/from-root"} {
98- t.Fatalf("bootstrap created %s: %v", path, statErr)92+ worktree := filepath.Join(defaultRoot, repoID, filepath.FromSlash(branch))
93+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
94+ t.Fatalf("worktree %q was not created: %v", worktree, err)
99 }95 }
100 }96 }
101- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
102- t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
103- }
104- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
105- t.Fatalf("bootstrap created branch: %#v", got)
106- }
107 }97 }
108 98
109-func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) {99+func TestCreateWorktreeDefaultRootSlugIsReadable(t *testing.T) {
110 if _, err := exec.LookPath("git"); err != nil {100 if _, err := exec.LookPath("git"); err != nil {
111 t.Skip("git is required")101 t.Skip("git is required")
112 }102 }
113- repo := initWorktreeTestRepo(t)103+ parent := t.TempDir()
114- if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {104+ repo := filepath.Join(parent, "My Project!")
105+ if err := os.Mkdir(repo, 0o755); err != nil {
115 t.Fatal(err)106 t.Fatal(err)
116 }107 }
117- tool := newCreateWorktreeTestTool(repo)108+ initWorktreeTestRepoAt(t, repo)
118- args := mustJSON(t, map[string]string{109+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
119- "branch": "feature/existing-default-root",110+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
120- "bootstrap_root": defaultWorktreeRoot,
121- })
122 111
123- if _, err := tool.Preview(context.Background(), args); err != nil {112+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/slug"}`), nil); err != nil {
124 t.Fatal(err)113 t.Fatal(err)
125 }114 }
126- result, err := tool.Execute(context.Background(), args, nil)115+ entries, err := os.ReadDir(defaultRoot)
127 if err != nil {116 if err != nil {
128 t.Fatal(err)117 t.Fatal(err)
129 }118 }
130- if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil {119+ if len(entries) != 1 {
131- t.Fatalf("worktree was not created: %v", err)120+ t.Fatalf("default root entries = %d, want 1", len(entries))
132- }
133- if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" {
134- t.Fatalf("root source = %q", source)
135 }121 }
136- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {122+ if name := entries[0].Name(); !strings.HasPrefix(name, "my-project-") {
137- t.Fatalf("existing default root saved config: %#v", got)123+ t.Fatalf("repo directory %q does not carry a readable slug", name)
138 }124 }
139 }125 }
140 126
141-func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) {127+func TestCreateWorktreeSeparatesSameNamedRepositories(t *testing.T) {
142 if _, err := exec.LookPath("git"); err != nil {128 if _, err := exec.LookPath("git"); err != nil {
143 t.Skip("git is required")129 t.Skip("git is required")
144 }130 }
145- repo := initWorktreeTestRepo(t)131+ parent := t.TempDir()
146- if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {132+ repoA := filepath.Join(parent, "a", "shared")
147- t.Fatal(err)133+ repoB := filepath.Join(parent, "b", "shared")
134+ for _, repo := range []string{repoA, repoB} {
135+ if err := os.MkdirAll(repo, 0o755); err != nil {
136+ t.Fatal(err)
137+ }
138+ initWorktreeTestRepoAt(t, repo)
148 }139 }
149- tool := newCreateWorktreeTestTool(repo)140+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
150 141
151- _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{142+ toolA := newCreateWorktreeTestTool(t, repoA, defaultRoot)
152- "branch": "feature/conflicting-bootstrap",143+ if _, err := toolA.Execute(context.Background(), json.RawMessage(`{"branch":"feature/a"}`), nil); err != nil {
153- "bootstrap_root": t.TempDir(),144+ t.Fatal(err)
154- }), nil)
155- if err == nil || !strings.Contains(err.Error(), "already established") {
156- t.Fatalf("error = %v, want established-root error", err)
157- }
158- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
159- t.Fatalf("conflicting bootstrap saved config: %#v", got)
160 }145 }
161- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {146+ toolB := newCreateWorktreeTestTool(t, repoB, defaultRoot)
162- t.Fatalf("conflicting bootstrap created branch: %#v", got)147+ if _, err := toolB.Execute(context.Background(), json.RawMessage(`{"branch":"feature/b"}`), nil); err != nil {
148+ t.Fatal(err)
163 }149 }
164-}
165 150
166-func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) {151+ if idA, idB := testWorktreeRepoID(t, repoA), testWorktreeRepoID(t, repoB); idA == idB {
167- if _, err := exec.LookPath("git"); err != nil {152+ t.Fatalf("same-named repositories share repo ID %q", idA)
168- t.Skip("git is required")
169 }153 }
170- repo := initWorktreeTestRepo(t)154+ entries, err := os.ReadDir(defaultRoot)
171- gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot)155+ if err != nil {
172- tool := newCreateWorktreeTestTool(repo)156+ t.Fatal(err)
173- args := mustJSON(t, map[string]string{
174- "branch": "feature/conflicting-configured-bootstrap",
175- "bootstrap_root": t.TempDir(),
176- })
177-
178- if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") {
179- t.Fatalf("preview error = %v, want configured-root error", err)
180 }157 }
181- _, err := tool.Execute(context.Background(), args, nil)158+ if len(entries) != 2 {
182- if err == nil || !strings.Contains(err.Error(), "already configured") {159+ t.Fatalf("default root entries = %d, want 2", len(entries))
183- t.Fatalf("error = %v, want configured-root error", err)
184 }160 }
185- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 {161+ for repo, branch := range map[string]string{repoA: "feature/a", repoB: "feature/b"} {
186- t.Fatalf("conflicting bootstrap created branch: %#v", got)162+ worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), filepath.FromSlash(branch))
163+ if _, err := os.Stat(worktree); err != nil {
164+ t.Fatalf("worktree %q was not created: %v", worktree, err)
165+ }
187 }166 }
188 }167 }
189 168
190-func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {169+func TestCreateWorktreeConfiguredRootOverridesDefaultRoot(t *testing.T) {
191 if _, err := exec.LookPath("git"); err != nil {170 if _, err := exec.LookPath("git"); err != nil {
192 t.Skip("git is required")171 t.Skip("git is required")
193 }172 }
@@ -196,10 +175,11 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing
196 if err != nil {175 if err != nil {
197 t.Fatal(err)176 t.Fatal(err)
198 }177 }
199- tool := newCreateWorktreeTestTool(repo)178+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
200- args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})179+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, externalRoot)
180+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
201 181
202- result, err := tool.Execute(context.Background(), args, nil)182+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external"}`), nil)
203 if err != nil {183 if err != nil {
204 t.Fatal(err)184 t.Fatal(err)
205 }185 }
@@ -210,51 +190,119 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing
210 if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {190 if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
211 t.Fatalf("external branch = %q", got)191 t.Fatalf("external branch = %q", got)
212 }192 }
213- if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {193+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
214- t.Fatalf("saved external root = %q, want %q", got, externalRoot)194+ t.Fatalf("root source = %q", source)
215 }195 }
216- if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {196+ if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) {
217- t.Fatalf("external bootstrap changed .gitignore: %v", err)197+ t.Fatalf("configured root still touched the default root: %v", err)
218 }198 }
219- if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {199+}
220- t.Fatalf("root source = %q", source)200+
201+func TestCreateWorktreeRejectsRelativeConfiguredRoot(t *testing.T) {
202+ if _, err := exec.LookPath("git"); err != nil {
203+ t.Skip("git is required")
221 }204 }
222- if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {205+ repo := initWorktreeTestRepo(t)
223- t.Fatalf("state = %q, want created", state)206+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, ".worktrees")
207+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
208+
209+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/relative"}`), nil)
210+ if err == nil || !strings.Contains(err.Error(), "absolute path") {
211+ t.Fatalf("error = %v, want absolute-path error", err)
224 }212 }
225- followupArgs := mustJSON(t, map[string]string{213+ if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
226- "branch": "feature/external-followup",214+ t.Fatalf("relative configured root created directory: %v", statErr)
227- "bootstrap_root": externalRoot,
228- })
229- if _, err := tool.Preview(context.Background(), followupArgs); err != nil {
230- t.Fatal(err)
231 }215 }
232- followup, err := tool.Execute(context.Background(), followupArgs, nil)216+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/relative"); got.exitCode != 1 {
233- if err != nil {217+ t.Fatalf("relative configured root created branch: %#v", got)
234- t.Fatal(err)218+ }
219+}
220+
221+func TestCreateWorktreeRejectsConfiguredRootResolvingInsideRepository(t *testing.T) {
222+ if _, err := exec.LookPath("git"); err != nil {
223+ t.Skip("git is required")
235 }224 }
236- if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {225+ repo := initWorktreeTestRepo(t)
237- t.Fatalf("configured external root was not reused: %v", err)226+ link := filepath.Join(t.TempDir(), "repository-link")
227+ if err := os.Symlink(repo, link); err != nil {
228+ t.Skipf("symbolic links are unavailable: %v", err)
238 }229 }
239- if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {230+ root := filepath.Join(link, "external-worktrees")
240- t.Fatalf("follow-up root source = %q", source)231+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
232+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
233+
234+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
235+ if err == nil || !strings.Contains(err.Error(), "must be outside the repository") {
236+ t.Fatalf("error = %v, want outside-repository error", err)
241 }237 }
242 }238 }
243 239
244-func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {240+func TestCreateWorktreeRequiresDefaultRoot(t *testing.T) {
245 if _, err := exec.LookPath("git"); err != nil {241 if _, err := exec.LookPath("git"); err != nil {
246 t.Skip("git is required")242 t.Skip("git is required")
247 }243 }
248 repo := initWorktreeTestRepo(t)244 repo := initWorktreeTestRepo(t)
249- tool := newCreateWorktreeTestTool(repo)245+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo)}
250- for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {246+
251- _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)247+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-default-root"}`), nil)
252- if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {248+ if err == nil || !strings.Contains(err.Error(), "default worktree root is not configured") {
253- t.Fatalf("bootstrap_root %q error = %v", root, err)249+ t.Fatalf("error = %v, want default-root error", err)
254- }
255 }250 }
256- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {251+}
257- t.Fatalf("invalid bootstrap saved config: %#v", got)252+
253+func TestCreateWorktreeSchemaDropsBootstrapRoot(t *testing.T) {
254+ schema := string((&CreateWorktreeTool{}).Schema())
255+ if strings.Contains(schema, "bootstrap_root") {
256+ t.Fatalf("schema still exposes bootstrap_root:\n%s", schema)
257+ }
258+ var parsed struct {
259+ Properties map[string]json.RawMessage `json:"properties"`
260+ Required []string `json:"required"`
261+ }
262+ if err := json.Unmarshal([]byte(schema), &parsed); err != nil {
263+ t.Fatal(err)
264+ }
265+ if _, ok := parsed.Properties["branch"]; !ok {
266+ t.Fatalf("schema lacks branch: %s", schema)
267+ }
268+ if len(parsed.Properties) != 1 {
269+ t.Fatalf("schema properties = %v, want only branch", parsed.Properties)
270+ }
271+ if len(parsed.Required) != 1 || parsed.Required[0] != "branch" {
272+ t.Fatalf("schema required = %v", parsed.Required)
273+ }
274+}
275+
276+func TestWorktreeRepoIDIsReadableStableAndDistinct(t *testing.T) {
277+ tests := []struct {
278+ name string
279+ commonDir string
280+ slug string
281+ }{
282+ {name: "worktree metadata", commonDir: filepath.Join("home", "u", "src", "my-repo", ".git"), slug: "my-repo"},
283+ {name: "bare repository", commonDir: filepath.Join("srv", "git", "tools.git"), slug: "tools"},
284+ {name: "unusual characters", commonDir: filepath.Join("home", "u", "My Project!", ".git"), slug: "my-project"},
285+ {name: "empty slug", commonDir: filepath.Join("home", "@@@", ".git"), slug: "repo"},
286+ }
287+ for _, test := range tests {
288+ t.Run(test.name, func(t *testing.T) {
289+ id := worktreeRepoID(test.commonDir)
290+ if want := test.slug + "-"; !strings.HasPrefix(id, want) {
291+ t.Fatalf("repo ID = %q, want prefix %q", id, want)
292+ }
293+ if got := len(id) - len(test.slug) - 1; got != worktreeRepoIDHashLength {
294+ t.Fatalf("repo ID hash length = %d, want %d", got, worktreeRepoIDHashLength)
295+ }
296+ if again := worktreeRepoID(test.commonDir); again != id {
297+ t.Fatalf("repo ID is not stable: %q then %q", id, again)
298+ }
299+ })
300+ }
301+ if a, b := worktreeRepoID(filepath.Join("a", "shared", ".git")), worktreeRepoID(filepath.Join("b", "shared", ".git")); a == b {
302+ t.Fatalf("distinct repositories share repo ID %q", a)
303+ }
304+ if a, b := worktreeRepoID(filepath.Join("a", "Shared", ".git")), worktreeRepoID(filepath.Join("a", "shared", ".git")); a == b {
305+ t.Fatalf("case-different repositories share repo ID %q", a)
258 }306 }
259 }307 }
260 308
@@ -263,7 +311,7 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
263 t.Skip("git is required")311 t.Skip("git is required")
264 }312 }
265 repo := initWorktreeTestRepo(t)313 repo := initWorktreeTestRepo(t)
266- tool := newCreateWorktreeTestTool(repo)314+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
267 for _, branch := range []string{"-option", "topic@{1}"} {315 for _, branch := range []string{"-option", "topic@{1}"} {
268 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)316 _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
269 if err == nil || !strings.Contains(err.Error(), "invalid branch") {317 if err == nil || !strings.Contains(err.Error(), "invalid branch") {
@@ -272,22 +320,43 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
272 }320 }
273 }321 }
274 322
275-func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {323+func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
324+ dir := t.TempDir()
325+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
326+ tool := newCreateWorktreeTestTool(t, dir, defaultRoot)
327+
328+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
329+ if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
330+ t.Fatalf("error = %v, want repository error", err)
331+ }
332+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
333+ t.Fatalf("non-repository call created the default root: %v", statErr)
334+ }
335+}
336+
337+func TestCreateWorktreeRequiresSandbox(t *testing.T) {
338+ tool := &CreateWorktreeTool{CWD: t.TempDir(), DefaultRoot: filepath.Join(t.TempDir(), "worktrees")}
339+
340+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
341+ if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
342+ t.Fatalf("error = %v, want sandbox error", err)
343+ }
344+}
345+
346+func TestCreateWorktreeRejectsExistingBranch(t *testing.T) {
276 if _, err := exec.LookPath("git"); err != nil {347 if _, err := exec.LookPath("git"); err != nil {
277 t.Skip("git is required")348 t.Skip("git is required")
278 }349 }
279 repo := initWorktreeTestRepo(t)350 repo := initWorktreeTestRepo(t)
280- link := filepath.Join(t.TempDir(), "repository-link")351+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
281- if err := os.Symlink(repo, link); err != nil {352+ args := json.RawMessage(`{"branch":"feature/existing"}`)
282- t.Skipf("symbolic links are unavailable: %v", err)353+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
354+ t.Fatal(err)
283 }355 }
284- root := filepath.Join(link, "external-worktrees")
285- gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
286- tool := newCreateWorktreeTestTool(repo)
287 356
288- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)357+ _, err := tool.Execute(context.Background(), args, nil)
289- if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {358+ if err == nil || !strings.Contains(err.Error(), "already exists") {
290- t.Fatalf("error = %v, want external-root error", err)359+ t.Fatalf("error = %v, want collision error", err)
291 }360 }
292 }361 }
293 362
@@ -299,21 +368,19 @@ func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
299 t.Skip("git is required")368 t.Skip("git is required")
300 }369 }
301 repo := initWorktreeTestRepo(t)370 repo := initWorktreeTestRepo(t)
302- if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
303- t.Fatal(err)
304- }
305 sentinel := filepath.Join(repo, "hook-ran")371 sentinel := filepath.Join(repo, "hook-ran")
306 hook := filepath.Join(repo, ".git", "hooks", "post-checkout")372 hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
307 script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"373 script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
308 if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {374 if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
309 t.Fatal(err)375 t.Fatal(err)
310 }376 }
311- tool := newCreateWorktreeTestTool(repo)377+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
378+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
312 379
313 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {380 if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
314 t.Fatal(err)381 t.Fatal(err)
315 }382 }
316- worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")383+ worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "no-hook")
317 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {384 if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
318 t.Fatalf("worktree %q was not created: %v", worktree, err)385 t.Fatalf("worktree %q was not created: %v", worktree, err)
319 }386 }
@@ -327,7 +394,7 @@ func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
327 t.Skip("git is required")394 t.Skip("git is required")
328 }395 }
329 repo := initWorktreeTestRepo(t)396 repo := initWorktreeTestRepo(t)
330- worktreePath := filepath.Join(repo, ".worktrees", "partial")397+ worktreePath := filepath.Join(t.TempDir(), "worktrees", "partial")
331 if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {398 if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
332 t.Fatal(err)399 t.Fatal(err)
333 }400 }
@@ -357,220 +424,264 @@ func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *test
357 }424 }
358 repo := initWorktreeTestRepo(t)425 repo := initWorktreeTestRepo(t)
359 branch := "feature/checkout-failure"426 branch := "feature/checkout-failure"
427+ root := filepath.Join(t.TempDir(), "worktrees", "repo-id")
360 plan := createWorktreePlan{428 plan := createWorktreePlan{
361 repoRoot: repo,429 repoRoot: repo,
362 branch: branch,430 branch: branch,
363 base: "not-a-commit",431 base: "not-a-commit",
364- worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),432+ worktreeRoot: root,
365- configValue: defaultWorktreeRoot,433+ worktreePath: filepath.Join(root, "feature", "checkout-failure"),
366- setConfig: true,434+ rootSource: "global default root",
367 }435 }
368 436
369 _, err := executeCreateWorktreePlan(context.Background(), plan, nil)437 _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
370 if err == nil || !strings.Contains(err.Error(), "create worktree") {438 if err == nil || !strings.Contains(err.Error(), "create worktree") {
371 t.Fatalf("error = %v, want worktree creation error", err)439 t.Fatalf("error = %v, want worktree creation error", err)
372 }440 }
373- if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {441+ if _, err := os.Stat(root); !os.IsNotExist(err) {
374 t.Fatalf("failed checkout left worktree directories: %v", err)442 t.Fatalf("failed checkout left worktree directories: %v", err)
375 }443 }
376- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
377- t.Fatalf("failed checkout kept local config: %#v", got)
378- }
379 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {444 if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
380 t.Fatalf("failed checkout created branch: %#v", got)445 t.Fatalf("failed checkout created branch: %#v", got)
381 }446 }
382 }447 }
383 448
384-func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {449+func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
385 if _, err := exec.LookPath("git"); err != nil {450 if _, err := exec.LookPath("git"); err != nil {
386 t.Skip("git is required")451 t.Skip("git is required")
387 }452 }
388 repo := initWorktreeTestRepo(t)453 repo := initWorktreeTestRepo(t)
389- ignorePath := filepath.Join(repo, ".gitignore")454+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
390- before := []byte("generated/\n")455+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
391- if err := os.WriteFile(ignorePath, before, 0o644); err != nil {456+ args := json.RawMessage(`{"branch":"feature/preview"}`)
457+
458+ preview, err := tool.Preview(context.Background(), args)
459+ if err != nil {
392 t.Fatal(err)460 t.Fatal(err)
393 }461 }
394- if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {462+ text := preview.Content[0].(provider.TextBlock).Text
395- t.Fatal(err)463+ for _, want := range []string{"create worktree", "feature/preview", "global default root", testWorktreeRepoID(t, repo)} {
464+ if !strings.Contains(text, want) {
465+ t.Fatalf("preview missing %q:\n%s", want, text)
466+ }
396 }467 }
397- base := gitTestOutput(t, repo, "rev-parse", "HEAD")468+ if state, _ := preview.Details.(map[string]any)["state"].(string); state != "ready" {
398- plan := createWorktreePlan{469+ t.Fatalf("preview state = %q, want ready", state)
399- repoRoot: repo,
400- branch: "feature/ignore-failure",
401- base: base,
402- worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
403- configValue: defaultWorktreeRoot,
404- setConfig: true,
405- ignore: worktreesIgnoreUpdate{
406- path: ignorePath,
407- before: before,
408- after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
409- existed: true,
410- mode: 0o644,
411- changed: true,
412- },
413 }470 }
414-471+ if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) {
415- _, err := executeCreateWorktreePlan(context.Background(), plan, nil)472+ t.Fatalf("preview created the default root: %v", err)
416- if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
417- t.Fatalf("error = %v, want stale .gitignore error", err)
418 }473 }
419- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {474+ for _, path := range []string{".worktrees", ".gitignore"} {
420- t.Fatalf("failed bootstrap kept local config: %#v", got)475+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
476+ t.Fatalf("preview created repository file %s: %v", path, statErr)
477+ }
478+ }
479+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
480+ if err := cmd.Run(); err == nil {
481+ t.Fatal("preview created the branch")
482+ }
483+}
484+
485+func TestCreateWorktreePreviewThenExecuteCreatesWorktree(t *testing.T) {
486+ if _, err := exec.LookPath("git"); err != nil {
487+ t.Skip("git is required")
421 }488 }
422- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {489+ repo := initWorktreeTestRepo(t)
423- t.Fatalf("failed bootstrap created branch: %#v", got)490+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
491+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
492+ args := json.RawMessage(`{"branch":"feature/previewed"}`)
493+
494+ if _, err := tool.Preview(context.Background(), args); err != nil {
495+ t.Fatal(err)
424 }496 }
425- contents, err := os.ReadFile(ignorePath)497+ result, err := tool.Execute(context.Background(), args, nil)
426 if err != nil {498 if err != nil {
427 t.Fatal(err)499 t.Fatal(err)
428 }500 }
429- if got, want := string(contents), "changed by another process\n"; got != want {501+ worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "previewed")
430- t.Fatalf(".gitignore = %q, want %q", got, want)502+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
503+ t.Fatalf("worktree %q was not created: %v", worktree, err)
504+ }
505+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
506+ t.Fatalf("state = %q, want created", state)
431 }507 }
432 }508 }
433 509
434-func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {510+func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
435 if _, err := exec.LookPath("git"); err != nil {511 if _, err := exec.LookPath("git"); err != nil {
436 t.Skip("git is required")512 t.Skip("git is required")
437 }513 }
438- for _, test := range []struct {514+ repo := initWorktreeTestRepo(t)
439- name string515+ tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees"))
440- entry string516+ args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
441- }{517+ if _, err := tool.Preview(context.Background(), args); err != nil {
442- {name: "root-anchored", entry: worktreesIgnoreEntry},518+ t.Fatal(err)
443- {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444- } {
445- t.Run(test.name, func(t *testing.T) {
446- repo := initWorktreeTestRepo(t)
447- ignorePath := filepath.Join(repo, ".gitignore")
448- before := []byte(test.entry + "\n")
449- if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450- t.Fatal(err)
451- }
452- tool := newCreateWorktreeTestTool(repo)
453- args := mustJSON(t, map[string]string{
454- "branch": "feature/" + test.name + "-ignore",
455- "bootstrap_root": defaultWorktreeRoot,
456- })
457-
458- result, err := tool.Execute(context.Background(), args, nil)
459- if err != nil {
460- t.Fatal(err)
461- }
462- text := result.Content[0].(provider.TextBlock).Text
463- if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464- t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465- }
466- ignore, err := os.ReadFile(ignorePath)
467- if err != nil {
468- t.Fatal(err)
469- }
470- if string(ignore) != string(before) {
471- t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472- }
473- })
474 }519 }
475-}520+ if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
476-521+ t.Fatal(err)
477-func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {522+ }
478- for _, entry := range []string{523+ gitTestOutput(t, repo, "add", "after-preview.txt")
479- "!.worktrees/\n",524+ gitTestOutput(t, repo, "commit", "-qm", "change after preview")
480- "nested/.worktrees/\n",
481- "#.worktrees/\n",
482- ".worktrees-other/\n",
483- } {
484- t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485- ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486- if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487- t.Fatal(err)
488- }
489 525
490- update, err := newWorktreesIgnoreUpdate(ignorePath)526+ _, err := tool.Execute(context.Background(), args, nil)
491- if err != nil {527+ if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
492- t.Fatal(err)528+ t.Fatalf("error = %v, want stale-preview error", err)
493- }529+ }
494- if !update.changed {530+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
495- t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))531+ t.Fatalf("stale preview created branch: %#v", got)
496- }
497- if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498- t.Fatalf("updated .gitignore = %q, want %q", got, want)
499- }
500- })
501 }532 }
502 }533 }
503 534
504-func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {535+func TestCreateWorktreeRespectsJail(t *testing.T) {
505 if _, err := exec.LookPath("git"); err != nil {536 if _, err := exec.LookPath("git"); err != nil {
506 t.Skip("git is required")537 t.Skip("git is required")
507 }538 }
508 repo := initWorktreeTestRepo(t)539 repo := initWorktreeTestRepo(t)
509- ignorePath := filepath.Join(repo, ".gitignore")540+ nested := filepath.Join(repo, "nested")
510- if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {541+ if err := os.Mkdir(nested, 0o755); err != nil {
511 t.Fatal(err)542 t.Fatal(err)
512 }543 }
513- tool := newCreateWorktreeTestTool(repo)544+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
545+ sandbox := NewSandbox(nested)
546+ sandbox.Lock()
547+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: defaultRoot}
514 548
515- if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {549+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
550+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
551+ t.Fatalf("error = %v, want jailed error", err)
552+ }
553+ for _, path := range []string{filepath.Join(repo, ".worktrees"), filepath.Join(repo, ".gitignore")} {
554+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
555+ t.Fatalf("jailed call created %s: %v", path, statErr)
556+ }
557+ }
558+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
559+ t.Fatalf("jailed call created the default root: %v", statErr)
560+ }
561+}
562+
563+func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
564+ if runtime.GOOS == "windows" {
565+ t.Skip("Git invocation fixture uses a POSIX shell")
566+ }
567+ if _, err := exec.LookPath("git"); err != nil {
568+ t.Skip("git is required")
569+ }
570+ repo := initWorktreeTestRepo(t)
571+ nested := filepath.Join(repo, "nested")
572+ if err := os.Mkdir(nested, 0o755); err != nil {
516 t.Fatal(err)573 t.Fatal(err)
517 }574 }
518- ignore, err := os.ReadFile(ignorePath)575+ sentinel := filepath.Join(repo, "git-was-invoked")
519- if err != nil {576+ binDir := t.TempDir()
577+ fakeGit := filepath.Join(binDir, "git")
578+ if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
520 t.Fatal(err)579 t.Fatal(err)
521 }580 }
522- if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {581+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
523- t.Fatalf(".gitignore = %q, want %q", got, want)582+ sandbox := NewSandbox(nested)
583+ sandbox.Lock()
584+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: filepath.Join(t.TempDir(), "worktrees")}
585+
586+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
587+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
588+ t.Fatalf("error = %v, want jailed error", err)
589+ }
590+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
591+ t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
524 }592 }
525 }593 }
526 594
527-func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {595+func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
528 if _, err := exec.LookPath("git"); err != nil {596 if _, err := exec.LookPath("git"); err != nil {
529 t.Skip("git is required")597 t.Skip("git is required")
530 }598 }
531 repo := initWorktreeTestRepo(t)599 repo := initWorktreeTestRepo(t)
532- target := filepath.Join(repo, "ignore-target")600+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
533- if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {601+ sandbox := NewSandbox(repo)
534- t.Fatal(err)602+ permissions := &PermissionSet{}
603+ permissions.Bash.Mode = "allowlist"
604+ permissions.Bash.Allow = []string{"git"}
605+ sandbox.SetPermissions(permissions)
606+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
607+
608+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
609+ if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
610+ t.Fatalf("error = %v, want filesystem permission error", err)
535 }611 }
536- ignorePath := filepath.Join(repo, ".gitignore")612+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
537- if err := os.Symlink(target, ignorePath); err != nil {613+ t.Fatalf("restricted call created the default root: %v", statErr)
538- t.Skipf("symbolic links are unavailable: %v", err)
539 }614 }
540- tool := newCreateWorktreeTestTool(repo)615+}
541 616
542- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)617+func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
543- if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {618+ if _, err := exec.LookPath("git"); err != nil {
544- t.Fatalf("error = %v, want symbolic-link error", err)619+ t.Skip("git is required")
545 }620 }
546- info, err := os.Lstat(ignorePath)621+ repo := initWorktreeTestRepo(t)
547- if err != nil {622+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
548- t.Fatal(err)623+ sandbox := NewSandbox(repo)
624+ permissions := &PermissionSet{}
625+ permissions.FS.Read = []string{repo}
626+ permissions.FS.Write = []string{repo}
627+ sandbox.SetPermissions(permissions)
628+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
629+
630+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
631+ if err == nil || !strings.Contains(err.Error(), "no bash permission") {
632+ t.Fatalf("error = %v, want git permission error", err)
549 }633 }
550- if info.Mode()&os.ModeSymlink == 0 {634+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
551- t.Fatal("tool replaced .gitignore symbolic link")635+ t.Fatalf("restricted call created the default root: %v", statErr)
552 }636 }
553- if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {637+}
554- t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)638+
639+func TestCreateWorktreeWritesOutsideDeclaredScopesAreDenied(t *testing.T) {
640+ if _, err := exec.LookPath("git"); err != nil {
641+ t.Skip("git is required")
642+ }
643+ repo := initWorktreeTestRepo(t)
644+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
645+ sandbox := NewSandbox(repo)
646+ permissions := &PermissionSet{}
647+ permissions.FS.Read = []string{repo}
648+ permissions.FS.Write = []string{repo}
649+ permissions.Bash.Mode = "allowlist"
650+ permissions.Bash.Allow = []string{"git"}
651+ sandbox.SetPermissions(permissions)
652+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot}
653+
654+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/out-of-scope"}`), nil)
655+ if err == nil || !strings.Contains(err.Error(), "outside declared scopes") {
656+ t.Fatalf("error = %v, want scope-denied error", err)
657+ }
658+ if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) {
659+ t.Fatalf("denied call created the default root: %v", statErr)
555 }660 }
556 }661 }
557 662
558-func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {663+func TestCreateWorktreeRejectsInvalidWorktreePathBeforeCheckout(t *testing.T) {
559 if _, err := exec.LookPath("git"); err != nil {664 if _, err := exec.LookPath("git"); err != nil {
560 t.Skip("git is required")665 t.Skip("git is required")
561 }666 }
562 repo := initWorktreeTestRepo(t)667 repo := initWorktreeTestRepo(t)
563- if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {668+ defaultRoot := filepath.Join(t.TempDir(), "worktrees")
669+ repoRoot := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo))
670+ if err := os.MkdirAll(repoRoot, 0o755); err != nil {
671+ t.Fatal(err)
672+ }
673+ if err := os.WriteFile(filepath.Join(repoRoot, "blocked"), []byte("not a directory"), 0o644); err != nil {
564 t.Fatal(err)674 t.Fatal(err)
565 }675 }
566- tool := newCreateWorktreeTestTool(repo)676+ tool := newCreateWorktreeTestTool(t, repo, defaultRoot)
567 677
568- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)678+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
569- if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {679+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
570- t.Fatalf("error = %v, want regular-file error", err)680+ t.Fatalf("error = %v, want non-directory path error", err)
571 }681 }
572- if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {682+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
573- t.Fatalf("non-regular ignore saved config: %#v", got)683+ if err := cmd.Run(); err == nil {
684+ t.Fatal("invalid path created the branch")
574 }685 }
575 }686 }
576 687
@@ -664,255 +775,34 @@ func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
664 }775 }
665 }776 }
666 777
667-func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {778+func newCreateWorktreeTestTool(t *testing.T, cwd, defaultRoot string) *CreateWorktreeTool {
668- if _, err := exec.LookPath("git"); err != nil {779+ t.Helper()
669- t.Skip("git is required")780+ return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd), DefaultRoot: defaultRoot}
670- }
671- repo := initWorktreeTestRepo(t)
672- tool := newCreateWorktreeTestTool(repo)
673- args := json.RawMessage(`{"branch":"feature/preview"}`)
674-
675- preview, err := tool.Preview(context.Background(), args)
676- if err != nil {
677- t.Fatal(err)
678- }
679- text := preview.Content[0].(provider.TextBlock).Text
680- for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
681- if !strings.Contains(text, want) {
682- t.Fatalf("preview missing %q:\n%s", want, text)
683- }
684- }
685- if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
686- t.Fatalf("preview created .worktrees: %v", err)
687- }
688- if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
689- t.Fatalf("preview created .gitignore: %v", err)
690- }
691- cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
692- if err := cmd.Run(); err == nil {
693- t.Fatal("preview created the branch")
694- }
695-}
696-
697-func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
698- if _, err := exec.LookPath("git"); err != nil {
699- t.Skip("git is required")
700- }
701- repo := initWorktreeTestRepo(t)
702- if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
703- t.Fatal(err)
704- }
705- tool := newCreateWorktreeTestTool(repo)
706- args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
707- if _, err := tool.Preview(context.Background(), args); err != nil {
708- t.Fatal(err)
709- }
710- if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
711- t.Fatal(err)
712- }
713- gitTestOutput(t, repo, "add", "after-preview.txt")
714- gitTestOutput(t, repo, "commit", "-qm", "change after preview")
715-
716- _, err := tool.Execute(context.Background(), args, nil)
717- if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
718- t.Fatalf("error = %v, want stale-preview error", err)
719- }
720- if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
721- t.Fatalf("stale preview created branch: %#v", got)
722- }
723-}
724-
725-func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
726- dir := t.TempDir()
727- tool := newCreateWorktreeTestTool(dir)
728-
729- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
730- if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
731- t.Fatalf("error = %v, want repository error", err)
732- }
733- for _, path := range []string{".worktrees", ".gitignore"} {
734- if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
735- t.Fatalf("non-repository call created %s: %v", path, statErr)
736- }
737- }
738-}
739-
740-func TestCreateWorktreeRequiresSandbox(t *testing.T) {
741- tool := &CreateWorktreeTool{CWD: t.TempDir()}
742-
743- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
744- if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
745- t.Fatalf("error = %v, want sandbox error", err)
746- }
747 }781 }
748 782
749-func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {783+func testWorktreeRepoID(t *testing.T, repo string) string {
750- if _, err := exec.LookPath("git"); err != nil {784+ t.Helper()
751- t.Skip("git is required")785+ real, err := canonical(repo)
752- }
753- repo := initWorktreeTestRepo(t)
754- tool := newCreateWorktreeTestTool(repo)
755- bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
756- if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
757- t.Fatal(err)
758- }
759- ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
760 if err != nil {786 if err != nil {
761 t.Fatal(err)787 t.Fatal(err)
762 }788 }
763-789+ commonDir, err := canonical(filepath.Join(real, ".git"))
764- _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
765- if err == nil || !strings.Contains(err.Error(), "already exists") {
766- t.Fatalf("error = %v, want collision error", err)
767- }
768- ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
769 if err != nil {790 if err != nil {
770 t.Fatal(err)791 t.Fatal(err)
771 }792 }
772- if string(ignoreAfter) != string(ignoreBefore) {793+ return worktreeRepoID(commonDir)
773- t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
774- }
775-}
776-
777-func TestCreateWorktreeRespectsJail(t *testing.T) {
778- if _, err := exec.LookPath("git"); err != nil {
779- t.Skip("git is required")
780- }
781- repo := initWorktreeTestRepo(t)
782- nested := filepath.Join(repo, "nested")
783- if err := os.Mkdir(nested, 0o755); err != nil {
784- t.Fatal(err)
785- }
786- sandbox := NewSandbox(nested)
787- sandbox.Lock()
788- tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
789-
790- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
791- if err == nil || !strings.Contains(err.Error(), "jailed:") {
792- t.Fatalf("error = %v, want jailed error", err)
793- }
794- for _, path := range []string{".worktrees", ".gitignore"} {
795- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
796- t.Fatalf("jailed call created %s: %v", path, statErr)
797- }
798- }
799-}
800-
801-func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
802- if runtime.GOOS == "windows" {
803- t.Skip("Git invocation fixture uses a POSIX shell")
804- }
805- if _, err := exec.LookPath("git"); err != nil {
806- t.Skip("git is required")
807- }
808- repo := initWorktreeTestRepo(t)
809- nested := filepath.Join(repo, "nested")
810- if err := os.Mkdir(nested, 0o755); err != nil {
811- t.Fatal(err)
812- }
813- sentinel := filepath.Join(repo, "git-was-invoked")
814- binDir := t.TempDir()
815- fakeGit := filepath.Join(binDir, "git")
816- if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
817- t.Fatal(err)
818- }
819- t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
820- sandbox := NewSandbox(nested)
821- sandbox.Lock()
822- tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
823-
824- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
825- if err == nil || !strings.Contains(err.Error(), "jailed:") {
826- t.Fatalf("error = %v, want jailed error", err)
827- }
828- if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
829- t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
830- }
831-}
832-
833-func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
834- if _, err := exec.LookPath("git"); err != nil {
835- t.Skip("git is required")
836- }
837- repo := initWorktreeTestRepo(t)
838- sandbox := NewSandbox(repo)
839- permissions := &PermissionSet{}
840- permissions.Bash.Mode = "allowlist"
841- permissions.Bash.Allow = []string{"git"}
842- sandbox.SetPermissions(permissions)
843- tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
844-
845- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
846- if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
847- t.Fatalf("error = %v, want filesystem permission error", err)
848- }
849- for _, path := range []string{".worktrees", ".gitignore"} {
850- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
851- t.Fatalf("restricted call created %s: %v", path, statErr)
852- }
853- }
854-}
855-
856-func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
857- if _, err := exec.LookPath("git"); err != nil {
858- t.Skip("git is required")
859- }
860- repo := initWorktreeTestRepo(t)
861- sandbox := NewSandbox(repo)
862- permissions := &PermissionSet{}
863- permissions.FS.Read = []string{repo}
864- permissions.FS.Write = []string{repo}
865- sandbox.SetPermissions(permissions)
866- tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
867-
868- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
869- if err == nil || !strings.Contains(err.Error(), "no bash permission") {
870- t.Fatalf("error = %v, want git permission error", err)
871- }
872- for _, path := range []string{".worktrees", ".gitignore"} {
873- if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
874- t.Fatalf("restricted call created %s: %v", path, statErr)
875- }
876- }
877-}
878-
879-func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
880- if _, err := exec.LookPath("git"); err != nil {
881- t.Skip("git is required")
882- }
883- repo := initWorktreeTestRepo(t)
884- worktrees := filepath.Join(repo, ".worktrees")
885- if err := os.Mkdir(worktrees, 0o755); err != nil {
886- t.Fatal(err)
887- }
888- if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
889- t.Fatal(err)
890- }
891- tool := newCreateWorktreeTestTool(repo)
892-
893- _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
894- if err == nil || !strings.Contains(err.Error(), "not a directory") {
895- t.Fatalf("error = %v, want non-directory path error", err)
896- }
897- if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
898- t.Fatalf("invalid path left .gitignore behind: %v", statErr)
899- }
900- cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
901- if err := cmd.Run(); err == nil {
902- t.Fatal("invalid path created the branch")
903- }
904 }794 }
905 795
906-func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {796+func initWorktreeTestRepo(t *testing.T) string {
907- return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}797+ t.Helper()
798+ return initWorktreeTestRepoAt(t, t.TempDir())
908 }799 }
909 800
910-func initWorktreeTestRepo(t *testing.T) string {801+func initWorktreeTestRepoAt(t *testing.T, repo string) string {
911 t.Helper()802 t.Helper()
912 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")803 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
913 t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)804 t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
914 t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)805 t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
915- repo := t.TempDir()
916 gitTestOutput(t, repo, "init", "-q")806 gitTestOutput(t, repo, "init", "-q")
917 gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")807 gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
918 gitTestOutput(t, repo, "config", "user.name", "Test User")808 gitTestOutput(t, repo, "config", "user.name", "Test User")
A packages/agent/tools/manage_worktrees.go +355 -0
@@ -0,0 +1,355 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+
13+ "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+// ManageWorktreesTool inventories repository worktrees and removes only clean,
18+// merged, explicitly selected secondary worktrees.
19+type ManageWorktreesTool struct {
20+ CWD string
21+ Sandbox *Sandbox
22+
23+ previewMu sync.Mutex
24+ preview *manageWorktreesPreview
25+}
26+
27+type manageWorktreesArgs struct {
28+ Action string `json:"action"`
29+ Paths []string `json:"paths,omitempty"`
30+}
31+
32+type managedWorktree struct {
33+ Path string `json:"path"`
34+ Branch string `json:"branch,omitempty"`
35+ Head string `json:"head,omitempty"`
36+ Primary bool `json:"primary"`
37+ Dirty bool `json:"dirty"`
38+ Prunable bool `json:"prunable"`
39+ Merged bool `json:"merged"`
40+ CleanupOK bool `json:"cleanup_eligible"`
41+ BlockedBy string `json:"blocked_by,omitempty"`
42+}
43+
44+type manageWorktreesPlan struct {
45+ repoRoot string
46+ action string
47+ selected []managedWorktree
48+ all []managedWorktree
49+ text string
50+}
51+
52+type manageWorktreesPreview struct {
53+ raw string
54+ plan manageWorktreesPlan
55+}
56+
57+const manageWorktreesSchema = `{
58+ "type":"object",
59+ "properties":{
60+ "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},
61+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."}
62+ },
63+ "required":["action"]
64+}`
65+
66+func (t *ManageWorktreesTool) Name() string { return "manage_worktrees" }
67+func (t *ManageWorktreesTool) Description() string {
68+ return "List repository worktrees and safely remove explicitly selected clean, merged worktrees."
69+}
70+func (t *ManageWorktreesTool) Schema() json.RawMessage { return json.RawMessage(manageWorktreesSchema) }
71+
72+func (t *ManageWorktreesTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
73+ plan, err := t.plan(ctx, raw)
74+ if err != nil {
75+ return core.ToolResult{}, err
76+ }
77+ t.previewMu.Lock()
78+ t.preview = &manageWorktreesPreview{raw: string(raw), plan: plan}
79+ t.previewMu.Unlock()
80+ return manageWorktreesResult(plan, "preview"), nil
81+}
82+
83+func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
84+ plan, err := t.plan(ctx, raw)
85+ if err != nil {
86+ return core.ToolResult{}, err
87+ }
88+ t.previewMu.Lock()
89+ cached := t.preview
90+ t.preview = nil
91+ t.previewMu.Unlock()
92+ if cached != nil && cached.raw == string(raw) && !sameManageWorktreesPlan(cached.plan, plan) {
93+ return core.ToolResult{}, errors.New("manage_worktrees: repository state changed since preview; inspect and retry")
94+ }
95+ if plan.action == "list" {
96+ return manageWorktreesResult(plan, "listed"), nil
97+ }
98+ for _, worktree := range plan.selected {
99+ if progress != nil {
100+ progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")
101+ }
102+ if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {
103+ return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err)
104+ }
105+ }
106+ _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
107+ return manageWorktreesResult(plan, "removed"), nil
108+}
109+
110+func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
111+ var args manageWorktreesArgs
112+ if err := json.Unmarshal(raw, &args); err != nil {
113+ return manageWorktreesPlan{}, fmt.Errorf("invalid args: %w", err)
114+ }
115+ if args.Action != "list" && args.Action != "cleanup" {
116+ return manageWorktreesPlan{}, errors.New("manage_worktrees: action must be list or cleanup")
117+ }
118+ if t.Sandbox == nil {
119+ return manageWorktreesPlan{}, errors.New("manage_worktrees: sandbox is required")
120+ }
121+ if err := t.Sandbox.CheckBashPermission("git"); err != nil {
122+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
123+ }
124+ cwd := t.CWD
125+ if cwd == "" {
126+ var err error
127+ cwd, err = os.Getwd()
128+ if err != nil {
129+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: working directory: %w", err)
130+ }
131+ }
132+ if err := t.Sandbox.CheckReadPath(cwd); err != nil {
133+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
134+ }
135+ repoRoot, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
136+ if err != nil {
137+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: verify repository: %w", err)
138+ }
139+ repoRoot, err = filepath.Abs(repoRoot)
140+ if err != nil {
141+ return manageWorktreesPlan{}, err
142+ }
143+ if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {
144+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
145+ }
146+ items, err := inspectManagedWorktrees(ctx, repoRoot)
147+ if err != nil {
148+ return manageWorktreesPlan{}, err
149+ }
150+ plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items}
151+ if args.Action == "cleanup" {
152+ if len(args.Paths) == 0 {
153+ return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
154+ }
155+ byPath := make(map[string]managedWorktree, len(items))
156+ for _, item := range items {
157+ canonicalPath, err := canonicalOrParent(item.Path)
158+ if err != nil {
159+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve registered path: %w", err)
160+ }
161+ byPath[canonicalPath] = item
162+ }
163+ seen := map[string]bool{}
164+ for _, requested := range args.Paths {
165+ if !filepath.IsAbs(requested) {
166+ requested = filepath.Join(cwd, requested)
167+ }
168+ path, err := canonicalOrParent(requested)
169+ if err != nil {
170+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve path: %w", err)
171+ }
172+ item, ok := byPath[path]
173+ if !ok {
174+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q is not a registered worktree", filepath.ToSlash(path))
175+ }
176+ if !item.CleanupOK {
177+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q cannot be removed: %s", filepath.ToSlash(path), item.BlockedBy)
178+ }
179+ if err := t.Sandbox.CheckWritePath(item.Path); err != nil {
180+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
181+ }
182+ if !seen[item.Path] {
183+ plan.selected = append(plan.selected, item)
184+ seen[item.Path] = true
185+ }
186+ }
187+ }
188+ plan.text = renderManagedWorktrees(plan)
189+ return plan, nil
190+}
191+
192+// WorktreeInventoryContext returns a model-readable repository inventory. A
193+// non-Git working directory has no inventory and is not an error.
194+func WorktreeInventoryContext(ctx context.Context, cwd string) string {
195+ root, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
196+ if err != nil {
197+ return ""
198+ }
199+ output, err := manageWorktreesGit(ctx, root, "worktree", "list", "--porcelain")
200+ if err != nil {
201+ return ""
202+ }
203+ const maxInventoryItems = 20
204+ var b strings.Builder
205+ b.WriteString("[Repository worktrees]\n")
206+ count := 0
207+ for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
208+ if count == maxInventoryItems {
209+ break
210+ }
211+ path, branch := "", ""
212+ for _, line := range strings.Split(block, "\n") {
213+ if strings.HasPrefix(line, "worktree ") {
214+ path = strings.TrimPrefix(line, "worktree ")
215+ }
216+ if strings.HasPrefix(line, "branch refs/heads/") {
217+ branch = strings.TrimPrefix(line, "branch refs/heads/")
218+ }
219+ }
220+ if path == "" {
221+ continue
222+ }
223+ fmt.Fprintf(&b, "- %s", filepath.ToSlash(path))
224+ if branch != "" {
225+ fmt.Fprintf(&b, " [%s]", branch)
226+ }
227+ b.WriteByte('\n')
228+ count++
229+ }
230+ total := len(strings.Split(strings.TrimSpace(output), "\n\n"))
231+ if total <= 1 {
232+ return ""
233+ }
234+ if total > count {
235+ fmt.Fprintf(&b, "- ... %d more worktrees\n", total-count)
236+ }
237+ b.WriteString("Use worktree action=list for current safety status before cleanup.")
238+ return b.String()
239+}
240+
241+func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) {
242+ output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
243+ if err != nil {
244+ return nil, fmt.Errorf("manage_worktrees: list: %w", err)
245+ }
246+ blocks := strings.Split(strings.TrimSpace(output), "\n\n")
247+ items := make([]managedWorktree, 0, len(blocks))
248+ for index, block := range blocks {
249+ item := managedWorktree{Primary: index == 0}
250+ for _, line := range strings.Split(block, "\n") {
251+ switch {
252+ case strings.HasPrefix(line, "worktree "):
253+ item.Path = strings.TrimPrefix(line, "worktree ")
254+ case strings.HasPrefix(line, "HEAD "):
255+ item.Head = strings.TrimPrefix(line, "HEAD ")
256+ case strings.HasPrefix(line, "branch refs/heads/"):
257+ item.Branch = strings.TrimPrefix(line, "branch refs/heads/")
258+ case strings.HasPrefix(line, "prunable"):
259+ item.Prunable = true
260+ }
261+ }
262+ if item.Path == "" {
263+ continue
264+ }
265+ if item.Primary {
266+ item.BlockedBy = "primary worktree"
267+ } else if item.Prunable {
268+ item.BlockedBy = "missing worktree; prune metadata instead"
269+ } else if item.Branch == "" {
270+ item.BlockedBy = "detached HEAD"
271+ } else if _, submoduleErr := os.Stat(filepath.Join(item.Path, ".gitmodules")); submoduleErr == nil {
272+ item.BlockedBy = "worktree contains submodules"
273+ } else if !os.IsNotExist(submoduleErr) {
274+ item.BlockedBy = "cannot inspect submodules: " + firstErrorLine(submoduleErr)
275+ } else {
276+ status, statusErr := manageWorktreesGit(ctx, item.Path, "status", "--porcelain", "--ignored=matching")
277+ if statusErr != nil {
278+ item.BlockedBy = "status unavailable: " + firstErrorLine(statusErr)
279+ } else if status != "" {
280+ item.Dirty = true
281+ item.BlockedBy = "uncommitted changes"
282+ } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil {
283+ item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr)
284+ } else {
285+ item.Merged = true
286+ item.CleanupOK = true
287+ }
288+ }
289+ items = append(items, item)
290+ }
291+ return items, nil
292+}
293+
294+func renderManagedWorktrees(plan manageWorktreesPlan) string {
295+ var b strings.Builder
296+ if plan.action == "cleanup" {
297+ b.WriteString("remove worktrees\n")
298+ } else {
299+ b.WriteString("repository worktrees\n")
300+ }
301+ for _, item := range plan.all {
302+ fmt.Fprintf(&b, "- %s", filepath.ToSlash(item.Path))
303+ if item.Branch != "" {
304+ fmt.Fprintf(&b, " [%s]", item.Branch)
305+ }
306+ state := item.BlockedBy
307+ if item.CleanupOK {
308+ state = "clean and merged; cleanup eligible"
309+ }
310+ if state != "" {
311+ fmt.Fprintf(&b, ": %s", state)
312+ }
313+ b.WriteByte('\n')
314+ }
315+ if plan.action == "cleanup" {
316+ b.WriteString("selected:\n")
317+ for _, item := range plan.selected {
318+ fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))
319+ }
320+ }
321+ return b.String()
322+}
323+
324+func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResult {
325+ return core.ToolResult{
326+ Content: []provider.Content{provider.TextBlock{Text: plan.text}},
327+ Details: map[string]any{"state": state, "worktrees": plan.all},
328+ }
329+}
330+
331+func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
332+ if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
333+ return false
334+ }
335+ for index := range a.selected {
336+ if a.selected[index] != b.selected[index] {
337+ return false
338+ }
339+ }
340+ for index := range a.all {
341+ if a.all[index] != b.all[index] {
342+ return false
343+ }
344+ }
345+ return true
346+}
347+
348+func firstErrorLine(err error) string {
349+ line, _, _ := strings.Cut(err.Error(), "\n")
350+ return line
351+}
352+
353+func manageWorktreesGit(ctx context.Context, dir string, args ...string) (string, error) {
354+ return createWorktreeGitOutput(ctx, dir, args...)
355+}
@@ -0,0 +1,355 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "strings"
11+ "sync"
12+
13+ "github.com/bnema/zut/packages/core"
14+ "github.com/bnema/zut/packages/provider"
15+)
16+
17+// ManageWorktreesTool inventories repository worktrees and removes only clean,
18+// merged, explicitly selected secondary worktrees.
19+type ManageWorktreesTool struct {
20+ CWD string
21+ Sandbox *Sandbox
22+
23+ previewMu sync.Mutex
24+ preview *manageWorktreesPreview
25+}
26+
27+type manageWorktreesArgs struct {
28+ Action string `json:"action"`
29+ Paths []string `json:"paths,omitempty"`
30+}
31+
32+type managedWorktree struct {
33+ Path string `json:"path"`
34+ Branch string `json:"branch,omitempty"`
35+ Head string `json:"head,omitempty"`
36+ Primary bool `json:"primary"`
37+ Dirty bool `json:"dirty"`
38+ Prunable bool `json:"prunable"`
39+ Merged bool `json:"merged"`
40+ CleanupOK bool `json:"cleanup_eligible"`
41+ BlockedBy string `json:"blocked_by,omitempty"`
42+}
43+
44+type manageWorktreesPlan struct {
45+ repoRoot string
46+ action string
47+ selected []managedWorktree
48+ all []managedWorktree
49+ text string
50+}
51+
52+type manageWorktreesPreview struct {
53+ raw string
54+ plan manageWorktreesPlan
55+}
56+
57+const manageWorktreesSchema = `{
58+ "type":"object",
59+ "properties":{
60+ "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."},
61+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."}
62+ },
63+ "required":["action"]
64+}`
65+
66+func (t *ManageWorktreesTool) Name() string { return "manage_worktrees" }
67+func (t *ManageWorktreesTool) Description() string {
68+ return "List repository worktrees and safely remove explicitly selected clean, merged worktrees."
69+}
70+func (t *ManageWorktreesTool) Schema() json.RawMessage { return json.RawMessage(manageWorktreesSchema) }
71+
72+func (t *ManageWorktreesTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
73+ plan, err := t.plan(ctx, raw)
74+ if err != nil {
75+ return core.ToolResult{}, err
76+ }
77+ t.previewMu.Lock()
78+ t.preview = &manageWorktreesPreview{raw: string(raw), plan: plan}
79+ t.previewMu.Unlock()
80+ return manageWorktreesResult(plan, "preview"), nil
81+}
82+
83+func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
84+ plan, err := t.plan(ctx, raw)
85+ if err != nil {
86+ return core.ToolResult{}, err
87+ }
88+ t.previewMu.Lock()
89+ cached := t.preview
90+ t.preview = nil
91+ t.previewMu.Unlock()
92+ if cached != nil && cached.raw == string(raw) && !sameManageWorktreesPlan(cached.plan, plan) {
93+ return core.ToolResult{}, errors.New("manage_worktrees: repository state changed since preview; inspect and retry")
94+ }
95+ if plan.action == "list" {
96+ return manageWorktreesResult(plan, "listed"), nil
97+ }
98+ for _, worktree := range plan.selected {
99+ if progress != nil {
100+ progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n")
101+ }
102+ if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil {
103+ return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err)
104+ }
105+ }
106+ _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune")
107+ return manageWorktreesResult(plan, "removed"), nil
108+}
109+
110+func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) {
111+ var args manageWorktreesArgs
112+ if err := json.Unmarshal(raw, &args); err != nil {
113+ return manageWorktreesPlan{}, fmt.Errorf("invalid args: %w", err)
114+ }
115+ if args.Action != "list" && args.Action != "cleanup" {
116+ return manageWorktreesPlan{}, errors.New("manage_worktrees: action must be list or cleanup")
117+ }
118+ if t.Sandbox == nil {
119+ return manageWorktreesPlan{}, errors.New("manage_worktrees: sandbox is required")
120+ }
121+ if err := t.Sandbox.CheckBashPermission("git"); err != nil {
122+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
123+ }
124+ cwd := t.CWD
125+ if cwd == "" {
126+ var err error
127+ cwd, err = os.Getwd()
128+ if err != nil {
129+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: working directory: %w", err)
130+ }
131+ }
132+ if err := t.Sandbox.CheckReadPath(cwd); err != nil {
133+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
134+ }
135+ repoRoot, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
136+ if err != nil {
137+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: verify repository: %w", err)
138+ }
139+ repoRoot, err = filepath.Abs(repoRoot)
140+ if err != nil {
141+ return manageWorktreesPlan{}, err
142+ }
143+ if err := t.Sandbox.CheckReadPath(repoRoot); err != nil {
144+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
145+ }
146+ items, err := inspectManagedWorktrees(ctx, repoRoot)
147+ if err != nil {
148+ return manageWorktreesPlan{}, err
149+ }
150+ plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items}
151+ if args.Action == "cleanup" {
152+ if len(args.Paths) == 0 {
153+ return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path")
154+ }
155+ byPath := make(map[string]managedWorktree, len(items))
156+ for _, item := range items {
157+ canonicalPath, err := canonicalOrParent(item.Path)
158+ if err != nil {
159+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve registered path: %w", err)
160+ }
161+ byPath[canonicalPath] = item
162+ }
163+ seen := map[string]bool{}
164+ for _, requested := range args.Paths {
165+ if !filepath.IsAbs(requested) {
166+ requested = filepath.Join(cwd, requested)
167+ }
168+ path, err := canonicalOrParent(requested)
169+ if err != nil {
170+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve path: %w", err)
171+ }
172+ item, ok := byPath[path]
173+ if !ok {
174+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q is not a registered worktree", filepath.ToSlash(path))
175+ }
176+ if !item.CleanupOK {
177+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q cannot be removed: %s", filepath.ToSlash(path), item.BlockedBy)
178+ }
179+ if err := t.Sandbox.CheckWritePath(item.Path); err != nil {
180+ return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err)
181+ }
182+ if !seen[item.Path] {
183+ plan.selected = append(plan.selected, item)
184+ seen[item.Path] = true
185+ }
186+ }
187+ }
188+ plan.text = renderManagedWorktrees(plan)
189+ return plan, nil
190+}
191+
192+// WorktreeInventoryContext returns a model-readable repository inventory. A
193+// non-Git working directory has no inventory and is not an error.
194+func WorktreeInventoryContext(ctx context.Context, cwd string) string {
195+ root, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel")
196+ if err != nil {
197+ return ""
198+ }
199+ output, err := manageWorktreesGit(ctx, root, "worktree", "list", "--porcelain")
200+ if err != nil {
201+ return ""
202+ }
203+ const maxInventoryItems = 20
204+ var b strings.Builder
205+ b.WriteString("[Repository worktrees]\n")
206+ count := 0
207+ for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") {
208+ if count == maxInventoryItems {
209+ break
210+ }
211+ path, branch := "", ""
212+ for _, line := range strings.Split(block, "\n") {
213+ if strings.HasPrefix(line, "worktree ") {
214+ path = strings.TrimPrefix(line, "worktree ")
215+ }
216+ if strings.HasPrefix(line, "branch refs/heads/") {
217+ branch = strings.TrimPrefix(line, "branch refs/heads/")
218+ }
219+ }
220+ if path == "" {
221+ continue
222+ }
223+ fmt.Fprintf(&b, "- %s", filepath.ToSlash(path))
224+ if branch != "" {
225+ fmt.Fprintf(&b, " [%s]", branch)
226+ }
227+ b.WriteByte('\n')
228+ count++
229+ }
230+ total := len(strings.Split(strings.TrimSpace(output), "\n\n"))
231+ if total <= 1 {
232+ return ""
233+ }
234+ if total > count {
235+ fmt.Fprintf(&b, "- ... %d more worktrees\n", total-count)
236+ }
237+ b.WriteString("Use worktree action=list for current safety status before cleanup.")
238+ return b.String()
239+}
240+
241+func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) {
242+ output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain")
243+ if err != nil {
244+ return nil, fmt.Errorf("manage_worktrees: list: %w", err)
245+ }
246+ blocks := strings.Split(strings.TrimSpace(output), "\n\n")
247+ items := make([]managedWorktree, 0, len(blocks))
248+ for index, block := range blocks {
249+ item := managedWorktree{Primary: index == 0}
250+ for _, line := range strings.Split(block, "\n") {
251+ switch {
252+ case strings.HasPrefix(line, "worktree "):
253+ item.Path = strings.TrimPrefix(line, "worktree ")
254+ case strings.HasPrefix(line, "HEAD "):
255+ item.Head = strings.TrimPrefix(line, "HEAD ")
256+ case strings.HasPrefix(line, "branch refs/heads/"):
257+ item.Branch = strings.TrimPrefix(line, "branch refs/heads/")
258+ case strings.HasPrefix(line, "prunable"):
259+ item.Prunable = true
260+ }
261+ }
262+ if item.Path == "" {
263+ continue
264+ }
265+ if item.Primary {
266+ item.BlockedBy = "primary worktree"
267+ } else if item.Prunable {
268+ item.BlockedBy = "missing worktree; prune metadata instead"
269+ } else if item.Branch == "" {
270+ item.BlockedBy = "detached HEAD"
271+ } else if _, submoduleErr := os.Stat(filepath.Join(item.Path, ".gitmodules")); submoduleErr == nil {
272+ item.BlockedBy = "worktree contains submodules"
273+ } else if !os.IsNotExist(submoduleErr) {
274+ item.BlockedBy = "cannot inspect submodules: " + firstErrorLine(submoduleErr)
275+ } else {
276+ status, statusErr := manageWorktreesGit(ctx, item.Path, "status", "--porcelain", "--ignored=matching")
277+ if statusErr != nil {
278+ item.BlockedBy = "status unavailable: " + firstErrorLine(statusErr)
279+ } else if status != "" {
280+ item.Dirty = true
281+ item.BlockedBy = "uncommitted changes"
282+ } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil {
283+ item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr)
284+ } else {
285+ item.Merged = true
286+ item.CleanupOK = true
287+ }
288+ }
289+ items = append(items, item)
290+ }
291+ return items, nil
292+}
293+
294+func renderManagedWorktrees(plan manageWorktreesPlan) string {
295+ var b strings.Builder
296+ if plan.action == "cleanup" {
297+ b.WriteString("remove worktrees\n")
298+ } else {
299+ b.WriteString("repository worktrees\n")
300+ }
301+ for _, item := range plan.all {
302+ fmt.Fprintf(&b, "- %s", filepath.ToSlash(item.Path))
303+ if item.Branch != "" {
304+ fmt.Fprintf(&b, " [%s]", item.Branch)
305+ }
306+ state := item.BlockedBy
307+ if item.CleanupOK {
308+ state = "clean and merged; cleanup eligible"
309+ }
310+ if state != "" {
311+ fmt.Fprintf(&b, ": %s", state)
312+ }
313+ b.WriteByte('\n')
314+ }
315+ if plan.action == "cleanup" {
316+ b.WriteString("selected:\n")
317+ for _, item := range plan.selected {
318+ fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path))
319+ }
320+ }
321+ return b.String()
322+}
323+
324+func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResult {
325+ return core.ToolResult{
326+ Content: []provider.Content{provider.TextBlock{Text: plan.text}},
327+ Details: map[string]any{"state": state, "worktrees": plan.all},
328+ }
329+}
330+
331+func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool {
332+ if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) {
333+ return false
334+ }
335+ for index := range a.selected {
336+ if a.selected[index] != b.selected[index] {
337+ return false
338+ }
339+ }
340+ for index := range a.all {
341+ if a.all[index] != b.all[index] {
342+ return false
343+ }
344+ }
345+ return true
346+}
347+
348+func firstErrorLine(err error) string {
349+ line, _, _ := strings.Cut(err.Error(), "\n")
350+ return line
351+}
352+
353+func manageWorktreesGit(ctx context.Context, dir string, args ...string) (string, error) {
354+ return createWorktreeGitOutput(ctx, dir, args...)
355+}
A packages/agent/tools/manage_worktrees_test.go +142 -0
@@ -0,0 +1,142 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestManageWorktreesListsCleanupEligibility(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is required")
15+ }
16+ repo := initWorktreeTestRepo(t)
17+ mergedPath := filepath.Join(t.TempDir(), "merged")
18+ gitTestOutput(t, repo, "branch", "merged")
19+ gitTestOutput(t, repo, "worktree", "add", mergedPath, "merged")
20+ dirtyPath := filepath.Join(t.TempDir(), "dirty")
21+ gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", dirtyPath)
22+ if err := os.WriteFile(filepath.Join(dirtyPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+
26+ tool := newManageWorktreesTestTool(repo)
27+ result, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ got := resultText(result)
32+ for _, want := range []string{"repository worktrees", "merged", "cleanup eligible", "dirty", "uncommitted changes", "primary worktree"} {
33+ if !strings.Contains(got, want) {
34+ t.Fatalf("result missing %q:\n%s", want, got)
35+ }
36+ }
37+}
38+
39+func TestManageWorktreesCleanupRemovesOnlyExplicitCleanMergedWorktree(t *testing.T) {
40+ if _, err := exec.LookPath("git"); err != nil {
41+ t.Skip("git is required")
42+ }
43+ repo := initWorktreeTestRepo(t)
44+ path := filepath.Join(t.TempDir(), "merged")
45+ gitTestOutput(t, repo, "branch", "merged")
46+ gitTestOutput(t, repo, "worktree", "add", path, "merged")
47+ tool := newManageWorktreesTestTool(repo)
48+ args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})
49+
50+ if _, err := tool.Preview(context.Background(), args); err != nil {
51+ t.Fatal(err)
52+ }
53+ if _, err := os.Stat(path); err != nil {
54+ t.Fatalf("preview removed worktree: %v", err)
55+ }
56+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := os.Stat(path); !os.IsNotExist(err) {
60+ t.Fatalf("worktree remains: %v", err)
61+ }
62+ if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/merged"); got == "" {
63+ t.Fatal("cleanup unexpectedly removed branch")
64+ }
65+}
66+
67+func TestManageWorktreesRejectsDirtyCleanup(t *testing.T) {
68+ if _, err := exec.LookPath("git"); err != nil {
69+ t.Skip("git is required")
70+ }
71+ repo := initWorktreeTestRepo(t)
72+ path := filepath.Join(t.TempDir(), "dirty")
73+ gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", path)
74+ if err := os.WriteFile(filepath.Join(path, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
75+ t.Fatal(err)
76+ }
77+ tool := newManageWorktreesTestTool(repo)
78+ _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
79+ if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
80+ t.Fatalf("error = %v, want dirty-worktree rejection", err)
81+ }
82+}
83+
84+func TestWorktreeInventoryContextReportsRepositoryWorktrees(t *testing.T) {
85+ if _, err := exec.LookPath("git"); err != nil {
86+ t.Skip("git is required")
87+ }
88+ repo := initWorktreeTestRepo(t)
89+ path := filepath.Join(t.TempDir(), "merged")
90+ gitTestOutput(t, repo, "branch", "merged")
91+ gitTestOutput(t, repo, "worktree", "add", path, "merged")
92+
93+ contextText := WorktreeInventoryContext(context.Background(), repo)
94+ wantPath, err := canonicalOrParent(path)
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ for _, want := range []string{"[Repository worktrees]", filepath.ToSlash(wantPath), "worktree action=list"} {
99+ if !strings.Contains(contextText, want) {
100+ t.Fatalf("context missing %q:\n%s", want, contextText)
101+ }
102+ }
103+}
104+
105+func TestManageWorktreesRejectsIgnoredFilesCleanup(t *testing.T) {
106+ if _, err := exec.LookPath("git"); err != nil {
107+ t.Skip("git is required")
108+ }
109+ repo := initWorktreeTestRepo(t)
110+ path := filepath.Join(t.TempDir(), "ignored")
111+ gitTestOutput(t, repo, "worktree", "add", "-b", "ignored", path)
112+ if err := os.WriteFile(filepath.Join(path, ".gitignore"), []byte("local.env\n"), 0o644); err != nil {
113+ t.Fatal(err)
114+ }
115+ gitTestOutput(t, path, "add", ".gitignore")
116+ gitTestOutput(t, path, "commit", "-m", "ignore local file")
117+ gitTestOutput(t, repo, "merge", "--ff-only", "ignored")
118+ if err := os.WriteFile(filepath.Join(path, "local.env"), []byte("private"), 0o600); err != nil {
119+ t.Fatal(err)
120+ }
121+ tool := newManageWorktreesTestTool(repo)
122+ _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
123+ if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
124+ t.Fatalf("error = %v, want ignored-file rejection", err)
125+ }
126+}
127+
128+func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) {
129+ if _, err := exec.LookPath("git"); err != nil {
130+ t.Skip("git is required")
131+ }
132+ repo := initWorktreeTestRepo(t)
133+ tool := newManageWorktreesTestTool(repo)
134+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "cleanup"}), nil)
135+ if err == nil || !strings.Contains(err.Error(), "explicit path") {
136+ t.Fatalf("error = %v, want explicit-path rejection", err)
137+ }
138+}
139+
140+func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {
141+ return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
142+}
@@ -0,0 +1,142 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os"
6+ "os/exec"
7+ "path/filepath"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestManageWorktreesListsCleanupEligibility(t *testing.T) {
13+ if _, err := exec.LookPath("git"); err != nil {
14+ t.Skip("git is required")
15+ }
16+ repo := initWorktreeTestRepo(t)
17+ mergedPath := filepath.Join(t.TempDir(), "merged")
18+ gitTestOutput(t, repo, "branch", "merged")
19+ gitTestOutput(t, repo, "worktree", "add", mergedPath, "merged")
20+ dirtyPath := filepath.Join(t.TempDir(), "dirty")
21+ gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", dirtyPath)
22+ if err := os.WriteFile(filepath.Join(dirtyPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
23+ t.Fatal(err)
24+ }
25+
26+ tool := newManageWorktreesTestTool(repo)
27+ result, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ got := resultText(result)
32+ for _, want := range []string{"repository worktrees", "merged", "cleanup eligible", "dirty", "uncommitted changes", "primary worktree"} {
33+ if !strings.Contains(got, want) {
34+ t.Fatalf("result missing %q:\n%s", want, got)
35+ }
36+ }
37+}
38+
39+func TestManageWorktreesCleanupRemovesOnlyExplicitCleanMergedWorktree(t *testing.T) {
40+ if _, err := exec.LookPath("git"); err != nil {
41+ t.Skip("git is required")
42+ }
43+ repo := initWorktreeTestRepo(t)
44+ path := filepath.Join(t.TempDir(), "merged")
45+ gitTestOutput(t, repo, "branch", "merged")
46+ gitTestOutput(t, repo, "worktree", "add", path, "merged")
47+ tool := newManageWorktreesTestTool(repo)
48+ args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})
49+
50+ if _, err := tool.Preview(context.Background(), args); err != nil {
51+ t.Fatal(err)
52+ }
53+ if _, err := os.Stat(path); err != nil {
54+ t.Fatalf("preview removed worktree: %v", err)
55+ }
56+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := os.Stat(path); !os.IsNotExist(err) {
60+ t.Fatalf("worktree remains: %v", err)
61+ }
62+ if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/merged"); got == "" {
63+ t.Fatal("cleanup unexpectedly removed branch")
64+ }
65+}
66+
67+func TestManageWorktreesRejectsDirtyCleanup(t *testing.T) {
68+ if _, err := exec.LookPath("git"); err != nil {
69+ t.Skip("git is required")
70+ }
71+ repo := initWorktreeTestRepo(t)
72+ path := filepath.Join(t.TempDir(), "dirty")
73+ gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", path)
74+ if err := os.WriteFile(filepath.Join(path, "dirty.txt"), []byte("dirty"), 0o644); err != nil {
75+ t.Fatal(err)
76+ }
77+ tool := newManageWorktreesTestTool(repo)
78+ _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
79+ if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
80+ t.Fatalf("error = %v, want dirty-worktree rejection", err)
81+ }
82+}
83+
84+func TestWorktreeInventoryContextReportsRepositoryWorktrees(t *testing.T) {
85+ if _, err := exec.LookPath("git"); err != nil {
86+ t.Skip("git is required")
87+ }
88+ repo := initWorktreeTestRepo(t)
89+ path := filepath.Join(t.TempDir(), "merged")
90+ gitTestOutput(t, repo, "branch", "merged")
91+ gitTestOutput(t, repo, "worktree", "add", path, "merged")
92+
93+ contextText := WorktreeInventoryContext(context.Background(), repo)
94+ wantPath, err := canonicalOrParent(path)
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ for _, want := range []string{"[Repository worktrees]", filepath.ToSlash(wantPath), "worktree action=list"} {
99+ if !strings.Contains(contextText, want) {
100+ t.Fatalf("context missing %q:\n%s", want, contextText)
101+ }
102+ }
103+}
104+
105+func TestManageWorktreesRejectsIgnoredFilesCleanup(t *testing.T) {
106+ if _, err := exec.LookPath("git"); err != nil {
107+ t.Skip("git is required")
108+ }
109+ repo := initWorktreeTestRepo(t)
110+ path := filepath.Join(t.TempDir(), "ignored")
111+ gitTestOutput(t, repo, "worktree", "add", "-b", "ignored", path)
112+ if err := os.WriteFile(filepath.Join(path, ".gitignore"), []byte("local.env\n"), 0o644); err != nil {
113+ t.Fatal(err)
114+ }
115+ gitTestOutput(t, path, "add", ".gitignore")
116+ gitTestOutput(t, path, "commit", "-m", "ignore local file")
117+ gitTestOutput(t, repo, "merge", "--ff-only", "ignored")
118+ if err := os.WriteFile(filepath.Join(path, "local.env"), []byte("private"), 0o600); err != nil {
119+ t.Fatal(err)
120+ }
121+ tool := newManageWorktreesTestTool(repo)
122+ _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}))
123+ if err == nil || !strings.Contains(err.Error(), "uncommitted changes") {
124+ t.Fatalf("error = %v, want ignored-file rejection", err)
125+ }
126+}
127+
128+func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) {
129+ if _, err := exec.LookPath("git"); err != nil {
130+ t.Skip("git is required")
131+ }
132+ repo := initWorktreeTestRepo(t)
133+ tool := newManageWorktreesTestTool(repo)
134+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "cleanup"}), nil)
135+ if err == nil || !strings.Contains(err.Error(), "explicit path") {
136+ t.Fatalf("error = %v, want explicit-path rejection", err)
137+ }
138+}
139+
140+func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool {
141+ return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
142+}
M packages/agent/tools/read.go +1 -1
@@ -1,5 +1,5 @@
11 // Package tools implements zut's built-in tools: read, write, edit, bash,
2-// create_worktree, grep, lsp, web search, and autonomous goal updates.
2+// worktree management, grep, lsp, web search, and autonomous goal updates.
33 package tools
44
55 import (
@@ -1,5 +1,5 @@
1 // Package tools implements zut's built-in tools: read, write, edit, bash,1 // Package tools implements zut's built-in tools: read, write, edit, bash,
2-// create_worktree, grep, lsp, web search, and autonomous goal updates.2+// worktree management, grep, lsp, web search, and autonomous goal updates.
3 package tools3 package tools
4 4
5 import (5 import (
A packages/agent/tools/worktree.go +130 -0
@@ -0,0 +1,130 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/core"
11+)
12+
13+// WorktreeTool exposes persistent worktree creation and lifecycle management
14+// through one public tool while keeping the implementations focused internally.
15+type WorktreeTool struct {
16+ Create *CreateWorktreeTool
17+ Manage *ManageWorktreesTool
18+}
19+
20+const worktreeSchema = `{
21+ "type":"object",
22+ "properties":{
23+ "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."},
24+ "branch":{"type":"string","description":"New branch name. Required for create."},
25+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."}
26+ },
27+ "required":["action"]
28+}`
29+
30+func (t *WorktreeTool) Name() string { return "worktree" }
31+func (t *WorktreeTool) Description() string {
32+ return "Create, list, and safely clean up Git worktrees."
33+}
34+func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) }
35+
36+func (t *WorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
37+ action, delegated, err := parseWorktreeAction(raw)
38+ if err != nil {
39+ return core.ToolResult{}, err
40+ }
41+ switch action {
42+ case "create":
43+ if t.Create == nil {
44+ return core.ToolResult{}, errors.New("worktree: create action is unavailable")
45+ }
46+ result, err := t.Create.Preview(ctx, delegated)
47+ return result, worktreeError(err)
48+ case "list", "cleanup":
49+ if t.Manage == nil {
50+ return core.ToolResult{}, errors.New("worktree: management actions are unavailable")
51+ }
52+ result, err := t.Manage.Preview(ctx, delegated)
53+ return result, worktreeError(err)
54+ default:
55+ return core.ToolResult{}, errors.New("worktree: unknown action")
56+ }
57+}
58+
59+func (t *WorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
60+ action, delegated, err := parseWorktreeAction(raw)
61+ if err != nil {
62+ return core.ToolResult{}, err
63+ }
64+ switch action {
65+ case "create":
66+ if t.Create == nil {
67+ return core.ToolResult{}, errors.New("worktree: create action is unavailable")
68+ }
69+ result, err := t.Create.Execute(ctx, delegated, progress)
70+ return result, worktreeError(err)
71+ case "list", "cleanup":
72+ if t.Manage == nil {
73+ return core.ToolResult{}, errors.New("worktree: management actions are unavailable")
74+ }
75+ result, err := t.Manage.Execute(ctx, delegated, progress)
76+ return result, worktreeError(err)
77+ default:
78+ return core.ToolResult{}, errors.New("worktree: unknown action")
79+ }
80+}
81+
82+func parseWorktreeAction(raw json.RawMessage) (string, json.RawMessage, error) {
83+ var values map[string]json.RawMessage
84+ if err := json.Unmarshal(raw, &values); err != nil {
85+ return "", nil, err
86+ }
87+ var action string
88+ if err := json.Unmarshal(values["action"], &action); err != nil || action == "" {
89+ return "", nil, errors.New("worktree: action is required")
90+ }
91+ delete(values, "action")
92+ if action == "list" || action == "cleanup" {
93+ encoded, err := json.Marshal(map[string]any{"action": action, "paths": rawField(values, "paths")})
94+ if err != nil {
95+ return "", nil, err
96+ }
97+ if action == "list" {
98+ encoded = json.RawMessage(`{"action":"list"}`)
99+ }
100+ return action, encoded, nil
101+ }
102+ encoded, err := json.Marshal(map[string]any{"branch": rawField(values, "branch")})
103+ return action, encoded, err
104+}
105+
106+func worktreeError(err error) error {
107+ if err == nil {
108+ return nil
109+ }
110+ message := err.Error()
111+ for _, prefix := range []string{"create_worktree: ", "manage_worktrees: "} {
112+ if strings.HasPrefix(message, prefix) {
113+ message = strings.TrimPrefix(message, prefix)
114+ break
115+ }
116+ }
117+ return fmt.Errorf("worktree: %s", message)
118+}
119+
120+func rawField(values map[string]json.RawMessage, key string) any {
121+ value, ok := values[key]
122+ if !ok {
123+ return nil
124+ }
125+ var decoded any
126+ if json.Unmarshal(value, &decoded) != nil {
127+ return nil
128+ }
129+ return decoded
130+}
@@ -0,0 +1,130 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "strings"
9+
10+ "github.com/bnema/zut/packages/core"
11+)
12+
13+// WorktreeTool exposes persistent worktree creation and lifecycle management
14+// through one public tool while keeping the implementations focused internally.
15+type WorktreeTool struct {
16+ Create *CreateWorktreeTool
17+ Manage *ManageWorktreesTool
18+}
19+
20+const worktreeSchema = `{
21+ "type":"object",
22+ "properties":{
23+ "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."},
24+ "branch":{"type":"string","description":"New branch name. Required for create."},
25+ "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."}
26+ },
27+ "required":["action"]
28+}`
29+
30+func (t *WorktreeTool) Name() string { return "worktree" }
31+func (t *WorktreeTool) Description() string {
32+ return "Create, list, and safely clean up Git worktrees."
33+}
34+func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) }
35+
36+func (t *WorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
37+ action, delegated, err := parseWorktreeAction(raw)
38+ if err != nil {
39+ return core.ToolResult{}, err
40+ }
41+ switch action {
42+ case "create":
43+ if t.Create == nil {
44+ return core.ToolResult{}, errors.New("worktree: create action is unavailable")
45+ }
46+ result, err := t.Create.Preview(ctx, delegated)
47+ return result, worktreeError(err)
48+ case "list", "cleanup":
49+ if t.Manage == nil {
50+ return core.ToolResult{}, errors.New("worktree: management actions are unavailable")
51+ }
52+ result, err := t.Manage.Preview(ctx, delegated)
53+ return result, worktreeError(err)
54+ default:
55+ return core.ToolResult{}, errors.New("worktree: unknown action")
56+ }
57+}
58+
59+func (t *WorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
60+ action, delegated, err := parseWorktreeAction(raw)
61+ if err != nil {
62+ return core.ToolResult{}, err
63+ }
64+ switch action {
65+ case "create":
66+ if t.Create == nil {
67+ return core.ToolResult{}, errors.New("worktree: create action is unavailable")
68+ }
69+ result, err := t.Create.Execute(ctx, delegated, progress)
70+ return result, worktreeError(err)
71+ case "list", "cleanup":
72+ if t.Manage == nil {
73+ return core.ToolResult{}, errors.New("worktree: management actions are unavailable")
74+ }
75+ result, err := t.Manage.Execute(ctx, delegated, progress)
76+ return result, worktreeError(err)
77+ default:
78+ return core.ToolResult{}, errors.New("worktree: unknown action")
79+ }
80+}
81+
82+func parseWorktreeAction(raw json.RawMessage) (string, json.RawMessage, error) {
83+ var values map[string]json.RawMessage
84+ if err := json.Unmarshal(raw, &values); err != nil {
85+ return "", nil, err
86+ }
87+ var action string
88+ if err := json.Unmarshal(values["action"], &action); err != nil || action == "" {
89+ return "", nil, errors.New("worktree: action is required")
90+ }
91+ delete(values, "action")
92+ if action == "list" || action == "cleanup" {
93+ encoded, err := json.Marshal(map[string]any{"action": action, "paths": rawField(values, "paths")})
94+ if err != nil {
95+ return "", nil, err
96+ }
97+ if action == "list" {
98+ encoded = json.RawMessage(`{"action":"list"}`)
99+ }
100+ return action, encoded, nil
101+ }
102+ encoded, err := json.Marshal(map[string]any{"branch": rawField(values, "branch")})
103+ return action, encoded, err
104+}
105+
106+func worktreeError(err error) error {
107+ if err == nil {
108+ return nil
109+ }
110+ message := err.Error()
111+ for _, prefix := range []string{"create_worktree: ", "manage_worktrees: "} {
112+ if strings.HasPrefix(message, prefix) {
113+ message = strings.TrimPrefix(message, prefix)
114+ break
115+ }
116+ }
117+ return fmt.Errorf("worktree: %s", message)
118+}
119+
120+func rawField(values map[string]json.RawMessage, key string) any {
121+ value, ok := values[key]
122+ if !ok {
123+ return nil
124+ }
125+ var decoded any
126+ if json.Unmarshal(value, &decoded) != nil {
127+ return nil
128+ }
129+ return decoded
130+}
A packages/agent/tools/worktree_test.go +37 -0
@@ -0,0 +1,37 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestWorktreeToolDispatchesListAndCreate(t *testing.T) {
11+ if _, err := exec.LookPath("git"); err != nil {
12+ t.Skip("git is required")
13+ }
14+ repo := initWorktreeTestRepo(t)
15+ root := t.TempDir()
16+ tool := &WorktreeTool{
17+ Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root},
18+ Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)},
19+ }
20+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil); err != nil {
21+ t.Fatal(err)
22+ }
23+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feature/unified"}), nil); err != nil {
24+ t.Fatal(err)
25+ }
26+ matches, err := filepath.Glob(filepath.Join(root, "*", "feature", "unified"))
27+ if err != nil || len(matches) != 1 {
28+ t.Fatalf("created paths = %v, error = %v", matches, err)
29+ }
30+}
31+
32+func TestWorktreeToolRequiresAction(t *testing.T) {
33+ tool := &WorktreeTool{Create: &CreateWorktreeTool{}, Manage: &ManageWorktreesTool{}}
34+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"branch": "missing-action"}), nil); err == nil {
35+ t.Fatal("missing action was accepted")
36+ }
37+}
@@ -0,0 +1,37 @@
1+package tools
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "path/filepath"
7+ "testing"
8+)
9+
10+func TestWorktreeToolDispatchesListAndCreate(t *testing.T) {
11+ if _, err := exec.LookPath("git"); err != nil {
12+ t.Skip("git is required")
13+ }
14+ repo := initWorktreeTestRepo(t)
15+ root := t.TempDir()
16+ tool := &WorktreeTool{
17+ Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root},
18+ Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)},
19+ }
20+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil); err != nil {
21+ t.Fatal(err)
22+ }
23+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feature/unified"}), nil); err != nil {
24+ t.Fatal(err)
25+ }
26+ matches, err := filepath.Glob(filepath.Join(root, "*", "feature", "unified"))
27+ if err != nil || len(matches) != 1 {
28+ t.Fatalf("created paths = %v, error = %v", matches, err)
29+ }
30+}
31+
32+func TestWorktreeToolRequiresAction(t *testing.T) {
33+ tool := &WorktreeTool{Create: &CreateWorktreeTool{}, Manage: &ManageWorktreesTool{}}
34+ if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"branch": "missing-action"}), nil); err == nil {
35+ t.Fatal("missing action was accepted")
36+ }
37+}
M packages/core/agent.go +9 -1
@@ -101,6 +101,10 @@ type Agent struct {
101101 // collection into one lifecycle round trip.
102102 BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)
103103
104+ // HostContext returns bounded host-owned context for the current turn. It is
105+ // composed with extension context instead of replacing extension hooks.
106+ HostContext func(ctx context.Context) string
107+
104108 // CommitToolResult, if set, commits extension/tool metadata immediately
105109 // after each tool call completes. Returning an error converts the result to
106110 // an error before it is appended to the transcript or sent to the provider.
@@ -666,7 +670,11 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
666670 }
667671 }
668672
669- a.appendDynamicContext(turnContext)
673+ hostContext := ""
674+ if a.HostContext != nil {
675+ hostContext = a.HostContext(ctx)
676+ }
677+ a.appendDynamicContext(hostContext, turnContext)
670678
671679 var (
672680 stop provider.StopReason
@@ -101,6 +101,10 @@ type Agent struct {
101 // collection into one lifecycle round trip.101 // collection into one lifecycle round trip.
102 BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)102 BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)
103 103
104+ // HostContext returns bounded host-owned context for the current turn. It is
105+ // composed with extension context instead of replacing extension hooks.
106+ HostContext func(ctx context.Context) string
107+
104 // CommitToolResult, if set, commits extension/tool metadata immediately108 // CommitToolResult, if set, commits extension/tool metadata immediately
105 // after each tool call completes. Returning an error converts the result to109 // after each tool call completes. Returning an error converts the result to
106 // an error before it is appended to the transcript or sent to the provider.110 // an error before it is appended to the transcript or sent to the provider.
@@ -666,7 +670,11 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
666 }670 }
667 }671 }
668 672
669- a.appendDynamicContext(turnContext)673+ hostContext := ""
674+ if a.HostContext != nil {
675+ hostContext = a.HostContext(ctx)
676+ }
677+ a.appendDynamicContext(hostContext, turnContext)
670 678
671 var (679 var (
672 stop provider.StopReason680 stop provider.StopReason
M packages/core/dynamic_context.go +5 -2
@@ -13,11 +13,14 @@ const internalContextMarker = "internal_context"
1313 // only when its canonical value changes. It is deliberately separate from the
1414 // stable top-level instructions so changing time or extension state cannot
1515 // rewrite the instruction cache prefix.
16-func (a *Agent) appendDynamicContext(turnContext string) {
17- parts := make([]string, 0, 2)
16+func (a *Agent) appendDynamicContext(hostContext, turnContext string) {
17+ parts := make([]string, 0, 3)
1818 if timeContext := a.providerTimeContext().developerText(); timeContext != "" {
1919 parts = append(parts, timeContext)
2020 }
21+ if hostContext = boundedTurnContext(hostContext); hostContext != "" {
22+ parts = append(parts, hostContext)
23+ }
2124 if turnContext = boundedTurnContext(turnContext); turnContext != "" {
2225 parts = append(parts, "[Extension context]\n"+turnContext)
2326 }
@@ -13,11 +13,14 @@ const internalContextMarker = "internal_context"
13 // only when its canonical value changes. It is deliberately separate from the13 // only when its canonical value changes. It is deliberately separate from the
14 // stable top-level instructions so changing time or extension state cannot14 // stable top-level instructions so changing time or extension state cannot
15 // rewrite the instruction cache prefix.15 // rewrite the instruction cache prefix.
16-func (a *Agent) appendDynamicContext(turnContext string) {16+func (a *Agent) appendDynamicContext(hostContext, turnContext string) {
17- parts := make([]string, 0, 2)17+ parts := make([]string, 0, 3)
18 if timeContext := a.providerTimeContext().developerText(); timeContext != "" {18 if timeContext := a.providerTimeContext().developerText(); timeContext != "" {
19 parts = append(parts, timeContext)19 parts = append(parts, timeContext)
20 }20 }
21+ if hostContext = boundedTurnContext(hostContext); hostContext != "" {
22+ parts = append(parts, hostContext)
23+ }
21 if turnContext = boundedTurnContext(turnContext); turnContext != "" {24 if turnContext = boundedTurnContext(turnContext); turnContext != "" {
22 parts = append(parts, "[Extension context]\n"+turnContext)25 parts = append(parts, "[Extension context]\n"+turnContext)
23 }26 }
M packages/core/dynamic_context_test.go +25 -0
@@ -2,11 +2,36 @@ package core
22
33 import (
44 "context"
5+ "strings"
56 "testing"
67
78 "github.com/bnema/zut/packages/provider"
89 )
910
11+func TestDynamicContextComposesHostAndExtensionContext(t *testing.T) {
12+ agent := NewAgent(&compactLifecycleClient{}, "test-model", "system", Registry{})
13+ agent.HostContext = func(context.Context) string { return "host inventory" }
14+ agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
15+ return true, "", "extension data"
16+ }
17+ if err := agent.Prompt(context.Background(), "user message", nil, nil); err != nil {
18+ t.Fatal(err)
19+ }
20+ messages := agent.Messages()
21+ if len(messages) < 2 {
22+ t.Fatalf("messages = %#v", messages)
23+ }
24+ text, ok := messages[0].Content[0].(provider.TextBlock)
25+ if !ok {
26+ t.Fatalf("context content = %T", messages[0].Content[0])
27+ }
28+ for _, want := range []string{"host inventory", "[Extension context]", "extension data"} {
29+ if !strings.Contains(text.Text, want) {
30+ t.Fatalf("dynamic context missing %q: %q", want, text.Text)
31+ }
32+ }
33+}
34+
1035 func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) {
1136 client := &compactLifecycleClient{}
1237 agent := NewAgent(client, "test-model", "system", Registry{})
@@ -2,11 +2,36 @@ package core
2 2
3 import (3 import (
4 "context"4 "context"
5+ "strings"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/zut/packages/provider"8 "github.com/bnema/zut/packages/provider"
8 )9 )
9 10
11+func TestDynamicContextComposesHostAndExtensionContext(t *testing.T) {
12+ agent := NewAgent(&compactLifecycleClient{}, "test-model", "system", Registry{})
13+ agent.HostContext = func(context.Context) string { return "host inventory" }
14+ agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
15+ return true, "", "extension data"
16+ }
17+ if err := agent.Prompt(context.Background(), "user message", nil, nil); err != nil {
18+ t.Fatal(err)
19+ }
20+ messages := agent.Messages()
21+ if len(messages) < 2 {
22+ t.Fatalf("messages = %#v", messages)
23+ }
24+ text, ok := messages[0].Content[0].(provider.TextBlock)
25+ if !ok {
26+ t.Fatalf("context content = %T", messages[0].Content[0])
27+ }
28+ for _, want := range []string{"host inventory", "[Extension context]", "extension data"} {
29+ if !strings.Contains(text.Text, want) {
30+ t.Fatalf("dynamic context missing %q: %q", want, text.Text)
31+ }
32+ }
33+}
34+
10 func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) {35 func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) {
11 client := &compactLifecycleClient{}36 client := &compactLifecycleClient{}
12 agent := NewAgent(client, "test-model", "system", Registry{})37 agent := NewAgent(client, "test-model", "system", Registry{})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close