detached b9225b3

feat(tools): add persistent worktree creation

commit
b9225b3416331bd1d9678ffec56e94418497e284
author
brice · · 2026-08-06 15:00 UTC
committer
brice · · 2026-08-06 16:42 UTC
parent
83c2c60
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1809 -22
M README.md +7 -6
@@ -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-- five core built-in tools (read, write, edit, bash, and lsp); the conditional `skill` tool is available when skills are enabled.
12+- six core built-in tools (read, write, edit, bash, create_worktree, and lsp); the conditional `skill` tool is available when skills are enabled.
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).
@@ -233,9 +233,10 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
233233 - `write`: create or overwrite files, making parent directories as needed.
234234 - `edit`: one or more exact-match replacements in an existing file.
235235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. 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.
236+- `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.
236237 - `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. See [docs/lsp.md](docs/lsp.md).
237238
238-When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd.
239+When the sandbox is on (see `/jail`), filesystem tools 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.
239240
240241 ## Modes
241242
@@ -424,7 +425,7 @@ zut also auto-compacts in the background: after any turn that reaches the config
424425
425426 ### `/jail`
426427
427-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside 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.
428+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` 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.
428429
429430 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
430431
@@ -792,7 +793,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
792793
793794 ## Tool rendering
794795
795-By default each tool call (bash, read, write, edit) 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.
796+By default each tool call (bash, read, write, edit, create_worktree, 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.
796797
797798 Set the `tool_render` key in `$ZUT_HOME/config.json`:
798799
@@ -881,7 +882,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
881882 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
882883 | `ctrl+l` | Redraw the screen. |
883884 | `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. |
884-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |
885+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, and lsp outputs over ~12 lines). |
885886 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
886887 | `@` | Open the file picker. Browse files and directories in the working directory. |
887888
@@ -1064,7 +1065,7 @@ packages/agent/extproto/ extension wire-format types
10641065 packages/agent/modes/ interactive tui, print, json, dialogs
10651066 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10661067 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1067-packages/agent/tools/ read, write, edit, bash, lsp, sandbox
1068+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, sandbox
10681069 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10691070 packages/agent/subagents/ named profiles, supervisor, and background runtime
10701071 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-- five core built-in tools (read, write, edit, bash, and lsp); the conditional `skill` tool is available when skills are enabled.12+- six core built-in tools (read, write, edit, bash, create_worktree, and lsp); the conditional `skill` tool is available when skills are enabled.
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).
@@ -233,9 +233,10 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
233 - `write`: create or overwrite files, making parent directories as needed.233 - `write`: create or overwrite files, making parent directories as needed.
234 - `edit`: one or more exact-match replacements in an existing file.234 - `edit`: one or more exact-match replacements in an existing file.
235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. 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.235 - `bash`: run a command in the session cwd with merged stdout/stderr and a timeout. 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.
236+- `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.
236 - `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. See [docs/lsp.md](docs/lsp.md).237 - `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. See [docs/lsp.md](docs/lsp.md).
237 238
238-When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd.239+When the sandbox is on (see `/jail`), filesystem tools 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.
239 240
240 ## Modes241 ## Modes
241 242
@@ -424,7 +425,7 @@ zut also auto-compacts in the background: after any turn that reaches the config
424 425
425 ### `/jail`426 ### `/jail`
426 427
427-Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` resolve their target path (including through symlinks) and refuse anything outside 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.428+Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, and `edit` 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.
428 429
429 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.430 This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox.
430 431
@@ -792,7 +793,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st
792 793
793 ## Tool rendering794 ## Tool rendering
794 795
795-By default each tool call (bash, read, write, edit) 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.796+By default each tool call (bash, read, write, edit, create_worktree, 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.
796 797
797 Set the `tool_render` key in `$ZUT_HOME/config.json`:798 Set the `tool_render` key in `$ZUT_HOME/config.json`:
798 799
@@ -881,7 +882,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
881 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |882 | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. |
882 | `ctrl+l` | Redraw the screen. |883 | `ctrl+l` | Redraw the screen. |
883 | `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. |884 | `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. |
884-| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). |885+| `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, and lsp outputs over ~12 lines). |
885 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |886 | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. |
886 | `@` | Open the file picker. Browse files and directories in the working directory. |887 | `@` | Open the file picker. Browse files and directories in the working directory. |
887 888
@@ -1064,7 +1065,7 @@ packages/agent/extproto/ extension wire-format types
1064 packages/agent/modes/ interactive tui, print, json, dialogs1065 packages/agent/modes/ interactive tui, print, json, dialogs
1065 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)1066 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
1066 packages/agent/modes/telegram/ telegram adapter, api client, daemon1067 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1067-packages/agent/tools/ read, write, edit, bash, lsp, sandbox1068+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, sandbox
1068 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1069 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1069 packages/agent/subagents/ named profiles, supervisor, and background runtime1070 packages/agent/subagents/ named profiles, supervisor, and background runtime
1070 packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk)1071 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`, `skill`). Conflicts are silently shadowed by
228+`write`, `edit`, `bash`, `create_worktree`, `lsp`, `skill`). Conflicts are silently shadowed by
229229 the built-in.
230230
231231 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
@@ -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`, `skill`). Conflicts are silently shadowed by228+`write`, `edit`, `bash`, `create_worktree`, `lsp`, `skill`). Conflicts are silently shadowed by
229 the built-in.229 the built-in.
230 230
231 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:231 Set `"deferred": true` to register a tool without advertising its definition initially. A loader tool can activate registered deferred tools by returning their names in `activate_tools`:
M docs/subagents.md +1 -1
@@ -41,7 +41,7 @@ Supported metadata:
4141 |---|---|
4242 | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |
4343 | `description` | Short description shown to the main agent. |
44-| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
44+| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
4545 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
4646 | `provider` | Optional separate provider ID for a model without a provider prefix. |
4747 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
@@ -41,7 +41,7 @@ Supported metadata:
41 |---|---|41 |---|---|
42 | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |42 | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. |
43 | `description` | Short description shown to the main agent. |43 | `description` | Short description shown to the main agent. |
44-| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |44+| `tools` | Comma-separated or list-form tool names. zut enforces its built-in `read`, `write`, `edit`, `bash`, `create_worktree`, and `lsp` registry; the conditional `skill` tool is available when skills are enabled. Unknown names do not grant capabilities. |
45 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |45 | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. |
46 | `provider` | Optional separate provider ID for a model without a provider prefix. |46 | `provider` | Optional separate provider ID for a model without a provider prefix. |
47 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |47 | `thinking` / `reasoning` | Optional reasoning level: `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, or `max`. |
M docs/zutfiles.md +19 -1
@@ -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`, and `edit` tools. Empty or omitted scopes deny the operation.
306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_worktree` tools. Empty or omitted scopes deny the operation.
307307
308308 Two variables are available in filesystem scopes:
309309
@@ -373,6 +373,24 @@ 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`.
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:
379+
380+```json
381+{
382+ "permissions": {
383+ "fs": {
384+ "read": ["${workspace}"],
385+ "write": ["${workspace}"]
386+ },
387+ "bash": { "mode": "allowlist", "allow": ["git"] }
388+ }
389+}
390+```
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.
393+
376394 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.
377395
378396 ## Bash permissions
@@ -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`, and `edit` tools. Empty or omitted scopes deny the operation.306+Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, and `create_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,6 +373,24 @@ 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`.
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:
379+
380+```json
381+{
382+ "permissions": {
383+ "fs": {
384+ "read": ["${workspace}"],
385+ "write": ["${workspace}"]
386+ },
387+ "bash": { "mode": "allowlist", "allow": ["git"] }
388+ }
389+}
390+```
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.
393+
376 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.394 If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory.
377 395
378 ## Bash permissions396 ## Bash permissions
M packages/agent/build.go +8 -5
@@ -1114,6 +1114,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
11141114 v.Sandbox = s
11151115 case *tools.BashTool:
11161116 v.Sandbox = s
1117+ case *tools.CreateWorktreeTool:
1118+ v.Sandbox = s
11171119 case *tools.LSPTool:
11181120 v.Sandbox = s
11191121 }
@@ -1145,10 +1147,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
11451147 manager = lsp.NewManagerWithOptions(options)
11461148 }
11471149 all := map[string]core.Tool{
1148- "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1149- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1150- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1151- "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1150+ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1151+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1152+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1153+ "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1154+ "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
11521155 }
11531156 if manager != nil {
11541157 lspTool := tools.NewLSPTool(cwd, manager)
@@ -1219,7 +1222,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
12191222 }
12201223
12211224 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1222- order := []string{"read", "write", "edit", "bash", "lsp"}
1225+ order := []string{"read", "write", "edit", "bash", "create_worktree", "lsp"}
12231226 var out []ToolSummary
12241227 for _, name := range order {
12251228 if t, ok := reg[name]; ok {
@@ -1114,6 +1114,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1114 v.Sandbox = s1114 v.Sandbox = s
1115 case *tools.BashTool:1115 case *tools.BashTool:
1116 v.Sandbox = s1116 v.Sandbox = s
1117+ case *tools.CreateWorktreeTool:
1118+ v.Sandbox = s
1117 case *tools.LSPTool:1119 case *tools.LSPTool:
1118 v.Sandbox = s1120 v.Sandbox = s
1119 }1121 }
@@ -1145,10 +1147,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
1145 manager = lsp.NewManagerWithOptions(options)1147 manager = lsp.NewManagerWithOptions(options)
1146 }1148 }
1147 all := map[string]core.Tool{1149 all := map[string]core.Tool{
1148- "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},1150+ "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1149- "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},1151+ "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite},
1150- "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},1152+ "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
1151- "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},1153+ "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1154+ "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1152 }1155 }
1153 if manager != nil {1156 if manager != nil {
1154 lspTool := tools.NewLSPTool(cwd, manager)1157 lspTool := tools.NewLSPTool(cwd, manager)
@@ -1219,7 +1222,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1219 }1222 }
1220 1223
1221 func toolSummaries(reg core.Registry, args Args) []ToolSummary {1224 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1222- order := []string{"read", "write", "edit", "bash", "lsp"}1225+ order := []string{"read", "write", "edit", "bash", "create_worktree", "lsp"}
1223 var out []ToolSummary1226 var out []ToolSummary
1224 for _, name := range order {1227 for _, name := range order {
1225 if t, ok := reg[name]; ok {1228 if t, ok := reg[name]; ok {
M packages/agent/build_test.go +48 -3
@@ -80,7 +80,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
8080
8181 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
8282 root := t.TempDir()
83- registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)
83+ sandbox := tools.NewSandbox(root)
84+ registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
8485 lspTool, ok := registry["lsp"].(*tools.LSPTool)
8586 if !ok || lspTool.Manager == nil {
8687 t.Fatalf("lsp tool = %#v", registry["lsp"])
@@ -89,6 +90,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
8990 if _, ok := registry["lsp"]; !ok {
9091 t.Fatal("default registry does not include lsp")
9192 }
93+ worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
94+ if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
95+ t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
96+ }
9297 write, ok := registry["write"].(*tools.WriteTool)
9398 if !ok || write.LSP == nil || !write.LSPDiagnostics {
9499 t.Fatalf("write tool LSP wiring = %#v", write)
@@ -97,13 +102,53 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
97102 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
98103 t.Fatalf("edit tool LSP wiring = %#v", edit)
99104 }
100- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {
101- t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))
105+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 5 {
106+ t.Fatalf("LSP-disabled registry has %d tools, want 5", len(disabled))
102107 }
103108 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
104109 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
105110 t.Fatalf("read-only registry = %#v", readOnly)
106111 }
112+ worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
113+ if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
114+ t.Fatalf("worktree-only registry = %#v", worktreeOnly)
115+ }
116+}
117+
118+func TestToolSummariesIncludeCreateWorktree(t *testing.T) {
119+ root := t.TempDir()
120+ registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
121+
122+ summaries := toolSummaries(registry, Args{})
123+ want := []string{"read", "write", "edit", "bash", "create_worktree"}
124+ if len(summaries) != len(want) {
125+ t.Fatalf("tool summaries = %#v, want %v", summaries, want)
126+ }
127+ for index, summary := range summaries {
128+ if summary.Name != want[index] {
129+ t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
130+ }
131+ }
132+}
133+
134+func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
135+ root := t.TempDir()
136+ previous := tools.NewSandbox(root)
137+ shared := tools.NewSandbox(root)
138+ worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
139+ resolved := &Resolved{
140+ Sandbox: previous,
141+ ToolRegistry: core.NewRegistry(worktree),
142+ }
143+
144+ resolved.UseSandbox(shared)
145+
146+ if resolved.Sandbox != shared {
147+ t.Fatal("resolved sandbox was not replaced")
148+ }
149+ if worktree.Sandbox != shared {
150+ t.Fatal("worktree tool sandbox was not replaced")
151+ }
107152 }
108153
109154 func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
@@ -80,7 +80,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) {
80 80
81 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {81 func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
82 root := t.TempDir()82 root := t.TempDir()
83- registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), true, true, false)83+ sandbox := tools.NewSandbox(root)
84+ registry := buildToolRegistry(Args{}, root, sandbox, true, true, false)
84 lspTool, ok := registry["lsp"].(*tools.LSPTool)85 lspTool, ok := registry["lsp"].(*tools.LSPTool)
85 if !ok || lspTool.Manager == nil {86 if !ok || lspTool.Manager == nil {
86 t.Fatalf("lsp tool = %#v", registry["lsp"])87 t.Fatalf("lsp tool = %#v", registry["lsp"])
@@ -89,6 +90,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
89 if _, ok := registry["lsp"]; !ok {90 if _, ok := registry["lsp"]; !ok {
90 t.Fatal("default registry does not include lsp")91 t.Fatal("default registry does not include lsp")
91 }92 }
93+ worktree, ok := registry["create_worktree"].(*tools.CreateWorktreeTool)
94+ if !ok || worktree.CWD != root || worktree.Sandbox != sandbox {
95+ t.Fatalf("default registry worktree tool = %#v", registry["create_worktree"])
96+ }
92 write, ok := registry["write"].(*tools.WriteTool)97 write, ok := registry["write"].(*tools.WriteTool)
93 if !ok || write.LSP == nil || !write.LSPDiagnostics {98 if !ok || write.LSP == nil || !write.LSPDiagnostics {
94 t.Fatalf("write tool LSP wiring = %#v", write)99 t.Fatalf("write tool LSP wiring = %#v", write)
@@ -97,13 +102,53 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
97 if !ok || edit.LSP == nil || edit.LSPDiagnostics {102 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
98 t.Fatalf("edit tool LSP wiring = %#v", edit)103 t.Fatalf("edit tool LSP wiring = %#v", edit)
99 }104 }
100- if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 4 {105+ if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 5 {
101- t.Fatalf("LSP-disabled registry has %d tools, want 4", len(disabled))106+ t.Fatalf("LSP-disabled registry has %d tools, want 5", len(disabled))
102 }107 }
103 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)108 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
104 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {109 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
105 t.Fatalf("read-only registry = %#v", readOnly)110 t.Fatalf("read-only registry = %#v", readOnly)
106 }111 }
112+ worktreeOnly := buildToolRegistry(Args{Tools: []string{"create_worktree"}}, root, tools.NewSandbox(root), true, true, true)
113+ if worktree, ok := worktreeOnly["create_worktree"].(*tools.CreateWorktreeTool); !ok || worktree == nil || len(worktreeOnly) != 1 {
114+ t.Fatalf("worktree-only registry = %#v", worktreeOnly)
115+ }
116+}
117+
118+func TestToolSummariesIncludeCreateWorktree(t *testing.T) {
119+ root := t.TempDir()
120+ registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
121+
122+ summaries := toolSummaries(registry, Args{})
123+ want := []string{"read", "write", "edit", "bash", "create_worktree"}
124+ if len(summaries) != len(want) {
125+ t.Fatalf("tool summaries = %#v, want %v", summaries, want)
126+ }
127+ for index, summary := range summaries {
128+ if summary.Name != want[index] {
129+ t.Fatalf("tool summaries[%d] = %q, want %q", index, summary.Name, want[index])
130+ }
131+ }
132+}
133+
134+func TestResolvedUseSandboxUpdatesCreateWorktreeTool(t *testing.T) {
135+ root := t.TempDir()
136+ previous := tools.NewSandbox(root)
137+ shared := tools.NewSandbox(root)
138+ worktree := &tools.CreateWorktreeTool{CWD: root, Sandbox: previous}
139+ resolved := &Resolved{
140+ Sandbox: previous,
141+ ToolRegistry: core.NewRegistry(worktree),
142+ }
143+
144+ resolved.UseSandbox(shared)
145+
146+ if resolved.Sandbox != shared {
147+ t.Fatal("resolved sandbox was not replaced")
148+ }
149+ if worktree.Sandbox != shared {
150+ t.Fatal("worktree tool sandbox was not replaced")
151+ }
107 }152 }
108 153
109 func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {154 func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
M packages/agent/ext/ext.go +2 -2
@@ -420,8 +420,8 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
420420 // OpenAI accept). Call this BEFORE Run(); zut folds extension tools
421421 // into the agent's registry once the extension's ready frame fires.
422422 //
423-// Naming conflicts with built-in tools (read, write, edit, bash, lsp,
424-// skill) are silently shadowed by the built-in.
423+// Naming conflicts with built-in tools (read, write, edit, bash, create_worktree,
424+// lsp, skill) are silently shadowed by the built-in.
425425 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
426426 e.registerTool(name, description, schema, false, fn)
427427 }
@@ -420,8 +420,8 @@ func (e *Extension) Command(name, description string, fn CommandHandler) {
420 // OpenAI accept). Call this BEFORE Run(); zut folds extension tools420 // OpenAI accept). Call this BEFORE Run(); zut folds extension tools
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-// Naming conflicts with built-in tools (read, write, edit, bash, lsp,423+// Naming conflicts with built-in tools (read, write, edit, bash, create_worktree,
424-// skill) are silently shadowed by the built-in.424+// lsp, skill) are silently shadowed by the built-in.
425 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {425 func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) {
426 e.registerTool(name, description, schema, false, fn)426 e.registerTool(name, description, schema, false, fn)
427 }427 }
M packages/agent/sdk/sdk.go +1 -1
@@ -76,7 +76,7 @@ type Config struct {
7676 BaseURL string
7777
7878 // Tools is the list of tools to enable. Nil/empty = all built-in
79- // tools (read, write, edit, bash, lsp). Pass an empty-but-non-nil slice
79+ // tools (read, write, edit, bash, create_worktree, lsp). Pass an empty-but-non-nil slice
8080 // (e.g. []string{}) plus NoTools=true to disable everything.
8181 Tools []string
8282
@@ -76,7 +76,7 @@ type Config struct {
76 BaseURL string76 BaseURL string
77 77
78 // Tools is the list of tools to enable. Nil/empty = all built-in78 // Tools is the list of tools to enable. Nil/empty = all built-in
79- // tools (read, write, edit, bash, lsp). Pass an empty-but-non-nil slice79+ // tools (read, write, edit, bash, create_worktree, lsp). Pass an empty-but-non-nil slice
80 // (e.g. []string{}) plus NoTools=true to disable everything.80 // (e.g. []string{}) plus NoTools=true to disable everything.
81 Tools []string81 Tools []string
82 82
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/lsp/skill).
32+ (read/write/edit/bash/create_worktree/lsp/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/lsp/skill).32+ (read/write/edit/bash/create_worktree/lsp/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,
A packages/agent/tools/create_worktree.go +908 -0
@@ -0,0 +1,908 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "github.com/bnema/zut/packages/core"
18+ "github.com/bnema/zut/packages/provider"
19+)
20+
21+const (
22+ worktreesIgnoreEntry = "/.worktrees/"
23+ defaultWorktreeRoot = ".worktrees"
24+ worktreeConfigKey = "zut.worktrees.path"
25+ maxCreateWorktreeGitOutput = 64 * 1024
26+ maxCreateWorktreeIgnoreBytes = 1 << 20
27+)
28+
29+var createWorktreeOperationMu sync.Mutex
30+
31+// CreateWorktreeTool creates a persistent branch checkout. A repository that
32+// has neither a local worktree configuration nor an existing .worktrees
33+// directory returns a bootstrap request without changing any files.
34+type CreateWorktreeTool struct {
35+ CWD string
36+ Sandbox *Sandbox
37+
38+ previewMu sync.Mutex
39+ preview *createWorktreePreview
40+}
41+
42+type createWorktreeArgs struct {
43+ Branch string `json:"branch"`
44+ BootstrapRoot string `json:"bootstrap_root,omitempty"`
45+}
46+
47+const createWorktreeSchema = `{
48+ "type":"object",
49+ "properties":{
50+ "branch":{
51+ "type":"string",
52+ "description":"Name of the new Git branch."
53+ },
54+ "bootstrap_root":{
55+ "type":"string",
56+ "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."
57+ }
58+ },
59+ "required":["branch"]
60+}`
61+
62+func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
63+func (t *CreateWorktreeTool) Description() string {
64+ 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."
65+}
66+func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
67+
68+// Preview reports the exact bootstrap request or checkout that Execute will
69+// produce without modifying the repository.
70+func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
71+ plan, err := t.plan(ctx, raw)
72+ if err != nil {
73+ return core.ToolResult{}, err
74+ }
75+ t.previewMu.Lock()
76+ t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
77+ t.previewMu.Unlock()
78+ return plan.preview, nil
79+}
80+
81+func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
82+ createWorktreeOperationMu.Lock()
83+ defer createWorktreeOperationMu.Unlock()
84+
85+ plan, err := t.planForExecute(ctx, raw)
86+ if err != nil {
87+ return core.ToolResult{}, err
88+ }
89+ if plan.bootstrapRequired {
90+ return plan.preview, nil
91+ }
92+ return executeCreateWorktreePlan(ctx, plan, progress)
93+}
94+
95+func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
96+ configSaved := false
97+ var createdWorktreeParents []string
98+ if plan.setConfig {
99+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
100+ return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
101+ }
102+ configSaved = true
103+ }
104+ rollback := func(cause error) error {
105+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
106+ defer cancel()
107+ if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
108+ cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
109+ }
110+ if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
111+ cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
112+ }
113+ if err := plan.ignore.restore(); err != nil {
114+ cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
115+ }
116+ if configSaved {
117+ if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
118+ cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
119+ }
120+ }
121+ return cause
122+ }
123+ if err := plan.ignore.apply(); err != nil {
124+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
125+ }
126+
127+ createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
128+ if err != nil {
129+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
130+ }
131+ if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
132+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
133+ }
134+ if progress != nil {
135+ progress("Creating Git worktree...\n")
136+ }
137+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
138+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
139+ }
140+
141+ result := plan.preview
142+ previewDetails, _ := plan.preview.Details.(map[string]any)
143+ details := make(map[string]any, len(previewDetails)+1)
144+ for key, value := range previewDetails {
145+ details[key] = value
146+ }
147+ details["state"] = "created"
148+ result.Details = details
149+ result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
150+ return result, nil
151+}
152+
153+type createWorktreePreview struct {
154+ raw string
155+ plan createWorktreePlan
156+}
157+
158+func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
159+ t.previewMu.Lock()
160+ cached := t.preview
161+ t.preview = nil
162+ t.previewMu.Unlock()
163+
164+ plan, err := t.plan(ctx, raw)
165+ if err != nil {
166+ return createWorktreePlan{}, err
167+ }
168+ if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
169+ return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
170+ }
171+ return plan, nil
172+}
173+
174+type createWorktreePlan struct {
175+ repoRoot string
176+ branch string
177+ base string
178+ worktreeRoot string
179+ worktreePath string
180+ rootSource string
181+ configValue string
182+ setConfig bool
183+ bootstrapRequired bool
184+ previewText string
185+ preview core.ToolResult
186+ ignore worktreesIgnoreUpdate
187+}
188+
189+func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
190+ var args createWorktreeArgs
191+ if err := json.Unmarshal(raw, &args); err != nil {
192+ return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
193+ }
194+ branchInput := strings.TrimSpace(args.Branch)
195+ if branchInput == "" {
196+ return createWorktreePlan{}, errors.New("branch is required")
197+ }
198+ if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
199+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
200+ }
201+ if t.Sandbox == nil {
202+ return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
203+ }
204+ if err := t.Sandbox.CheckBashPermission("git"); err != nil {
205+ return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
206+ }
207+
208+ cwd, err := t.accessibleCWD()
209+ if err != nil {
210+ return createWorktreePlan{}, err
211+ }
212+ candidateRoot, err := t.findGitRootCandidate(cwd)
213+ if err != nil {
214+ return createWorktreePlan{}, err
215+ }
216+ repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
217+ if err != nil {
218+ return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
219+ }
220+ repoRoot, err = canonical(repoRoot)
221+ if err != nil {
222+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
223+ }
224+ if err := t.checkReadAccess(repoRoot); err != nil {
225+ return createWorktreePlan{}, err
226+ }
227+ branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
228+ if err != nil {
229+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
230+ }
231+ if branch != branchInput {
232+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
233+ }
234+ base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
235+ if err != nil {
236+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
237+ }
238+ commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
239+ if err != nil {
240+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
241+ }
242+ if !filepath.IsAbs(commonDir) {
243+ commonDir = filepath.Join(repoRoot, commonDir)
244+ }
245+ commonDir, err = canonical(commonDir)
246+ if err != nil {
247+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
248+ }
249+ if err := t.checkReadAccess(commonDir); err != nil {
250+ return createWorktreePlan{}, err
251+ }
252+
253+ configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
254+ if err != nil {
255+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
256+ }
257+ bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
258+ if configured && bootstrapRoot != "" {
259+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260+ }
261+
262+ rootSource := "existing .worktrees directory"
263+ configValue := ""
264+ setConfig := false
265+ defaultRoot := false
266+ var worktreeRoot string
267+ if configured {
268+ worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
269+ if err != nil {
270+ return createWorktreePlan{}, err
271+ }
272+ rootSource = "local Git config"
273+ } else {
274+ defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
275+ exists, err := inspectWorktreeRoot(defaultPath)
276+ if err != nil {
277+ return createWorktreePlan{}, err
278+ }
279+ if !exists && bootstrapRoot == "" {
280+ return bootstrapRequiredPlan(branch, repoRoot), nil
281+ }
282+ if exists {
283+ if bootstrapRoot != "" {
284+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285+ }
286+ worktreeRoot = defaultPath
287+ defaultRoot = true
288+ } else {
289+ worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
290+ if err != nil {
291+ return createWorktreePlan{}, err
292+ }
293+ setConfig = true
294+ rootSource = "bootstrap selection"
295+ }
296+ }
297+
298+ worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
299+ if err != nil {
300+ return createWorktreePlan{}, err
301+ }
302+ worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
303+ rootCanonical, err := canonicalOrParent(worktreeRoot)
304+ if err != nil {
305+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
306+ }
307+ if !defaultRoot && isUnder(repoRoot, rootCanonical) {
308+ return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
309+ }
310+ pathCanonical, err := canonicalOrParent(worktreePath)
311+ if err != nil {
312+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
313+ }
314+ if !isUnder(rootCanonical, pathCanonical) {
315+ return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
316+ }
317+
318+ ignorePath := ""
319+ accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
320+ if defaultRoot {
321+ ignorePath = filepath.Join(repoRoot, ".gitignore")
322+ accessPaths = append(accessPaths, ignorePath)
323+ }
324+ if err := t.checkReadWriteAccess(accessPaths...); err != nil {
325+ return createWorktreePlan{}, err
326+ }
327+ if _, err := os.Lstat(worktreePath); err == nil {
328+ return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
329+ } else if !os.IsNotExist(err) {
330+ return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
331+ }
332+ exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
333+ if err != nil {
334+ return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
335+ }
336+ if exists {
337+ return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
338+ }
339+
340+ ignore := worktreesIgnoreUpdate{}
341+ ignoreAction := "not modified (external worktree root)"
342+ if defaultRoot {
343+ ignore, err = newWorktreesIgnoreUpdate(ignorePath)
344+ if err != nil {
345+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
346+ }
347+ ignoreAction = "already contains " + worktreesIgnoreEntry
348+ if ignore.changed {
349+ ignoreAction = "add " + worktreesIgnoreEntry
350+ }
351+ }
352+
353+ displayRoot := worktreeRoot
354+ if defaultRoot {
355+ displayRoot = defaultWorktreeRoot
356+ }
357+ displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
358+ configAction := ""
359+ if setConfig {
360+ configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
361+ }
362+ 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)
363+ return createWorktreePlan{
364+ repoRoot: repoRoot,
365+ branch: branch,
366+ base: base,
367+ worktreeRoot: worktreeRoot,
368+ worktreePath: worktreePath,
369+ rootSource: rootSource,
370+ configValue: configValue,
371+ setConfig: setConfig,
372+ previewText: previewText,
373+ preview: core.ToolResult{
374+ Content: []provider.Content{provider.TextBlock{Text: previewText}},
375+ Details: map[string]any{
376+ "state": "ready",
377+ "branch": branch,
378+ "base": base,
379+ "worktree_root": displayRoot,
380+ "root_source": rootSource,
381+ "path": displayPath,
382+ "bootstrap_saved": setConfig,
383+ },
384+ },
385+ ignore: ignore,
386+ }, nil
387+}
388+
389+func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
390+ 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))
391+ return createWorktreePlan{
392+ branch: branch,
393+ bootstrapRequired: true,
394+ previewText: text,
395+ preview: core.ToolResult{
396+ Content: []provider.Content{provider.TextBlock{Text: text}},
397+ Details: map[string]any{
398+ "state": "bootstrap_required",
399+ "branch": branch,
400+ "default_bootstrap_root": defaultWorktreeRoot,
401+ },
402+ },
403+ }
404+}
405+
406+func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
407+ cwd := t.CWD
408+ if cwd == "" {
409+ var err error
410+ cwd, err = os.Getwd()
411+ if err != nil {
412+ return "", fmt.Errorf("create_worktree: working directory: %w", err)
413+ }
414+ }
415+ cwd, err := canonical(cwd)
416+ if err != nil {
417+ return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
418+ }
419+ if err := t.checkReadAccess(cwd); err != nil {
420+ return "", err
421+ }
422+ return cwd, nil
423+}
424+
425+func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
426+ for dir := cwd; ; dir = filepath.Dir(dir) {
427+ if err := t.checkReadAccess(dir); err != nil {
428+ return "", err
429+ }
430+ if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
431+ return dir, nil
432+ } else if !os.IsNotExist(err) {
433+ return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
434+ }
435+ parent := filepath.Dir(dir)
436+ if parent == dir {
437+ break
438+ }
439+ }
440+ return "", errors.New("create_worktree: working directory is not a Git repository")
441+}
442+
443+func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
444+ for _, path := range paths {
445+ if err := t.Sandbox.CheckReadPath(path); err != nil {
446+ return fmt.Errorf("create_worktree: %w", err)
447+ }
448+ }
449+ return nil
450+}
451+
452+func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
453+ for _, path := range paths {
454+ if err := t.Sandbox.CheckReadPath(path); err != nil {
455+ return fmt.Errorf("create_worktree: %w", err)
456+ }
457+ if err := t.Sandbox.CheckWritePath(path); err != nil {
458+ return fmt.Errorf("create_worktree: %w", err)
459+ }
460+ }
461+ return nil
462+}
463+
464+func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
465+ value = strings.TrimSpace(value)
466+ if value == defaultWorktreeRoot {
467+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
468+ }
469+ if !filepath.IsAbs(value) {
470+ return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
471+ }
472+ return value, false, nil
473+}
474+
475+func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
476+ if value == defaultWorktreeRoot {
477+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
478+ }
479+ if !filepath.IsAbs(value) {
480+ return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
481+ }
482+ root, err := canonicalOrParent(value)
483+ if err != nil {
484+ return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
485+ }
486+ if isUnder(repoRoot, root) {
487+ return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
488+ }
489+ return root, false, root, nil
490+}
491+
492+func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
493+ info, err := os.Lstat(path)
494+ if os.IsNotExist(err) {
495+ return path, nil
496+ }
497+ if err != nil {
498+ return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
499+ }
500+ if info.Mode()&os.ModeSymlink != 0 {
501+ return "", errors.New("create_worktree: worktree root must not be a symbolic link")
502+ }
503+ if !info.IsDir() {
504+ return "", errors.New("create_worktree: worktree root exists but is not a directory")
505+ }
506+ if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
507+ return "", errors.New("create_worktree: invalid default worktree root")
508+ }
509+ return path, nil
510+}
511+
512+func inspectWorktreeRoot(path string) (bool, error) {
513+ info, err := os.Lstat(path)
514+ if os.IsNotExist(err) {
515+ return false, nil
516+ }
517+ if err != nil {
518+ return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
519+ }
520+ if info.Mode()&os.ModeSymlink != 0 {
521+ return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
522+ }
523+ if !info.IsDir() {
524+ return false, errors.New("create_worktree: .worktrees exists but is not a directory")
525+ }
526+ return true, nil
527+}
528+
529+func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
530+ if defaultRoot {
531+ relative, err := filepath.Rel(repoRoot, path)
532+ if err == nil {
533+ return filepath.ToSlash(relative)
534+ }
535+ }
536+ return filepath.ToSlash(path)
537+}
538+
539+func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
540+ return a.bootstrapRequired == b.bootstrapRequired &&
541+ a.repoRoot == b.repoRoot &&
542+ a.branch == b.branch &&
543+ a.base == b.base &&
544+ a.worktreeRoot == b.worktreeRoot &&
545+ a.worktreePath == b.worktreePath &&
546+ a.rootSource == b.rootSource &&
547+ a.configValue == b.configValue &&
548+ a.setConfig == b.setConfig &&
549+ a.ignore.path == b.ignore.path &&
550+ a.ignore.existed == b.ignore.existed &&
551+ a.ignore.changed == b.ignore.changed &&
552+ bytes.Equal(a.ignore.before, b.ignore.before)
553+}
554+
555+func missingWorktreeParents(worktreePath string) ([]string, error) {
556+ var missing []string
557+ for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
558+ info, err := os.Lstat(directory)
559+ if err == nil {
560+ if !info.IsDir() {
561+ return nil, fmt.Errorf("%q is not a directory", directory)
562+ }
563+ break
564+ }
565+ if !os.IsNotExist(err) {
566+ return nil, err
567+ }
568+ missing = append(missing, directory)
569+ parent := filepath.Dir(directory)
570+ if parent == directory {
571+ return nil, errors.New("no existing parent directory")
572+ }
573+ }
574+ for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
575+ missing[left], missing[right] = missing[right], missing[left]
576+ }
577+ return missing, nil
578+}
579+
580+func removeCreatedWorktreeParents(parents []string) error {
581+ for index := len(parents) - 1; index >= 0; index-- {
582+ if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
583+ return err
584+ }
585+ }
586+ return nil
587+}
588+
589+func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
590+ value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
591+ if err != nil {
592+ return err
593+ }
594+ if !configured {
595+ return nil
596+ }
597+ if value != p.configValue {
598+ return errors.New("local worktree configuration changed during creation")
599+ }
600+ _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
601+ return err
602+}
603+
604+func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
605+ registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
606+ if err != nil {
607+ return err
608+ }
609+ var cleanupErr error
610+ if registered {
611+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
612+ cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
613+ }
614+ }
615+ branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
616+ if err == nil && exists && branchHead == p.base {
617+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
618+ cleanupErr = fmt.Errorf("remove branch: %w", err)
619+ }
620+ }
621+ if err != nil && cleanupErr == nil {
622+ cleanupErr = err
623+ }
624+ return cleanupErr
625+}
626+
627+func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
628+ output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
629+ if err != nil {
630+ return false, err
631+ }
632+ want, err := canonicalOrParent(worktreePath)
633+ if err != nil {
634+ return false, err
635+ }
636+ for _, line := range strings.Split(output, "\n") {
637+ if !strings.HasPrefix(line, "worktree ") {
638+ continue
639+ }
640+ path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
641+ if err == nil && path == want {
642+ return true, nil
643+ }
644+ }
645+ return false, nil
646+}
647+
648+func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
649+ _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
650+ if err == nil {
651+ return true, nil
652+ }
653+ if ctx.Err() != nil {
654+ return false, ctx.Err()
655+ }
656+ if createWorktreeGitExitCode(err) == 1 {
657+ return false, nil
658+ }
659+ return false, err
660+}
661+
662+func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
663+ output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
664+ if err == nil {
665+ return output, true, nil
666+ }
667+ if createWorktreeGitExitCode(err) == 1 {
668+ return "", false, nil
669+ }
670+ return "", false, err
671+}
672+
673+func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
674+ output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
675+ if err == nil {
676+ return output, true, nil
677+ }
678+ if createWorktreeGitExitCode(err) == 1 {
679+ return "", false, nil
680+ }
681+ return "", false, err
682+}
683+
684+type createWorktreeGitError struct {
685+ args []string
686+ output string
687+ err error
688+}
689+
690+func (e *createWorktreeGitError) Error() string {
691+ message := strings.TrimSpace(e.output)
692+ if message == "" {
693+ message = e.err.Error()
694+ }
695+ return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
696+}
697+
698+func (e *createWorktreeGitError) Unwrap() error { return e.err }
699+
700+func createWorktreeGitExitCode(err error) int {
701+ var exitErr *exec.ExitError
702+ if errors.As(err, &exitErr) {
703+ return exitErr.ExitCode()
704+ }
705+ return -1
706+}
707+
708+func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
709+ cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
710+ cmd := exec.CommandContext(ctx, "git", cmdArgs...)
711+ cmd.Env = createWorktreeGitEnv()
712+ setProcessGroup(cmd)
713+
714+ var stdout, output cappedGitOutput
715+ cmd.Stdout = io.MultiWriter(&stdout, &output)
716+ cmd.Stderr = &output
717+ if err := cmd.Start(); err != nil {
718+ return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
719+ }
720+ watchDone := make(chan struct{})
721+ watchStopped := make(chan struct{})
722+ go func() {
723+ defer close(watchStopped)
724+ select {
725+ case <-ctx.Done():
726+ killProcessGroup(cmd)
727+ case <-watchDone:
728+ }
729+ }()
730+ waitErr := cmd.Wait()
731+ close(watchDone)
732+ <-watchStopped
733+ if waitErr != nil {
734+ if ctx.Err() != nil {
735+ return "", ctx.Err()
736+ }
737+ return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
738+ }
739+ return strings.TrimSpace(stdout.String()), nil
740+}
741+
742+type cappedGitOutput struct {
743+ mu sync.Mutex
744+ buffer bytes.Buffer
745+ truncated bool
746+}
747+
748+func (b *cappedGitOutput) Write(p []byte) (int, error) {
749+ b.mu.Lock()
750+ defer b.mu.Unlock()
751+ if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
752+ if len(p) > room {
753+ _, _ = b.buffer.Write(p[:room])
754+ b.truncated = true
755+ } else {
756+ _, _ = b.buffer.Write(p)
757+ }
758+ } else {
759+ b.truncated = true
760+ }
761+ return len(p), nil
762+}
763+
764+func (b *cappedGitOutput) String() string {
765+ b.mu.Lock()
766+ defer b.mu.Unlock()
767+ output := b.buffer.String()
768+ if b.truncated {
769+ output += "\n... [git output truncated]"
770+ }
771+ return output
772+}
773+
774+func createWorktreeGitEnv() []string {
775+ env := make([]string, 0, len(os.Environ())+5)
776+ for _, entry := range os.Environ() {
777+ key, _, _ := strings.Cut(entry, "=")
778+ if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
779+ continue
780+ }
781+ env = append(env, entry)
782+ }
783+ return append(env,
784+ "GIT_CONFIG_NOSYSTEM=1",
785+ "GIT_CONFIG_GLOBAL="+os.DevNull,
786+ "GIT_TERMINAL_PROMPT=0",
787+ "GIT_ASKPASS=",
788+ "SSH_ASKPASS=",
789+ )
790+}
791+
792+type worktreesIgnoreUpdate struct {
793+ path string
794+ before []byte
795+ after []byte
796+ existed bool
797+ mode os.FileMode
798+ changed bool
799+}
800+
801+func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
802+ contents, mode, existed, err := readWorktreesIgnore(path)
803+ if err != nil {
804+ return worktreesIgnoreUpdate{}, err
805+ }
806+ for _, line := range strings.Split(string(contents), "\n") {
807+ if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
808+ return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
809+ }
810+ }
811+ lineEnding := "\n"
812+ if bytes.Contains(contents, []byte("\r\n")) {
813+ lineEnding = "\r\n"
814+ }
815+ after := append([]byte(nil), contents...)
816+ if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
817+ after = append(after, lineEnding...)
818+ }
819+ after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
820+ return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
821+}
822+
823+func (u worktreesIgnoreUpdate) apply() error {
824+ if !u.changed {
825+ return nil
826+ }
827+ current, _, existed, err := readWorktreesIgnore(u.path)
828+ if err != nil {
829+ return err
830+ }
831+ if existed != u.existed || !bytes.Equal(current, u.before) {
832+ return errors.New(".gitignore changed since preview; inspect it and retry")
833+ }
834+ return writeWorktreesIgnore(u.path, u.after, u.mode)
835+}
836+
837+func (u worktreesIgnoreUpdate) restore() error {
838+ if !u.changed {
839+ return nil
840+ }
841+ current, _, existed, err := readWorktreesIgnore(u.path)
842+ if err != nil {
843+ return err
844+ }
845+ if !existed || !bytes.Equal(current, u.after) {
846+ return nil
847+ }
848+ if !u.existed {
849+ if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
850+ return err
851+ }
852+ return nil
853+ }
854+ return writeWorktreesIgnore(u.path, u.before, u.mode)
855+}
856+
857+func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
858+ info, err := os.Lstat(path)
859+ if os.IsNotExist(err) {
860+ return nil, 0o644, false, nil
861+ }
862+ if err != nil {
863+ return nil, 0, false, err
864+ }
865+ if info.Mode()&os.ModeSymlink != 0 {
866+ return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
867+ }
868+ if !info.Mode().IsRegular() {
869+ return nil, 0, false, errors.New(".gitignore must be a regular file")
870+ }
871+ if info.Size() > maxCreateWorktreeIgnoreBytes {
872+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
873+ }
874+ file, err := os.Open(path)
875+ if err != nil {
876+ return nil, 0, false, err
877+ }
878+ defer file.Close()
879+ contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
880+ if err != nil {
881+ return nil, 0, false, err
882+ }
883+ if len(contents) > maxCreateWorktreeIgnoreBytes {
884+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
885+ }
886+ return contents, info.Mode().Perm(), true, nil
887+}
888+
889+func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
890+ file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
891+ if err != nil {
892+ return err
893+ }
894+ tempPath := file.Name()
895+ defer os.Remove(tempPath)
896+ if err := file.Chmod(mode); err != nil {
897+ _ = file.Close()
898+ return err
899+ }
900+ if _, err := file.Write(contents); err != nil {
901+ _ = file.Close()
902+ return err
903+ }
904+ if err := file.Close(); err != nil {
905+ return err
906+ }
907+ return os.Rename(tempPath, path)
908+}
@@ -0,0 +1,908 @@
1+package tools
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "os/exec"
12+ "path/filepath"
13+ "strings"
14+ "sync"
15+ "time"
16+
17+ "github.com/bnema/zut/packages/core"
18+ "github.com/bnema/zut/packages/provider"
19+)
20+
21+const (
22+ worktreesIgnoreEntry = "/.worktrees/"
23+ defaultWorktreeRoot = ".worktrees"
24+ worktreeConfigKey = "zut.worktrees.path"
25+ maxCreateWorktreeGitOutput = 64 * 1024
26+ maxCreateWorktreeIgnoreBytes = 1 << 20
27+)
28+
29+var createWorktreeOperationMu sync.Mutex
30+
31+// CreateWorktreeTool creates a persistent branch checkout. A repository that
32+// has neither a local worktree configuration nor an existing .worktrees
33+// directory returns a bootstrap request without changing any files.
34+type CreateWorktreeTool struct {
35+ CWD string
36+ Sandbox *Sandbox
37+
38+ previewMu sync.Mutex
39+ preview *createWorktreePreview
40+}
41+
42+type createWorktreeArgs struct {
43+ Branch string `json:"branch"`
44+ BootstrapRoot string `json:"bootstrap_root,omitempty"`
45+}
46+
47+const createWorktreeSchema = `{
48+ "type":"object",
49+ "properties":{
50+ "branch":{
51+ "type":"string",
52+ "description":"Name of the new Git branch."
53+ },
54+ "bootstrap_root":{
55+ "type":"string",
56+ "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."
57+ }
58+ },
59+ "required":["branch"]
60+}`
61+
62+func (t *CreateWorktreeTool) Name() string { return "create_worktree" }
63+func (t *CreateWorktreeTool) Description() string {
64+ 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."
65+}
66+func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) }
67+
68+// Preview reports the exact bootstrap request or checkout that Execute will
69+// produce without modifying the repository.
70+func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) {
71+ plan, err := t.plan(ctx, raw)
72+ if err != nil {
73+ return core.ToolResult{}, err
74+ }
75+ t.previewMu.Lock()
76+ t.preview = &createWorktreePreview{raw: string(raw), plan: plan}
77+ t.previewMu.Unlock()
78+ return plan.preview, nil
79+}
80+
81+func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) {
82+ createWorktreeOperationMu.Lock()
83+ defer createWorktreeOperationMu.Unlock()
84+
85+ plan, err := t.planForExecute(ctx, raw)
86+ if err != nil {
87+ return core.ToolResult{}, err
88+ }
89+ if plan.bootstrapRequired {
90+ return plan.preview, nil
91+ }
92+ return executeCreateWorktreePlan(ctx, plan, progress)
93+}
94+
95+func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) {
96+ configSaved := false
97+ var createdWorktreeParents []string
98+ if plan.setConfig {
99+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil {
100+ return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err)
101+ }
102+ configSaved = true
103+ }
104+ rollback := func(cause error) error {
105+ cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
106+ defer cancel()
107+ if err := plan.cleanupFailedCheckout(cleanupCtx); err != nil {
108+ cause = fmt.Errorf("%w; cleanup worktree: %v", cause, err)
109+ }
110+ if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil {
111+ cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err)
112+ }
113+ if err := plan.ignore.restore(); err != nil {
114+ cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err)
115+ }
116+ if configSaved {
117+ if err := plan.removeBootstrapConfig(cleanupCtx); err != nil {
118+ cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err)
119+ }
120+ }
121+ return cause
122+ }
123+ if err := plan.ignore.apply(); err != nil {
124+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err))
125+ }
126+
127+ createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath)
128+ if err != nil {
129+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err))
130+ }
131+ if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil {
132+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err))
133+ }
134+ if progress != nil {
135+ progress("Creating Git worktree...\n")
136+ }
137+ if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "worktree", "add", "-b", plan.branch, plan.worktreePath, plan.base); err != nil {
138+ return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: create worktree: %w", err))
139+ }
140+
141+ result := plan.preview
142+ previewDetails, _ := plan.preview.Details.(map[string]any)
143+ details := make(map[string]any, len(previewDetails)+1)
144+ for key, value := range previewDetails {
145+ details[key] = value
146+ }
147+ details["state"] = "created"
148+ result.Details = details
149+ result.Content = []provider.Content{provider.TextBlock{Text: strings.Replace(plan.previewText, "create worktree", "created worktree", 1)}}
150+ return result, nil
151+}
152+
153+type createWorktreePreview struct {
154+ raw string
155+ plan createWorktreePlan
156+}
157+
158+func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
159+ t.previewMu.Lock()
160+ cached := t.preview
161+ t.preview = nil
162+ t.previewMu.Unlock()
163+
164+ plan, err := t.plan(ctx, raw)
165+ if err != nil {
166+ return createWorktreePlan{}, err
167+ }
168+ if cached != nil && cached.raw == string(raw) && !sameCreateWorktreePlan(cached.plan, plan) {
169+ return createWorktreePlan{}, errors.New("create_worktree: repository state changed since preview; inspect and retry")
170+ }
171+ return plan, nil
172+}
173+
174+type createWorktreePlan struct {
175+ repoRoot string
176+ branch string
177+ base string
178+ worktreeRoot string
179+ worktreePath string
180+ rootSource string
181+ configValue string
182+ setConfig bool
183+ bootstrapRequired bool
184+ previewText string
185+ preview core.ToolResult
186+ ignore worktreesIgnoreUpdate
187+}
188+
189+func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) {
190+ var args createWorktreeArgs
191+ if err := json.Unmarshal(raw, &args); err != nil {
192+ return createWorktreePlan{}, fmt.Errorf("invalid args: %w", err)
193+ }
194+ branchInput := strings.TrimSpace(args.Branch)
195+ if branchInput == "" {
196+ return createWorktreePlan{}, errors.New("branch is required")
197+ }
198+ if strings.HasPrefix(branchInput, "-") || strings.Contains(branchInput, "@{") {
199+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
200+ }
201+ if t.Sandbox == nil {
202+ return createWorktreePlan{}, errors.New("create_worktree: sandbox is required")
203+ }
204+ if err := t.Sandbox.CheckBashPermission("git"); err != nil {
205+ return createWorktreePlan{}, fmt.Errorf("create_worktree: %w", err)
206+ }
207+
208+ cwd, err := t.accessibleCWD()
209+ if err != nil {
210+ return createWorktreePlan{}, err
211+ }
212+ candidateRoot, err := t.findGitRootCandidate(cwd)
213+ if err != nil {
214+ return createWorktreePlan{}, err
215+ }
216+ repoRoot, err := createWorktreeGitOutput(ctx, candidateRoot, "rev-parse", "--show-toplevel")
217+ if err != nil {
218+ return createWorktreePlan{}, fmt.Errorf("create_worktree: verify repository: %w", err)
219+ }
220+ repoRoot, err = canonical(repoRoot)
221+ if err != nil {
222+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve repository root: %w", err)
223+ }
224+ if err := t.checkReadAccess(repoRoot); err != nil {
225+ return createWorktreePlan{}, err
226+ }
227+ branch, err := createWorktreeGitOutput(ctx, repoRoot, "check-ref-format", "--branch", branchInput)
228+ if err != nil {
229+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q: %w", branchInput, err)
230+ }
231+ if branch != branchInput {
232+ return createWorktreePlan{}, fmt.Errorf("create_worktree: invalid branch %q", branchInput)
233+ }
234+ base, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "HEAD^{commit}")
235+ if err != nil {
236+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve current commit: %w", err)
237+ }
238+ commonDir, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--git-common-dir")
239+ if err != nil {
240+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
241+ }
242+ if !filepath.IsAbs(commonDir) {
243+ commonDir = filepath.Join(repoRoot, commonDir)
244+ }
245+ commonDir, err = canonical(commonDir)
246+ if err != nil {
247+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve Git metadata: %w", err)
248+ }
249+ if err := t.checkReadAccess(commonDir); err != nil {
250+ return createWorktreePlan{}, err
251+ }
252+
253+ configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey)
254+ if err != nil {
255+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err)
256+ }
257+ bootstrapRoot := strings.TrimSpace(args.BootstrapRoot)
258+ if configured && bootstrapRoot != "" {
259+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again")
260+ }
261+
262+ rootSource := "existing .worktrees directory"
263+ configValue := ""
264+ setConfig := false
265+ defaultRoot := false
266+ var worktreeRoot string
267+ if configured {
268+ worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot)
269+ if err != nil {
270+ return createWorktreePlan{}, err
271+ }
272+ rootSource = "local Git config"
273+ } else {
274+ defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot)
275+ exists, err := inspectWorktreeRoot(defaultPath)
276+ if err != nil {
277+ return createWorktreePlan{}, err
278+ }
279+ if !exists && bootstrapRoot == "" {
280+ return bootstrapRequiredPlan(branch, repoRoot), nil
281+ }
282+ if exists {
283+ if bootstrapRoot != "" {
284+ return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root")
285+ }
286+ worktreeRoot = defaultPath
287+ defaultRoot = true
288+ } else {
289+ worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot)
290+ if err != nil {
291+ return createWorktreePlan{}, err
292+ }
293+ setConfig = true
294+ rootSource = "bootstrap selection"
295+ }
296+ }
297+
298+ worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot)
299+ if err != nil {
300+ return createWorktreePlan{}, err
301+ }
302+ worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch))
303+ rootCanonical, err := canonicalOrParent(worktreeRoot)
304+ if err != nil {
305+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err)
306+ }
307+ if !defaultRoot && isUnder(repoRoot, rootCanonical) {
308+ return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository")
309+ }
310+ pathCanonical, err := canonicalOrParent(worktreePath)
311+ if err != nil {
312+ return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err)
313+ }
314+ if !isUnder(rootCanonical, pathCanonical) {
315+ return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root")
316+ }
317+
318+ ignorePath := ""
319+ accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath}
320+ if defaultRoot {
321+ ignorePath = filepath.Join(repoRoot, ".gitignore")
322+ accessPaths = append(accessPaths, ignorePath)
323+ }
324+ if err := t.checkReadWriteAccess(accessPaths...); err != nil {
325+ return createWorktreePlan{}, err
326+ }
327+ if _, err := os.Lstat(worktreePath); err == nil {
328+ return createWorktreePlan{}, fmt.Errorf("create_worktree: worktree path %q already exists", filepath.ToSlash(worktreePath))
329+ } else if !os.IsNotExist(err) {
330+ return createWorktreePlan{}, fmt.Errorf("create_worktree: inspect worktree path: %w", err)
331+ }
332+ exists, err := createWorktreeBranchExists(ctx, repoRoot, branch)
333+ if err != nil {
334+ return createWorktreePlan{}, fmt.Errorf("create_worktree: check branch: %w", err)
335+ }
336+ if exists {
337+ return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch)
338+ }
339+
340+ ignore := worktreesIgnoreUpdate{}
341+ ignoreAction := "not modified (external worktree root)"
342+ if defaultRoot {
343+ ignore, err = newWorktreesIgnoreUpdate(ignorePath)
344+ if err != nil {
345+ return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
346+ }
347+ ignoreAction = "already contains " + worktreesIgnoreEntry
348+ if ignore.changed {
349+ ignoreAction = "add " + worktreesIgnoreEntry
350+ }
351+ }
352+
353+ displayRoot := worktreeRoot
354+ if defaultRoot {
355+ displayRoot = defaultWorktreeRoot
356+ }
357+ displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot)
358+ configAction := ""
359+ if setConfig {
360+ configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue)
361+ }
362+ 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)
363+ return createWorktreePlan{
364+ repoRoot: repoRoot,
365+ branch: branch,
366+ base: base,
367+ worktreeRoot: worktreeRoot,
368+ worktreePath: worktreePath,
369+ rootSource: rootSource,
370+ configValue: configValue,
371+ setConfig: setConfig,
372+ previewText: previewText,
373+ preview: core.ToolResult{
374+ Content: []provider.Content{provider.TextBlock{Text: previewText}},
375+ Details: map[string]any{
376+ "state": "ready",
377+ "branch": branch,
378+ "base": base,
379+ "worktree_root": displayRoot,
380+ "root_source": rootSource,
381+ "path": displayPath,
382+ "bootstrap_saved": setConfig,
383+ },
384+ },
385+ ignore: ignore,
386+ }, nil
387+}
388+
389+func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan {
390+ 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))
391+ return createWorktreePlan{
392+ branch: branch,
393+ bootstrapRequired: true,
394+ previewText: text,
395+ preview: core.ToolResult{
396+ Content: []provider.Content{provider.TextBlock{Text: text}},
397+ Details: map[string]any{
398+ "state": "bootstrap_required",
399+ "branch": branch,
400+ "default_bootstrap_root": defaultWorktreeRoot,
401+ },
402+ },
403+ }
404+}
405+
406+func (t *CreateWorktreeTool) accessibleCWD() (string, error) {
407+ cwd := t.CWD
408+ if cwd == "" {
409+ var err error
410+ cwd, err = os.Getwd()
411+ if err != nil {
412+ return "", fmt.Errorf("create_worktree: working directory: %w", err)
413+ }
414+ }
415+ cwd, err := canonical(cwd)
416+ if err != nil {
417+ return "", fmt.Errorf("create_worktree: resolve working directory: %w", err)
418+ }
419+ if err := t.checkReadAccess(cwd); err != nil {
420+ return "", err
421+ }
422+ return cwd, nil
423+}
424+
425+func (t *CreateWorktreeTool) findGitRootCandidate(cwd string) (string, error) {
426+ for dir := cwd; ; dir = filepath.Dir(dir) {
427+ if err := t.checkReadAccess(dir); err != nil {
428+ return "", err
429+ }
430+ if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
431+ return dir, nil
432+ } else if !os.IsNotExist(err) {
433+ return "", fmt.Errorf("create_worktree: inspect Git metadata: %w", err)
434+ }
435+ parent := filepath.Dir(dir)
436+ if parent == dir {
437+ break
438+ }
439+ }
440+ return "", errors.New("create_worktree: working directory is not a Git repository")
441+}
442+
443+func (t *CreateWorktreeTool) checkReadAccess(paths ...string) error {
444+ for _, path := range paths {
445+ if err := t.Sandbox.CheckReadPath(path); err != nil {
446+ return fmt.Errorf("create_worktree: %w", err)
447+ }
448+ }
449+ return nil
450+}
451+
452+func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error {
453+ for _, path := range paths {
454+ if err := t.Sandbox.CheckReadPath(path); err != nil {
455+ return fmt.Errorf("create_worktree: %w", err)
456+ }
457+ if err := t.Sandbox.CheckWritePath(path); err != nil {
458+ return fmt.Errorf("create_worktree: %w", err)
459+ }
460+ }
461+ return nil
462+}
463+
464+func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) {
465+ value = strings.TrimSpace(value)
466+ if value == defaultWorktreeRoot {
467+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil
468+ }
469+ if !filepath.IsAbs(value) {
470+ return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path")
471+ }
472+ return value, false, nil
473+}
474+
475+func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) {
476+ if value == defaultWorktreeRoot {
477+ return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil
478+ }
479+ if !filepath.IsAbs(value) {
480+ return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path")
481+ }
482+ root, err := canonicalOrParent(value)
483+ if err != nil {
484+ return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err)
485+ }
486+ if isUnder(repoRoot, root) {
487+ return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository")
488+ }
489+ return root, false, root, nil
490+}
491+
492+func validateWorktreeRoot(path string, defaultRoot bool) (string, error) {
493+ info, err := os.Lstat(path)
494+ if os.IsNotExist(err) {
495+ return path, nil
496+ }
497+ if err != nil {
498+ return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err)
499+ }
500+ if info.Mode()&os.ModeSymlink != 0 {
501+ return "", errors.New("create_worktree: worktree root must not be a symbolic link")
502+ }
503+ if !info.IsDir() {
504+ return "", errors.New("create_worktree: worktree root exists but is not a directory")
505+ }
506+ if defaultRoot && filepath.Base(path) != defaultWorktreeRoot {
507+ return "", errors.New("create_worktree: invalid default worktree root")
508+ }
509+ return path, nil
510+}
511+
512+func inspectWorktreeRoot(path string) (bool, error) {
513+ info, err := os.Lstat(path)
514+ if os.IsNotExist(err) {
515+ return false, nil
516+ }
517+ if err != nil {
518+ return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err)
519+ }
520+ if info.Mode()&os.ModeSymlink != 0 {
521+ return false, errors.New("create_worktree: .worktrees must not be a symbolic link")
522+ }
523+ if !info.IsDir() {
524+ return false, errors.New("create_worktree: .worktrees exists but is not a directory")
525+ }
526+ return true, nil
527+}
528+
529+func displayWorktreePath(repoRoot, path string, defaultRoot bool) string {
530+ if defaultRoot {
531+ relative, err := filepath.Rel(repoRoot, path)
532+ if err == nil {
533+ return filepath.ToSlash(relative)
534+ }
535+ }
536+ return filepath.ToSlash(path)
537+}
538+
539+func sameCreateWorktreePlan(a, b createWorktreePlan) bool {
540+ return a.bootstrapRequired == b.bootstrapRequired &&
541+ a.repoRoot == b.repoRoot &&
542+ a.branch == b.branch &&
543+ a.base == b.base &&
544+ a.worktreeRoot == b.worktreeRoot &&
545+ a.worktreePath == b.worktreePath &&
546+ a.rootSource == b.rootSource &&
547+ a.configValue == b.configValue &&
548+ a.setConfig == b.setConfig &&
549+ a.ignore.path == b.ignore.path &&
550+ a.ignore.existed == b.ignore.existed &&
551+ a.ignore.changed == b.ignore.changed &&
552+ bytes.Equal(a.ignore.before, b.ignore.before)
553+}
554+
555+func missingWorktreeParents(worktreePath string) ([]string, error) {
556+ var missing []string
557+ for directory := filepath.Dir(worktreePath); ; directory = filepath.Dir(directory) {
558+ info, err := os.Lstat(directory)
559+ if err == nil {
560+ if !info.IsDir() {
561+ return nil, fmt.Errorf("%q is not a directory", directory)
562+ }
563+ break
564+ }
565+ if !os.IsNotExist(err) {
566+ return nil, err
567+ }
568+ missing = append(missing, directory)
569+ parent := filepath.Dir(directory)
570+ if parent == directory {
571+ return nil, errors.New("no existing parent directory")
572+ }
573+ }
574+ for left, right := 0, len(missing)-1; left < right; left, right = left+1, right-1 {
575+ missing[left], missing[right] = missing[right], missing[left]
576+ }
577+ return missing, nil
578+}
579+
580+func removeCreatedWorktreeParents(parents []string) error {
581+ for index := len(parents) - 1; index >= 0; index-- {
582+ if err := os.Remove(parents[index]); err != nil && !os.IsNotExist(err) {
583+ return err
584+ }
585+ }
586+ return nil
587+}
588+
589+func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error {
590+ value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey)
591+ if err != nil {
592+ return err
593+ }
594+ if !configured {
595+ return nil
596+ }
597+ if value != p.configValue {
598+ return errors.New("local worktree configuration changed during creation")
599+ }
600+ _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey)
601+ return err
602+}
603+
604+func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error {
605+ registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath)
606+ if err != nil {
607+ return err
608+ }
609+ var cleanupErr error
610+ if registered {
611+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "worktree", "remove", "--force", p.worktreePath); err != nil {
612+ cleanupErr = fmt.Errorf("remove registered worktree: %w", err)
613+ }
614+ }
615+ branchHead, exists, err := createWorktreeGitRef(ctx, p.repoRoot, "refs/heads/"+p.branch)
616+ if err == nil && exists && branchHead == p.base {
617+ if _, err := createWorktreeGitOutput(ctx, p.repoRoot, "branch", "-D", p.branch); err != nil && cleanupErr == nil {
618+ cleanupErr = fmt.Errorf("remove branch: %w", err)
619+ }
620+ }
621+ if err != nil && cleanupErr == nil {
622+ cleanupErr = err
623+ }
624+ return cleanupErr
625+}
626+
627+func createWorktreeRegistered(ctx context.Context, repoRoot, worktreePath string) (bool, error) {
628+ output, err := createWorktreeGitOutput(ctx, repoRoot, "worktree", "list", "--porcelain")
629+ if err != nil {
630+ return false, err
631+ }
632+ want, err := canonicalOrParent(worktreePath)
633+ if err != nil {
634+ return false, err
635+ }
636+ for _, line := range strings.Split(output, "\n") {
637+ if !strings.HasPrefix(line, "worktree ") {
638+ continue
639+ }
640+ path, err := canonicalOrParent(strings.TrimPrefix(line, "worktree "))
641+ if err == nil && path == want {
642+ return true, nil
643+ }
644+ }
645+ return false, nil
646+}
647+
648+func createWorktreeBranchExists(ctx context.Context, repoRoot, branch string) (bool, error) {
649+ _, err := createWorktreeGitOutput(ctx, repoRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
650+ if err == nil {
651+ return true, nil
652+ }
653+ if ctx.Err() != nil {
654+ return false, ctx.Err()
655+ }
656+ if createWorktreeGitExitCode(err) == 1 {
657+ return false, nil
658+ }
659+ return false, err
660+}
661+
662+func createWorktreeGitRef(ctx context.Context, repoRoot, ref string) (string, bool, error) {
663+ output, err := createWorktreeGitOutput(ctx, repoRoot, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
664+ if err == nil {
665+ return output, true, nil
666+ }
667+ if createWorktreeGitExitCode(err) == 1 {
668+ return "", false, nil
669+ }
670+ return "", false, err
671+}
672+
673+func createWorktreeGitConfigValue(ctx context.Context, repoRoot, key string) (string, bool, error) {
674+ output, err := createWorktreeGitOutput(ctx, repoRoot, "config", "--local", "--get", key)
675+ if err == nil {
676+ return output, true, nil
677+ }
678+ if createWorktreeGitExitCode(err) == 1 {
679+ return "", false, nil
680+ }
681+ return "", false, err
682+}
683+
684+type createWorktreeGitError struct {
685+ args []string
686+ output string
687+ err error
688+}
689+
690+func (e *createWorktreeGitError) Error() string {
691+ message := strings.TrimSpace(e.output)
692+ if message == "" {
693+ message = e.err.Error()
694+ }
695+ return fmt.Sprintf("git %s: %s", strings.Join(e.args, " "), message)
696+}
697+
698+func (e *createWorktreeGitError) Unwrap() error { return e.err }
699+
700+func createWorktreeGitExitCode(err error) int {
701+ var exitErr *exec.ExitError
702+ if errors.As(err, &exitErr) {
703+ return exitErr.ExitCode()
704+ }
705+ return -1
706+}
707+
708+func createWorktreeGitOutput(ctx context.Context, dir string, args ...string) (string, error) {
709+ cmdArgs := append([]string{"-c", "core.hooksPath=" + os.DevNull, "-C", dir}, args...)
710+ cmd := exec.CommandContext(ctx, "git", cmdArgs...)
711+ cmd.Env = createWorktreeGitEnv()
712+ setProcessGroup(cmd)
713+
714+ var stdout, output cappedGitOutput
715+ cmd.Stdout = io.MultiWriter(&stdout, &output)
716+ cmd.Stderr = &output
717+ if err := cmd.Start(); err != nil {
718+ return "", fmt.Errorf("start git %s: %w", strings.Join(args, " "), err)
719+ }
720+ watchDone := make(chan struct{})
721+ watchStopped := make(chan struct{})
722+ go func() {
723+ defer close(watchStopped)
724+ select {
725+ case <-ctx.Done():
726+ killProcessGroup(cmd)
727+ case <-watchDone:
728+ }
729+ }()
730+ waitErr := cmd.Wait()
731+ close(watchDone)
732+ <-watchStopped
733+ if waitErr != nil {
734+ if ctx.Err() != nil {
735+ return "", ctx.Err()
736+ }
737+ return "", &createWorktreeGitError{args: args, output: output.String(), err: waitErr}
738+ }
739+ return strings.TrimSpace(stdout.String()), nil
740+}
741+
742+type cappedGitOutput struct {
743+ mu sync.Mutex
744+ buffer bytes.Buffer
745+ truncated bool
746+}
747+
748+func (b *cappedGitOutput) Write(p []byte) (int, error) {
749+ b.mu.Lock()
750+ defer b.mu.Unlock()
751+ if room := maxCreateWorktreeGitOutput - b.buffer.Len(); room > 0 {
752+ if len(p) > room {
753+ _, _ = b.buffer.Write(p[:room])
754+ b.truncated = true
755+ } else {
756+ _, _ = b.buffer.Write(p)
757+ }
758+ } else {
759+ b.truncated = true
760+ }
761+ return len(p), nil
762+}
763+
764+func (b *cappedGitOutput) String() string {
765+ b.mu.Lock()
766+ defer b.mu.Unlock()
767+ output := b.buffer.String()
768+ if b.truncated {
769+ output += "\n... [git output truncated]"
770+ }
771+ return output
772+}
773+
774+func createWorktreeGitEnv() []string {
775+ env := make([]string, 0, len(os.Environ())+5)
776+ for _, entry := range os.Environ() {
777+ key, _, _ := strings.Cut(entry, "=")
778+ if strings.HasPrefix(key, "GIT_") || key == "SSH_ASKPASS" {
779+ continue
780+ }
781+ env = append(env, entry)
782+ }
783+ return append(env,
784+ "GIT_CONFIG_NOSYSTEM=1",
785+ "GIT_CONFIG_GLOBAL="+os.DevNull,
786+ "GIT_TERMINAL_PROMPT=0",
787+ "GIT_ASKPASS=",
788+ "SSH_ASKPASS=",
789+ )
790+}
791+
792+type worktreesIgnoreUpdate struct {
793+ path string
794+ before []byte
795+ after []byte
796+ existed bool
797+ mode os.FileMode
798+ changed bool
799+}
800+
801+func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
802+ contents, mode, existed, err := readWorktreesIgnore(path)
803+ if err != nil {
804+ return worktreesIgnoreUpdate{}, err
805+ }
806+ for _, line := range strings.Split(string(contents), "\n") {
807+ if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
808+ return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
809+ }
810+ }
811+ lineEnding := "\n"
812+ if bytes.Contains(contents, []byte("\r\n")) {
813+ lineEnding = "\r\n"
814+ }
815+ after := append([]byte(nil), contents...)
816+ if len(after) > 0 && !strings.HasSuffix(string(after), "\n") {
817+ after = append(after, lineEnding...)
818+ }
819+ after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...)
820+ return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil
821+}
822+
823+func (u worktreesIgnoreUpdate) apply() error {
824+ if !u.changed {
825+ return nil
826+ }
827+ current, _, existed, err := readWorktreesIgnore(u.path)
828+ if err != nil {
829+ return err
830+ }
831+ if existed != u.existed || !bytes.Equal(current, u.before) {
832+ return errors.New(".gitignore changed since preview; inspect it and retry")
833+ }
834+ return writeWorktreesIgnore(u.path, u.after, u.mode)
835+}
836+
837+func (u worktreesIgnoreUpdate) restore() error {
838+ if !u.changed {
839+ return nil
840+ }
841+ current, _, existed, err := readWorktreesIgnore(u.path)
842+ if err != nil {
843+ return err
844+ }
845+ if !existed || !bytes.Equal(current, u.after) {
846+ return nil
847+ }
848+ if !u.existed {
849+ if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) {
850+ return err
851+ }
852+ return nil
853+ }
854+ return writeWorktreesIgnore(u.path, u.before, u.mode)
855+}
856+
857+func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) {
858+ info, err := os.Lstat(path)
859+ if os.IsNotExist(err) {
860+ return nil, 0o644, false, nil
861+ }
862+ if err != nil {
863+ return nil, 0, false, err
864+ }
865+ if info.Mode()&os.ModeSymlink != 0 {
866+ return nil, 0, false, errors.New(".gitignore must not be a symbolic link")
867+ }
868+ if !info.Mode().IsRegular() {
869+ return nil, 0, false, errors.New(".gitignore must be a regular file")
870+ }
871+ if info.Size() > maxCreateWorktreeIgnoreBytes {
872+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
873+ }
874+ file, err := os.Open(path)
875+ if err != nil {
876+ return nil, 0, false, err
877+ }
878+ defer file.Close()
879+ contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1))
880+ if err != nil {
881+ return nil, 0, false, err
882+ }
883+ if len(contents) > maxCreateWorktreeIgnoreBytes {
884+ return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes)
885+ }
886+ return contents, info.Mode().Perm(), true, nil
887+}
888+
889+func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error {
890+ file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*")
891+ if err != nil {
892+ return err
893+ }
894+ tempPath := file.Name()
895+ defer os.Remove(tempPath)
896+ if err := file.Chmod(mode); err != nil {
897+ _ = file.Close()
898+ return err
899+ }
900+ if _, err := file.Write(contents); err != nil {
901+ _ = file.Close()
902+ return err
903+ }
904+ if err := file.Close(); err != nil {
905+ return err
906+ }
907+ return os.Rename(tempPath, path)
908+}
A packages/agent/tools/create_worktree_test.go +812 -0
@@ -0,0 +1,812 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
19+ if _, err := exec.LookPath("git"); err != nil {
20+ t.Skip("git is required")
21+ }
22+ 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+ }
30+ branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31+ sandbox := NewSandbox(repo)
32+ sandbox.Lock()
33+
34+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
35+ args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40+ t.Fatal(err)
41+ }
42+
43+ worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")
44+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
45+ t.Fatalf("worktree %q was not created: %v", worktree, err)
46+ }
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")
49+ }
50+ if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
51+ t.Fatalf("source branch changed from %q to %q", branchBefore, got)
52+ }
53+ ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {
58+ t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)
59+ }
60+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61+ t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
62+ }
63+}
64+
65+func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
66+ if _, err := exec.LookPath("git"); err != nil {
67+ t.Skip("git is required")
68+ }
69+ repo := initWorktreeTestRepo(t)
70+ tool := newCreateWorktreeTestTool(repo)
71+
72+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ text := result.Content[0].(provider.TextBlock).Text
77+ if !strings.Contains(text, "Worktree bootstrap is required") {
78+ t.Fatalf("bootstrap result = %q", text)
79+ }
80+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {
81+ t.Fatalf("bootstrap state = %q", state)
82+ }
83+ for _, path := range []string{".worktrees", ".gitignore"} {
84+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
85+ t.Fatalf("bootstrap created %s: %v", path, statErr)
86+ }
87+ }
88+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
89+ t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
90+ }
91+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
92+ t.Fatalf("bootstrap created branch: %#v", got)
93+ }
94+}
95+
96+func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
97+ if _, err := exec.LookPath("git"); err != nil {
98+ t.Skip("git is required")
99+ }
100+ repo := initWorktreeTestRepo(t)
101+ if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
102+ t.Fatal(err)
103+ }
104+ tool := newCreateWorktreeTestTool(repo)
105+
106+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{
107+ "branch": "feature/conflicting-bootstrap",
108+ "bootstrap_root": t.TempDir(),
109+ }), nil)
110+ if err == nil || !strings.Contains(err.Error(), "already established") {
111+ t.Fatalf("error = %v, want established-root error", err)
112+ }
113+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
114+ t.Fatalf("conflicting bootstrap saved config: %#v", got)
115+ }
116+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {
117+ t.Fatalf("conflicting bootstrap created branch: %#v", got)
118+ }
119+}
120+
121+func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
122+ if _, err := exec.LookPath("git"); err != nil {
123+ t.Skip("git is required")
124+ }
125+ repo := initWorktreeTestRepo(t)
126+ externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees"))
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ tool := newCreateWorktreeTestTool(repo)
131+ args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})
132+
133+ result, err := tool.Execute(context.Background(), args, nil)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ worktree := filepath.Join(externalRoot, "feature", "external")
138+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
139+ t.Fatalf("external worktree %q was not created: %v", worktree, err)
140+ }
141+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
142+ t.Fatalf("external branch = %q", got)
143+ }
144+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {
145+ t.Fatalf("saved external root = %q, want %q", got, externalRoot)
146+ }
147+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
148+ t.Fatalf("external bootstrap changed .gitignore: %v", err)
149+ }
150+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {
151+ t.Fatalf("root source = %q", source)
152+ }
153+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
154+ t.Fatalf("state = %q, want created", state)
155+ }
156+ followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {
161+ t.Fatalf("configured external root was not reused: %v", err)
162+ }
163+ if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
164+ t.Fatalf("follow-up root source = %q", source)
165+ }
166+}
167+
168+func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {
169+ if _, err := exec.LookPath("git"); err != nil {
170+ t.Skip("git is required")
171+ }
172+ repo := initWorktreeTestRepo(t)
173+ tool := newCreateWorktreeTestTool(repo)
174+ for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {
175+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)
176+ if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {
177+ t.Fatalf("bootstrap_root %q error = %v", root, err)
178+ }
179+ }
180+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
181+ t.Fatalf("invalid bootstrap saved config: %#v", got)
182+ }
183+}
184+
185+func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
186+ if _, err := exec.LookPath("git"); err != nil {
187+ t.Skip("git is required")
188+ }
189+ repo := initWorktreeTestRepo(t)
190+ tool := newCreateWorktreeTestTool(repo)
191+ for _, branch := range []string{"-option", "topic@{1}"} {
192+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
193+ if err == nil || !strings.Contains(err.Error(), "invalid branch") {
194+ t.Fatalf("branch %q error = %v", branch, err)
195+ }
196+ }
197+}
198+
199+func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {
200+ if _, err := exec.LookPath("git"); err != nil {
201+ t.Skip("git is required")
202+ }
203+ repo := initWorktreeTestRepo(t)
204+ link := filepath.Join(t.TempDir(), "repository-link")
205+ if err := os.Symlink(repo, link); err != nil {
206+ t.Skipf("symbolic links are unavailable: %v", err)
207+ }
208+ root := filepath.Join(link, "external-worktrees")
209+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
210+ tool := newCreateWorktreeTestTool(repo)
211+
212+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
213+ if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {
214+ t.Fatalf("error = %v, want external-root error", err)
215+ }
216+}
217+
218+func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
219+ if runtime.GOOS == "windows" {
220+ t.Skip("Git hook fixture uses a POSIX shell")
221+ }
222+ if _, err := exec.LookPath("git"); err != nil {
223+ t.Skip("git is required")
224+ }
225+ repo := initWorktreeTestRepo(t)
226+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
227+ t.Fatal(err)
228+ }
229+ sentinel := filepath.Join(repo, "hook-ran")
230+ hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
231+ script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
232+ if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
233+ t.Fatal(err)
234+ }
235+ tool := newCreateWorktreeTestTool(repo)
236+
237+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
238+ t.Fatal(err)
239+ }
240+ worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")
241+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
242+ t.Fatalf("worktree %q was not created: %v", worktree, err)
243+ }
244+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
245+ t.Fatalf("post-checkout hook ran: %v", err)
246+ }
247+}
248+
249+func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
250+ if _, err := exec.LookPath("git"); err != nil {
251+ t.Skip("git is required")
252+ }
253+ repo := initWorktreeTestRepo(t)
254+ worktreePath := filepath.Join(repo, ".worktrees", "partial")
255+ if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
256+ t.Fatal(err)
257+ }
258+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
259+ gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base)
260+ plan := createWorktreePlan{
261+ repoRoot: repo,
262+ branch: "feature/partial",
263+ base: base,
264+ worktreePath: worktreePath,
265+ }
266+
267+ if err := plan.cleanupFailedCheckout(context.Background()); err != nil {
268+ t.Fatal(err)
269+ }
270+ if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
271+ t.Fatalf("partial worktree remains: %v", err)
272+ }
273+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 {
274+ t.Fatalf("partial branch remains: %#v", got)
275+ }
276+}
277+
278+func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) {
279+ if _, err := exec.LookPath("git"); err != nil {
280+ t.Skip("git is required")
281+ }
282+ repo := initWorktreeTestRepo(t)
283+ branch := "feature/checkout-failure"
284+ plan := createWorktreePlan{
285+ repoRoot: repo,
286+ branch: branch,
287+ base: "not-a-commit",
288+ worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),
289+ configValue: defaultWorktreeRoot,
290+ setConfig: true,
291+ }
292+
293+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
294+ if err == nil || !strings.Contains(err.Error(), "create worktree") {
295+ t.Fatalf("error = %v, want worktree creation error", err)
296+ }
297+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {
298+ t.Fatalf("failed checkout left worktree directories: %v", err)
299+ }
300+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
301+ t.Fatalf("failed checkout kept local config: %#v", got)
302+ }
303+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
304+ t.Fatalf("failed checkout created branch: %#v", got)
305+ }
306+}
307+
308+func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {
309+ if _, err := exec.LookPath("git"); err != nil {
310+ t.Skip("git is required")
311+ }
312+ repo := initWorktreeTestRepo(t)
313+ ignorePath := filepath.Join(repo, ".gitignore")
314+ before := []byte("generated/\n")
315+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
316+ t.Fatal(err)
317+ }
318+ if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {
319+ t.Fatal(err)
320+ }
321+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
322+ plan := createWorktreePlan{
323+ repoRoot: repo,
324+ branch: "feature/ignore-failure",
325+ base: base,
326+ worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
327+ configValue: defaultWorktreeRoot,
328+ setConfig: true,
329+ ignore: worktreesIgnoreUpdate{
330+ path: ignorePath,
331+ before: before,
332+ after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
333+ existed: true,
334+ mode: 0o644,
335+ changed: true,
336+ },
337+ }
338+
339+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
340+ if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
341+ t.Fatalf("error = %v, want stale .gitignore error", err)
342+ }
343+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
344+ t.Fatalf("failed bootstrap kept local config: %#v", got)
345+ }
346+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {
347+ t.Fatalf("failed bootstrap created branch: %#v", got)
348+ }
349+ contents, err := os.ReadFile(ignorePath)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ if got, want := string(contents), "changed by another process\n"; got != want {
354+ t.Fatalf(".gitignore = %q, want %q", got, want)
355+ }
356+}
357+
358+func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
359+ if _, err := exec.LookPath("git"); err != nil {
360+ t.Skip("git is required")
361+ }
362+ repo := initWorktreeTestRepo(t)
363+ ignorePath := filepath.Join(repo, ".gitignore")
364+ if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
365+ t.Fatal(err)
366+ }
367+ tool := newCreateWorktreeTestTool(repo)
368+
369+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
370+ t.Fatal(err)
371+ }
372+ ignore, err := os.ReadFile(ignorePath)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
377+ t.Fatalf(".gitignore = %q, want %q", got, want)
378+ }
379+}
380+
381+func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
382+ if _, err := exec.LookPath("git"); err != nil {
383+ t.Skip("git is required")
384+ }
385+ repo := initWorktreeTestRepo(t)
386+ target := filepath.Join(repo, "ignore-target")
387+ if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
388+ t.Fatal(err)
389+ }
390+ ignorePath := filepath.Join(repo, ".gitignore")
391+ if err := os.Symlink(target, ignorePath); err != nil {
392+ t.Skipf("symbolic links are unavailable: %v", err)
393+ }
394+ tool := newCreateWorktreeTestTool(repo)
395+
396+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
397+ if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
398+ t.Fatalf("error = %v, want symbolic-link error", err)
399+ }
400+ info, err := os.Lstat(ignorePath)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if info.Mode()&os.ModeSymlink == 0 {
405+ t.Fatal("tool replaced .gitignore symbolic link")
406+ }
407+ if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
408+ t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
409+ }
410+}
411+
412+func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
413+ if _, err := exec.LookPath("git"); err != nil {
414+ t.Skip("git is required")
415+ }
416+ repo := initWorktreeTestRepo(t)
417+ if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
418+ t.Fatal(err)
419+ }
420+ tool := newCreateWorktreeTestTool(repo)
421+
422+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
423+ if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
424+ t.Fatalf("error = %v, want regular-file error", err)
425+ }
426+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
427+ t.Fatalf("non-regular ignore saved config: %#v", got)
428+ }
429+}
430+
431+func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
432+ if runtime.GOOS == "windows" {
433+ t.Skip("Git fixture uses a POSIX shell")
434+ }
435+ binDir := t.TempDir()
436+ gitPath := filepath.Join(binDir, "git")
437+ if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
438+ t.Fatal(err)
439+ }
440+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
441+
442+ output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
443+ if err != nil {
444+ t.Fatal(err)
445+ }
446+ if output != "value" {
447+ t.Fatalf("output = %q, want value", output)
448+ }
449+}
450+
451+func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
452+ if runtime.GOOS == "windows" {
453+ t.Skip("process-group fixture uses a POSIX shell")
454+ }
455+ binDir := t.TempDir()
456+ started := filepath.Join(t.TempDir(), "git-started")
457+ childPID := filepath.Join(t.TempDir(), "child-pid")
458+ gitPath := filepath.Join(binDir, "git")
459+ script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
460+ if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
461+ t.Fatal(err)
462+ }
463+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
464+ ctx, cancel := context.WithCancel(context.Background())
465+ defer cancel()
466+ cwd := t.TempDir()
467+ done := make(chan error, 1)
468+ go func() {
469+ _, err := createWorktreeGitOutput(ctx, cwd, "status")
470+ done <- err
471+ }()
472+
473+ deadline := time.NewTimer(5 * time.Second)
474+ defer deadline.Stop()
475+ poll := time.NewTicker(10 * time.Millisecond)
476+ defer poll.Stop()
477+ for {
478+ if _, err := os.Stat(started); err == nil {
479+ break
480+ } else if !os.IsNotExist(err) {
481+ t.Fatal(err)
482+ }
483+ select {
484+ case <-deadline.C:
485+ t.Fatal("fake git did not start")
486+ case <-poll.C:
487+ }
488+ }
489+ cancel()
490+ select {
491+ case err := <-done:
492+ if !errors.Is(err, context.Canceled) {
493+ t.Fatalf("error = %v, want context canceled", err)
494+ }
495+ case <-time.After(5 * time.Second):
496+ t.Fatal("Git process group did not exit after cancellation")
497+ }
498+ pid, err := os.ReadFile(childPID)
499+ if err != nil {
500+ t.Fatal(err)
501+ }
502+ exitDeadline := time.NewTimer(5 * time.Second)
503+ defer exitDeadline.Stop()
504+ for {
505+ err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
506+ if err != nil {
507+ var exitErr *exec.ExitError
508+ if errors.As(err, &exitErr) {
509+ break
510+ }
511+ t.Fatalf("check child process: %v", err)
512+ }
513+ select {
514+ case <-exitDeadline.C:
515+ t.Fatalf("Git descendant %q remained after cancellation", pid)
516+ case <-poll.C:
517+ }
518+ }
519+}
520+
521+func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
522+ if _, err := exec.LookPath("git"); err != nil {
523+ t.Skip("git is required")
524+ }
525+ repo := initWorktreeTestRepo(t)
526+ tool := newCreateWorktreeTestTool(repo)
527+ args := json.RawMessage(`{"branch":"feature/preview"}`)
528+
529+ preview, err := tool.Preview(context.Background(), args)
530+ if err != nil {
531+ t.Fatal(err)
532+ }
533+ text := preview.Content[0].(provider.TextBlock).Text
534+ for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
535+ if !strings.Contains(text, want) {
536+ t.Fatalf("preview missing %q:\n%s", want, text)
537+ }
538+ }
539+ if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
540+ t.Fatalf("preview created .worktrees: %v", err)
541+ }
542+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
543+ t.Fatalf("preview created .gitignore: %v", err)
544+ }
545+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
546+ if err := cmd.Run(); err == nil {
547+ t.Fatal("preview created the branch")
548+ }
549+}
550+
551+func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
552+ if _, err := exec.LookPath("git"); err != nil {
553+ t.Skip("git is required")
554+ }
555+ repo := initWorktreeTestRepo(t)
556+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
557+ t.Fatal(err)
558+ }
559+ tool := newCreateWorktreeTestTool(repo)
560+ args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
561+ if _, err := tool.Preview(context.Background(), args); err != nil {
562+ t.Fatal(err)
563+ }
564+ if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
565+ t.Fatal(err)
566+ }
567+ gitTestOutput(t, repo, "add", "after-preview.txt")
568+ gitTestOutput(t, repo, "commit", "-qm", "change after preview")
569+
570+ _, err := tool.Execute(context.Background(), args, nil)
571+ if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
572+ t.Fatalf("error = %v, want stale-preview error", err)
573+ }
574+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
575+ t.Fatalf("stale preview created branch: %#v", got)
576+ }
577+}
578+
579+func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
580+ dir := t.TempDir()
581+ tool := newCreateWorktreeTestTool(dir)
582+
583+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
584+ if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
585+ t.Fatalf("error = %v, want repository error", err)
586+ }
587+ for _, path := range []string{".worktrees", ".gitignore"} {
588+ if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
589+ t.Fatalf("non-repository call created %s: %v", path, statErr)
590+ }
591+ }
592+}
593+
594+func TestCreateWorktreeRequiresSandbox(t *testing.T) {
595+ tool := &CreateWorktreeTool{CWD: t.TempDir()}
596+
597+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
598+ if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
599+ t.Fatalf("error = %v, want sandbox error", err)
600+ }
601+}
602+
603+func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
604+ if _, err := exec.LookPath("git"); err != nil {
605+ t.Skip("git is required")
606+ }
607+ repo := initWorktreeTestRepo(t)
608+ tool := newCreateWorktreeTestTool(repo)
609+ bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
610+ if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
611+ t.Fatal(err)
612+ }
613+ ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
614+ if err != nil {
615+ t.Fatal(err)
616+ }
617+
618+ _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
619+ if err == nil || !strings.Contains(err.Error(), "already exists") {
620+ t.Fatalf("error = %v, want collision error", err)
621+ }
622+ ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
623+ if err != nil {
624+ t.Fatal(err)
625+ }
626+ if string(ignoreAfter) != string(ignoreBefore) {
627+ t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
628+ }
629+}
630+
631+func TestCreateWorktreeRespectsJail(t *testing.T) {
632+ if _, err := exec.LookPath("git"); err != nil {
633+ t.Skip("git is required")
634+ }
635+ repo := initWorktreeTestRepo(t)
636+ nested := filepath.Join(repo, "nested")
637+ if err := os.Mkdir(nested, 0o755); err != nil {
638+ t.Fatal(err)
639+ }
640+ sandbox := NewSandbox(nested)
641+ sandbox.Lock()
642+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
643+
644+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
645+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
646+ t.Fatalf("error = %v, want jailed error", err)
647+ }
648+ for _, path := range []string{".worktrees", ".gitignore"} {
649+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
650+ t.Fatalf("jailed call created %s: %v", path, statErr)
651+ }
652+ }
653+}
654+
655+func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
656+ if runtime.GOOS == "windows" {
657+ t.Skip("Git invocation fixture uses a POSIX shell")
658+ }
659+ if _, err := exec.LookPath("git"); err != nil {
660+ t.Skip("git is required")
661+ }
662+ repo := initWorktreeTestRepo(t)
663+ nested := filepath.Join(repo, "nested")
664+ if err := os.Mkdir(nested, 0o755); err != nil {
665+ t.Fatal(err)
666+ }
667+ sentinel := filepath.Join(repo, "git-was-invoked")
668+ binDir := t.TempDir()
669+ fakeGit := filepath.Join(binDir, "git")
670+ if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
671+ t.Fatal(err)
672+ }
673+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
674+ sandbox := NewSandbox(nested)
675+ sandbox.Lock()
676+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
677+
678+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
679+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
680+ t.Fatalf("error = %v, want jailed error", err)
681+ }
682+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
683+ t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
684+ }
685+}
686+
687+func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
688+ if _, err := exec.LookPath("git"); err != nil {
689+ t.Skip("git is required")
690+ }
691+ repo := initWorktreeTestRepo(t)
692+ sandbox := NewSandbox(repo)
693+ permissions := &PermissionSet{}
694+ permissions.Bash.Mode = "allowlist"
695+ permissions.Bash.Allow = []string{"git"}
696+ sandbox.SetPermissions(permissions)
697+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
698+
699+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
700+ if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
701+ t.Fatalf("error = %v, want filesystem permission error", err)
702+ }
703+ for _, path := range []string{".worktrees", ".gitignore"} {
704+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
705+ t.Fatalf("restricted call created %s: %v", path, statErr)
706+ }
707+ }
708+}
709+
710+func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
711+ if _, err := exec.LookPath("git"); err != nil {
712+ t.Skip("git is required")
713+ }
714+ repo := initWorktreeTestRepo(t)
715+ sandbox := NewSandbox(repo)
716+ permissions := &PermissionSet{}
717+ permissions.FS.Read = []string{repo}
718+ permissions.FS.Write = []string{repo}
719+ sandbox.SetPermissions(permissions)
720+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
721+
722+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
723+ if err == nil || !strings.Contains(err.Error(), "no bash permission") {
724+ t.Fatalf("error = %v, want git permission error", err)
725+ }
726+ for _, path := range []string{".worktrees", ".gitignore"} {
727+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
728+ t.Fatalf("restricted call created %s: %v", path, statErr)
729+ }
730+ }
731+}
732+
733+func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
734+ if _, err := exec.LookPath("git"); err != nil {
735+ t.Skip("git is required")
736+ }
737+ repo := initWorktreeTestRepo(t)
738+ worktrees := filepath.Join(repo, ".worktrees")
739+ if err := os.Mkdir(worktrees, 0o755); err != nil {
740+ t.Fatal(err)
741+ }
742+ if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
743+ t.Fatal(err)
744+ }
745+ tool := newCreateWorktreeTestTool(repo)
746+
747+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
748+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
749+ t.Fatalf("error = %v, want non-directory path error", err)
750+ }
751+ if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
752+ t.Fatalf("invalid path left .gitignore behind: %v", statErr)
753+ }
754+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
755+ if err := cmd.Run(); err == nil {
756+ t.Fatal("invalid path created the branch")
757+ }
758+}
759+
760+func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
761+ return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
762+}
763+
764+func initWorktreeTestRepo(t *testing.T) string {
765+ t.Helper()
766+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
767+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
768+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
769+ repo := t.TempDir()
770+ gitTestOutput(t, repo, "init", "-q")
771+ gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
772+ gitTestOutput(t, repo, "config", "user.name", "Test User")
773+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
774+ t.Fatal(err)
775+ }
776+ gitTestOutput(t, repo, "add", "README.md")
777+ gitTestOutput(t, repo, "commit", "-qm", "initial")
778+ return repo
779+}
780+
781+func shellQuoteForTest(value string) string {
782+ return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
783+}
784+
785+type gitTestResult struct {
786+ output string
787+ exitCode int
788+}
789+
790+func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
791+ t.Helper()
792+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
793+ output, err := cmd.CombinedOutput()
794+ if err == nil {
795+ return gitTestResult{output: strings.TrimSpace(string(output))}
796+ }
797+ if exitErr, ok := err.(*exec.ExitError); ok {
798+ return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
799+ }
800+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
801+ return gitTestResult{}
802+}
803+
804+func gitTestOutput(t *testing.T, dir string, args ...string) string {
805+ t.Helper()
806+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
807+ output, err := cmd.CombinedOutput()
808+ if err != nil {
809+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
810+ }
811+ return strings.TrimSpace(string(output))
812+}
@@ -0,0 +1,812 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "os"
8+ "os/exec"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/zut/packages/provider"
16+)
17+
18+func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) {
19+ if _, err := exec.LookPath("git"); err != nil {
20+ t.Skip("git is required")
21+ }
22+ 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+ }
30+ branchBefore := gitTestOutput(t, repo, "branch", "--show-current")
31+ sandbox := NewSandbox(repo)
32+ sandbox.Lock()
33+
34+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
35+ args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"})
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if _, err := tool.Execute(context.Background(), args, nil); err != nil {
40+ t.Fatal(err)
41+ }
42+
43+ worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout")
44+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
45+ t.Fatalf("worktree %q was not created: %v", worktree, err)
46+ }
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")
49+ }
50+ if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore {
51+ t.Fatalf("source branch changed from %q to %q", branchBefore, got)
52+ }
53+ ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 {
58+ t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore)
59+ }
60+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot {
61+ t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot)
62+ }
63+}
64+
65+func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) {
66+ if _, err := exec.LookPath("git"); err != nil {
67+ t.Skip("git is required")
68+ }
69+ repo := initWorktreeTestRepo(t)
70+ tool := newCreateWorktreeTestTool(repo)
71+
72+ result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil)
73+ if err != nil {
74+ t.Fatal(err)
75+ }
76+ text := result.Content[0].(provider.TextBlock).Text
77+ if !strings.Contains(text, "Worktree bootstrap is required") {
78+ t.Fatalf("bootstrap result = %q", text)
79+ }
80+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" {
81+ t.Fatalf("bootstrap state = %q", state)
82+ }
83+ for _, path := range []string{".worktrees", ".gitignore"} {
84+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
85+ t.Fatalf("bootstrap created %s: %v", path, statErr)
86+ }
87+ }
88+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
89+ t.Fatalf("local worktree config exit = %d, want 1", got.exitCode)
90+ }
91+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 {
92+ t.Fatalf("bootstrap created branch: %#v", got)
93+ }
94+}
95+
96+func TestCreateWorktreeRejectsBootstrapRootWhenDefaultRootExists(t *testing.T) {
97+ if _, err := exec.LookPath("git"); err != nil {
98+ t.Skip("git is required")
99+ }
100+ repo := initWorktreeTestRepo(t)
101+ if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil {
102+ t.Fatal(err)
103+ }
104+ tool := newCreateWorktreeTestTool(repo)
105+
106+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{
107+ "branch": "feature/conflicting-bootstrap",
108+ "bootstrap_root": t.TempDir(),
109+ }), nil)
110+ if err == nil || !strings.Contains(err.Error(), "already established") {
111+ t.Fatalf("error = %v, want established-root error", err)
112+ }
113+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
114+ t.Fatalf("conflicting bootstrap saved config: %#v", got)
115+ }
116+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 {
117+ t.Fatalf("conflicting bootstrap created branch: %#v", got)
118+ }
119+}
120+
121+func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) {
122+ if _, err := exec.LookPath("git"); err != nil {
123+ t.Skip("git is required")
124+ }
125+ repo := initWorktreeTestRepo(t)
126+ externalRoot, err := canonicalOrParent(filepath.Join(t.TempDir(), "company-worktrees"))
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ tool := newCreateWorktreeTestTool(repo)
131+ args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot})
132+
133+ result, err := tool.Execute(context.Background(), args, nil)
134+ if err != nil {
135+ t.Fatal(err)
136+ }
137+ worktree := filepath.Join(externalRoot, "feature", "external")
138+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
139+ t.Fatalf("external worktree %q was not created: %v", worktree, err)
140+ }
141+ if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" {
142+ t.Fatalf("external branch = %q", got)
143+ }
144+ if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot {
145+ t.Fatalf("saved external root = %q, want %q", got, externalRoot)
146+ }
147+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
148+ t.Fatalf("external bootstrap changed .gitignore: %v", err)
149+ }
150+ if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" {
151+ t.Fatalf("root source = %q", source)
152+ }
153+ if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" {
154+ t.Fatalf("state = %q, want created", state)
155+ }
156+ followup, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external-followup"}`), nil)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil {
161+ t.Fatalf("configured external root was not reused: %v", err)
162+ }
163+ if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" {
164+ t.Fatalf("follow-up root source = %q", source)
165+ }
166+}
167+
168+func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) {
169+ if _, err := exec.LookPath("git"); err != nil {
170+ t.Skip("git is required")
171+ }
172+ repo := initWorktreeTestRepo(t)
173+ tool := newCreateWorktreeTestTool(repo)
174+ for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} {
175+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil)
176+ if err == nil || !strings.Contains(err.Error(), "bootstrap_root") {
177+ t.Fatalf("bootstrap_root %q error = %v", root, err)
178+ }
179+ }
180+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
181+ t.Fatalf("invalid bootstrap saved config: %#v", got)
182+ }
183+}
184+
185+func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) {
186+ if _, err := exec.LookPath("git"); err != nil {
187+ t.Skip("git is required")
188+ }
189+ repo := initWorktreeTestRepo(t)
190+ tool := newCreateWorktreeTestTool(repo)
191+ for _, branch := range []string{"-option", "topic@{1}"} {
192+ _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil)
193+ if err == nil || !strings.Contains(err.Error(), "invalid branch") {
194+ t.Fatalf("branch %q error = %v", branch, err)
195+ }
196+ }
197+}
198+
199+func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) {
200+ if _, err := exec.LookPath("git"); err != nil {
201+ t.Skip("git is required")
202+ }
203+ repo := initWorktreeTestRepo(t)
204+ link := filepath.Join(t.TempDir(), "repository-link")
205+ if err := os.Symlink(repo, link); err != nil {
206+ t.Skipf("symbolic links are unavailable: %v", err)
207+ }
208+ root := filepath.Join(link, "external-worktrees")
209+ gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root)
210+ tool := newCreateWorktreeTestTool(repo)
211+
212+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil)
213+ if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") {
214+ t.Fatalf("error = %v, want external-root error", err)
215+ }
216+}
217+
218+func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) {
219+ if runtime.GOOS == "windows" {
220+ t.Skip("Git hook fixture uses a POSIX shell")
221+ }
222+ if _, err := exec.LookPath("git"); err != nil {
223+ t.Skip("git is required")
224+ }
225+ repo := initWorktreeTestRepo(t)
226+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
227+ t.Fatal(err)
228+ }
229+ sentinel := filepath.Join(repo, "hook-ran")
230+ hook := filepath.Join(repo, ".git", "hooks", "post-checkout")
231+ script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n"
232+ if err := os.WriteFile(hook, []byte(script), 0o755); err != nil {
233+ t.Fatal(err)
234+ }
235+ tool := newCreateWorktreeTestTool(repo)
236+
237+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil {
238+ t.Fatal(err)
239+ }
240+ worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook")
241+ if info, err := os.Stat(worktree); err != nil || !info.IsDir() {
242+ t.Fatalf("worktree %q was not created: %v", worktree, err)
243+ }
244+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
245+ t.Fatalf("post-checkout hook ran: %v", err)
246+ }
247+}
248+
249+func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) {
250+ if _, err := exec.LookPath("git"); err != nil {
251+ t.Skip("git is required")
252+ }
253+ repo := initWorktreeTestRepo(t)
254+ worktreePath := filepath.Join(repo, ".worktrees", "partial")
255+ if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil {
256+ t.Fatal(err)
257+ }
258+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
259+ gitTestOutput(t, repo, "worktree", "add", "-b", "feature/partial", worktreePath, base)
260+ plan := createWorktreePlan{
261+ repoRoot: repo,
262+ branch: "feature/partial",
263+ base: base,
264+ worktreePath: worktreePath,
265+ }
266+
267+ if err := plan.cleanupFailedCheckout(context.Background()); err != nil {
268+ t.Fatal(err)
269+ }
270+ if _, err := os.Stat(worktreePath); !os.IsNotExist(err) {
271+ t.Fatalf("partial worktree remains: %v", err)
272+ }
273+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/partial"); got.exitCode != 1 {
274+ t.Fatalf("partial branch remains: %#v", got)
275+ }
276+}
277+
278+func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *testing.T) {
279+ if _, err := exec.LookPath("git"); err != nil {
280+ t.Skip("git is required")
281+ }
282+ repo := initWorktreeTestRepo(t)
283+ branch := "feature/checkout-failure"
284+ plan := createWorktreePlan{
285+ repoRoot: repo,
286+ branch: branch,
287+ base: "not-a-commit",
288+ worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"),
289+ configValue: defaultWorktreeRoot,
290+ setConfig: true,
291+ }
292+
293+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
294+ if err == nil || !strings.Contains(err.Error(), "create worktree") {
295+ t.Fatalf("error = %v, want worktree creation error", err)
296+ }
297+ if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) {
298+ t.Fatalf("failed checkout left worktree directories: %v", err)
299+ }
300+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
301+ t.Fatalf("failed checkout kept local config: %#v", got)
302+ }
303+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 {
304+ t.Fatalf("failed checkout created branch: %#v", got)
305+ }
306+}
307+
308+func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) {
309+ if _, err := exec.LookPath("git"); err != nil {
310+ t.Skip("git is required")
311+ }
312+ repo := initWorktreeTestRepo(t)
313+ ignorePath := filepath.Join(repo, ".gitignore")
314+ before := []byte("generated/\n")
315+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
316+ t.Fatal(err)
317+ }
318+ if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil {
319+ t.Fatal(err)
320+ }
321+ base := gitTestOutput(t, repo, "rev-parse", "HEAD")
322+ plan := createWorktreePlan{
323+ repoRoot: repo,
324+ branch: "feature/ignore-failure",
325+ base: base,
326+ worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"),
327+ configValue: defaultWorktreeRoot,
328+ setConfig: true,
329+ ignore: worktreesIgnoreUpdate{
330+ path: ignorePath,
331+ before: before,
332+ after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...),
333+ existed: true,
334+ mode: 0o644,
335+ changed: true,
336+ },
337+ }
338+
339+ _, err := executeCreateWorktreePlan(context.Background(), plan, nil)
340+ if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") {
341+ t.Fatalf("error = %v, want stale .gitignore error", err)
342+ }
343+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
344+ t.Fatalf("failed bootstrap kept local config: %#v", got)
345+ }
346+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 {
347+ t.Fatalf("failed bootstrap created branch: %#v", got)
348+ }
349+ contents, err := os.ReadFile(ignorePath)
350+ if err != nil {
351+ t.Fatal(err)
352+ }
353+ if got, want := string(contents), "changed by another process\n"; got != want {
354+ t.Fatalf(".gitignore = %q, want %q", got, want)
355+ }
356+}
357+
358+func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
359+ if _, err := exec.LookPath("git"); err != nil {
360+ t.Skip("git is required")
361+ }
362+ repo := initWorktreeTestRepo(t)
363+ ignorePath := filepath.Join(repo, ".gitignore")
364+ if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil {
365+ t.Fatal(err)
366+ }
367+ tool := newCreateWorktreeTestTool(repo)
368+
369+ if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil {
370+ t.Fatal(err)
371+ }
372+ ignore, err := os.ReadFile(ignorePath)
373+ if err != nil {
374+ t.Fatal(err)
375+ }
376+ if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want {
377+ t.Fatalf(".gitignore = %q, want %q", got, want)
378+ }
379+}
380+
381+func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) {
382+ if _, err := exec.LookPath("git"); err != nil {
383+ t.Skip("git is required")
384+ }
385+ repo := initWorktreeTestRepo(t)
386+ target := filepath.Join(repo, "ignore-target")
387+ if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil {
388+ t.Fatal(err)
389+ }
390+ ignorePath := filepath.Join(repo, ".gitignore")
391+ if err := os.Symlink(target, ignorePath); err != nil {
392+ t.Skipf("symbolic links are unavailable: %v", err)
393+ }
394+ tool := newCreateWorktreeTestTool(repo)
395+
396+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil)
397+ if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") {
398+ t.Fatalf("error = %v, want symbolic-link error", err)
399+ }
400+ info, err := os.Lstat(ignorePath)
401+ if err != nil {
402+ t.Fatal(err)
403+ }
404+ if info.Mode()&os.ModeSymlink == 0 {
405+ t.Fatal("tool replaced .gitignore symbolic link")
406+ }
407+ if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) {
408+ t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr)
409+ }
410+}
411+
412+func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) {
413+ if _, err := exec.LookPath("git"); err != nil {
414+ t.Skip("git is required")
415+ }
416+ repo := initWorktreeTestRepo(t)
417+ if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil {
418+ t.Fatal(err)
419+ }
420+ tool := newCreateWorktreeTestTool(repo)
421+
422+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil)
423+ if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") {
424+ t.Fatalf("error = %v, want regular-file error", err)
425+ }
426+ if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 {
427+ t.Fatalf("non-regular ignore saved config: %#v", got)
428+ }
429+}
430+
431+func TestCreateWorktreeGitOutputKeepsStderrOutOfSuccessfulOutput(t *testing.T) {
432+ if runtime.GOOS == "windows" {
433+ t.Skip("Git fixture uses a POSIX shell")
434+ }
435+ binDir := t.TempDir()
436+ gitPath := filepath.Join(binDir, "git")
437+ if err := os.WriteFile(gitPath, []byte("#!/bin/sh\nprintf 'value\\n'\nprintf 'warning\\n' >&2\n"), 0o755); err != nil {
438+ t.Fatal(err)
439+ }
440+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
441+
442+ output, err := createWorktreeGitOutput(context.Background(), t.TempDir(), "status")
443+ if err != nil {
444+ t.Fatal(err)
445+ }
446+ if output != "value" {
447+ t.Fatalf("output = %q, want value", output)
448+ }
449+}
450+
451+func TestCreateWorktreeGitCancelsDescendants(t *testing.T) {
452+ if runtime.GOOS == "windows" {
453+ t.Skip("process-group fixture uses a POSIX shell")
454+ }
455+ binDir := t.TempDir()
456+ started := filepath.Join(t.TempDir(), "git-started")
457+ childPID := filepath.Join(t.TempDir(), "child-pid")
458+ gitPath := filepath.Join(binDir, "git")
459+ script := "#!/bin/sh\nsleep 30 &\nprintf '%s' \"$!\" > " + shellQuoteForTest(childPID) + "\nprintf started > " + shellQuoteForTest(started) + "\nwait\n"
460+ if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
461+ t.Fatal(err)
462+ }
463+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
464+ ctx, cancel := context.WithCancel(context.Background())
465+ defer cancel()
466+ cwd := t.TempDir()
467+ done := make(chan error, 1)
468+ go func() {
469+ _, err := createWorktreeGitOutput(ctx, cwd, "status")
470+ done <- err
471+ }()
472+
473+ deadline := time.NewTimer(5 * time.Second)
474+ defer deadline.Stop()
475+ poll := time.NewTicker(10 * time.Millisecond)
476+ defer poll.Stop()
477+ for {
478+ if _, err := os.Stat(started); err == nil {
479+ break
480+ } else if !os.IsNotExist(err) {
481+ t.Fatal(err)
482+ }
483+ select {
484+ case <-deadline.C:
485+ t.Fatal("fake git did not start")
486+ case <-poll.C:
487+ }
488+ }
489+ cancel()
490+ select {
491+ case err := <-done:
492+ if !errors.Is(err, context.Canceled) {
493+ t.Fatalf("error = %v, want context canceled", err)
494+ }
495+ case <-time.After(5 * time.Second):
496+ t.Fatal("Git process group did not exit after cancellation")
497+ }
498+ pid, err := os.ReadFile(childPID)
499+ if err != nil {
500+ t.Fatal(err)
501+ }
502+ exitDeadline := time.NewTimer(5 * time.Second)
503+ defer exitDeadline.Stop()
504+ for {
505+ err := exec.Command("kill", "-0", strings.TrimSpace(string(pid))).Run()
506+ if err != nil {
507+ var exitErr *exec.ExitError
508+ if errors.As(err, &exitErr) {
509+ break
510+ }
511+ t.Fatalf("check child process: %v", err)
512+ }
513+ select {
514+ case <-exitDeadline.C:
515+ t.Fatalf("Git descendant %q remained after cancellation", pid)
516+ case <-poll.C:
517+ }
518+ }
519+}
520+
521+func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) {
522+ if _, err := exec.LookPath("git"); err != nil {
523+ t.Skip("git is required")
524+ }
525+ repo := initWorktreeTestRepo(t)
526+ tool := newCreateWorktreeTestTool(repo)
527+ args := json.RawMessage(`{"branch":"feature/preview"}`)
528+
529+ preview, err := tool.Preview(context.Background(), args)
530+ if err != nil {
531+ t.Fatal(err)
532+ }
533+ text := preview.Content[0].(provider.TextBlock).Text
534+ for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} {
535+ if !strings.Contains(text, want) {
536+ t.Fatalf("preview missing %q:\n%s", want, text)
537+ }
538+ }
539+ if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) {
540+ t.Fatalf("preview created .worktrees: %v", err)
541+ }
542+ if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) {
543+ t.Fatalf("preview created .gitignore: %v", err)
544+ }
545+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview")
546+ if err := cmd.Run(); err == nil {
547+ t.Fatal("preview created the branch")
548+ }
549+}
550+
551+func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) {
552+ if _, err := exec.LookPath("git"); err != nil {
553+ t.Skip("git is required")
554+ }
555+ repo := initWorktreeTestRepo(t)
556+ if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil {
557+ t.Fatal(err)
558+ }
559+ tool := newCreateWorktreeTestTool(repo)
560+ args := json.RawMessage(`{"branch":"feature/stale-preview"}`)
561+ if _, err := tool.Preview(context.Background(), args); err != nil {
562+ t.Fatal(err)
563+ }
564+ if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil {
565+ t.Fatal(err)
566+ }
567+ gitTestOutput(t, repo, "add", "after-preview.txt")
568+ gitTestOutput(t, repo, "commit", "-qm", "change after preview")
569+
570+ _, err := tool.Execute(context.Background(), args, nil)
571+ if err == nil || !strings.Contains(err.Error(), "state changed since preview") {
572+ t.Fatalf("error = %v, want stale-preview error", err)
573+ }
574+ if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 {
575+ t.Fatalf("stale preview created branch: %#v", got)
576+ }
577+}
578+
579+func TestCreateWorktreeRejectsNonRepository(t *testing.T) {
580+ dir := t.TempDir()
581+ tool := newCreateWorktreeTestTool(dir)
582+
583+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil)
584+ if err == nil || !strings.Contains(err.Error(), "not a Git repository") {
585+ t.Fatalf("error = %v, want repository error", err)
586+ }
587+ for _, path := range []string{".worktrees", ".gitignore"} {
588+ if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) {
589+ t.Fatalf("non-repository call created %s: %v", path, statErr)
590+ }
591+ }
592+}
593+
594+func TestCreateWorktreeRequiresSandbox(t *testing.T) {
595+ tool := &CreateWorktreeTool{CWD: t.TempDir()}
596+
597+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil)
598+ if err == nil || !strings.Contains(err.Error(), "sandbox is required") {
599+ t.Fatalf("error = %v, want sandbox error", err)
600+ }
601+}
602+
603+func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) {
604+ if _, err := exec.LookPath("git"); err != nil {
605+ t.Skip("git is required")
606+ }
607+ repo := initWorktreeTestRepo(t)
608+ tool := newCreateWorktreeTestTool(repo)
609+ bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`)
610+ if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil {
611+ t.Fatal(err)
612+ }
613+ ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
614+ if err != nil {
615+ t.Fatal(err)
616+ }
617+
618+ _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil)
619+ if err == nil || !strings.Contains(err.Error(), "already exists") {
620+ t.Fatalf("error = %v, want collision error", err)
621+ }
622+ ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore"))
623+ if err != nil {
624+ t.Fatal(err)
625+ }
626+ if string(ignoreAfter) != string(ignoreBefore) {
627+ t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter)
628+ }
629+}
630+
631+func TestCreateWorktreeRespectsJail(t *testing.T) {
632+ if _, err := exec.LookPath("git"); err != nil {
633+ t.Skip("git is required")
634+ }
635+ repo := initWorktreeTestRepo(t)
636+ nested := filepath.Join(repo, "nested")
637+ if err := os.Mkdir(nested, 0o755); err != nil {
638+ t.Fatal(err)
639+ }
640+ sandbox := NewSandbox(nested)
641+ sandbox.Lock()
642+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
643+
644+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil)
645+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
646+ t.Fatalf("error = %v, want jailed error", err)
647+ }
648+ for _, path := range []string{".worktrees", ".gitignore"} {
649+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
650+ t.Fatalf("jailed call created %s: %v", path, statErr)
651+ }
652+ }
653+}
654+
655+func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) {
656+ if runtime.GOOS == "windows" {
657+ t.Skip("Git invocation fixture uses a POSIX shell")
658+ }
659+ if _, err := exec.LookPath("git"); err != nil {
660+ t.Skip("git is required")
661+ }
662+ repo := initWorktreeTestRepo(t)
663+ nested := filepath.Join(repo, "nested")
664+ if err := os.Mkdir(nested, 0o755); err != nil {
665+ t.Fatal(err)
666+ }
667+ sentinel := filepath.Join(repo, "git-was-invoked")
668+ binDir := t.TempDir()
669+ fakeGit := filepath.Join(binDir, "git")
670+ if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil {
671+ t.Fatal(err)
672+ }
673+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
674+ sandbox := NewSandbox(nested)
675+ sandbox.Lock()
676+ tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox}
677+
678+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil)
679+ if err == nil || !strings.Contains(err.Error(), "jailed:") {
680+ t.Fatalf("error = %v, want jailed error", err)
681+ }
682+ if _, err := os.Stat(sentinel); !os.IsNotExist(err) {
683+ t.Fatalf("Git ran before jailed ancestor was rejected: %v", err)
684+ }
685+}
686+
687+func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) {
688+ if _, err := exec.LookPath("git"); err != nil {
689+ t.Skip("git is required")
690+ }
691+ repo := initWorktreeTestRepo(t)
692+ sandbox := NewSandbox(repo)
693+ permissions := &PermissionSet{}
694+ permissions.Bash.Mode = "allowlist"
695+ permissions.Bash.Allow = []string{"git"}
696+ sandbox.SetPermissions(permissions)
697+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
698+
699+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil)
700+ if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") {
701+ t.Fatalf("error = %v, want filesystem permission error", err)
702+ }
703+ for _, path := range []string{".worktrees", ".gitignore"} {
704+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
705+ t.Fatalf("restricted call created %s: %v", path, statErr)
706+ }
707+ }
708+}
709+
710+func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) {
711+ if _, err := exec.LookPath("git"); err != nil {
712+ t.Skip("git is required")
713+ }
714+ repo := initWorktreeTestRepo(t)
715+ sandbox := NewSandbox(repo)
716+ permissions := &PermissionSet{}
717+ permissions.FS.Read = []string{repo}
718+ permissions.FS.Write = []string{repo}
719+ sandbox.SetPermissions(permissions)
720+ tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox}
721+
722+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil)
723+ if err == nil || !strings.Contains(err.Error(), "no bash permission") {
724+ t.Fatalf("error = %v, want git permission error", err)
725+ }
726+ for _, path := range []string{".worktrees", ".gitignore"} {
727+ if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) {
728+ t.Fatalf("restricted call created %s: %v", path, statErr)
729+ }
730+ }
731+}
732+
733+func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) {
734+ if _, err := exec.LookPath("git"); err != nil {
735+ t.Skip("git is required")
736+ }
737+ repo := initWorktreeTestRepo(t)
738+ worktrees := filepath.Join(repo, ".worktrees")
739+ if err := os.Mkdir(worktrees, 0o755); err != nil {
740+ t.Fatal(err)
741+ }
742+ if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil {
743+ t.Fatal(err)
744+ }
745+ tool := newCreateWorktreeTestTool(repo)
746+
747+ _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil)
748+ if err == nil || !strings.Contains(err.Error(), "not a directory") {
749+ t.Fatalf("error = %v, want non-directory path error", err)
750+ }
751+ if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) {
752+ t.Fatalf("invalid path left .gitignore behind: %v", statErr)
753+ }
754+ cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new")
755+ if err := cmd.Run(); err == nil {
756+ t.Fatal("invalid path created the branch")
757+ }
758+}
759+
760+func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool {
761+ return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)}
762+}
763+
764+func initWorktreeTestRepo(t *testing.T) string {
765+ t.Helper()
766+ t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
767+ t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull)
768+ t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
769+ repo := t.TempDir()
770+ gitTestOutput(t, repo, "init", "-q")
771+ gitTestOutput(t, repo, "config", "user.email", "test@example.invalid")
772+ gitTestOutput(t, repo, "config", "user.name", "Test User")
773+ if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("before\n"), 0o644); err != nil {
774+ t.Fatal(err)
775+ }
776+ gitTestOutput(t, repo, "add", "README.md")
777+ gitTestOutput(t, repo, "commit", "-qm", "initial")
778+ return repo
779+}
780+
781+func shellQuoteForTest(value string) string {
782+ return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
783+}
784+
785+type gitTestResult struct {
786+ output string
787+ exitCode int
788+}
789+
790+func gitTestOutputAllowExit(t *testing.T, dir string, args ...string) gitTestResult {
791+ t.Helper()
792+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
793+ output, err := cmd.CombinedOutput()
794+ if err == nil {
795+ return gitTestResult{output: strings.TrimSpace(string(output))}
796+ }
797+ if exitErr, ok := err.(*exec.ExitError); ok {
798+ return gitTestResult{output: strings.TrimSpace(string(output)), exitCode: exitErr.ExitCode()}
799+ }
800+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
801+ return gitTestResult{}
802+}
803+
804+func gitTestOutput(t *testing.T, dir string, args ...string) string {
805+ t.Helper()
806+ cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
807+ output, err := cmd.CombinedOutput()
808+ if err != nil {
809+ t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
810+ }
811+ return strings.TrimSpace(string(output))
812+}
M packages/agent/tools/read.go +1 -1
@@ -1,4 +1,4 @@
1-// Package tools implements zut's built-in tools: read, write, edit, bash, and lsp.
1+// Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, and lsp.
22 package tools
33
44 import (
@@ -1,4 +1,4 @@
1-// Package tools implements zut's built-in tools: read, write, edit, bash, and lsp.1+// Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, and lsp.
2 package tools2 package tools
3 3
4 import (4 import (

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