Merge pull request #49 from bnema/feat/create-worktree-tool
feat(tools): add persistent worktree creation
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. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 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 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 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 | 233 | - `write`: create or overwrite files, making parent directories as needed. | |
| 234 | 234 | - `edit`: one or more exact-match replacements in an existing file. | |
| 235 | 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 | 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 | 241 | ## Modes | |
| 241 | 242 | ||
| @@ -425,7 +426,7 @@ zut also auto-compacts in the background: after any turn that reaches the config | |||
| 425 | 426 | ||
| 426 | 427 | ### `/jail` | |
| 427 | 428 | ||
| 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. `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. | |
| 429 | + | 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. | |
| 429 | 430 | ||
| 430 | 431 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | |
| 431 | 432 | ||
| @@ -793,7 +794,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||
| 793 | 794 | ||
| 794 | 795 | ## Tool rendering | |
| 795 | 796 | ||
| 796 | - | 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. | |
| 797 | + | 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. | |
| 797 | 798 | ||
| 798 | 799 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | |
| 799 | 800 | ||
| @@ -882,7 +883,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 882 | 883 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | |
| 883 | 884 | | `ctrl+l` | Redraw the screen. | | |
| 884 | 885 | | `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. | | |
| 885 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). | | |
| 886 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, and lsp outputs over ~12 lines). | | |
| 886 | 887 | | `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. | | |
| 887 | 888 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 888 | 889 | ||
| @@ -1065,7 +1066,7 @@ packages/agent/extproto/ extension wire-format types | |||
| 1065 | 1066 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1066 | 1067 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1067 | 1068 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1068 | - | packages/agent/tools/ read, write, edit, bash, lsp, sandbox | |
| 1069 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, sandbox | |
| 1069 | 1070 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1070 | 1071 | packages/agent/subagents/ named profiles, supervisor, and background runtime | |
| 1071 | 1072 | 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 | ## Modes | 241 | ## Modes | ||
| 241 | 242 | ||||
| @@ -425,7 +426,7 @@ zut also auto-compacts in the background: after any turn that reaches the config | |||||
| 425 | 426 | ||||
| 426 | ### `/jail` | 427 | ### `/jail` | ||
| 427 | 428 | ||||
| 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. `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. | 429 | + | 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. |
| 429 | 430 | ||||
| 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. | 431 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | ||
| 431 | 432 | ||||
| @@ -793,7 +794,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||||
| 793 | 794 | ||||
| 794 | ## Tool rendering | 795 | ## Tool rendering | ||
| 795 | 796 | ||||
| 796 | - | 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. | 797 | + | 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. |
| 797 | 798 | ||||
| 798 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | 799 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | ||
| 799 | 800 | ||||
| @@ -882,7 +883,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 882 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | 883 | | `ctrl+b` | Toggle the right sidebar; hidden or narrow widgets use a bounded above-input fallback. | | ||
| 883 | | `ctrl+l` | Redraw the screen. | | 884 | | `ctrl+l` | Redraw the screen. | | ||
| 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. | | 885 | | `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. | | ||
| 885 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, and lsp outputs over ~12 lines). | | 886 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, and lsp outputs over ~12 lines). | |
| 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. | | 887 | | `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. | | ||
| 887 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 888 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
| 888 | 889 | ||||
| @@ -1065,7 +1066,7 @@ packages/agent/extproto/ extension wire-format types | |||||
| 1065 | packages/agent/modes/ interactive tui, print, json, dialogs | 1066 | packages/agent/modes/ interactive tui, print, json, dialogs | ||
| 1066 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | 1067 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | ||
| 1067 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1068 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1068 | - | packages/agent/tools/ read, write, edit, bash, lsp, sandbox | 1069 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, sandbox |
| 1069 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1070 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1070 | packages/agent/subagents/ named profiles, supervisor, and background runtime | 1071 | packages/agent/subagents/ named profiles, supervisor, and background runtime | ||
| 1071 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | 1072 | 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). | |||
| 225 | 225 | ``` | |
| 226 | 226 | ||
| 227 | 227 | 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 | |
| 229 | 229 | the built-in. | |
| 230 | 230 | ||
| 231 | 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`: | |
| @@ -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 by | 228 | + | `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: | |||
| 41 | 41 | |---|---| | |
| 42 | 42 | | `name` | Name passed to `subagent_spawn`'s `agent` field. Falls back to the filename. | | |
| 43 | 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 | 45 | | `model` | Optional model ID. A qualified value such as `openai-codex/gpt-5.6-luna` selects both provider and model. | | |
| 46 | 46 | | `provider` | Optional separate provider ID for a model without a provider prefix. | | |
| 47 | 47 | | `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 | |||
| 303 | 303 | ||
| 304 | 304 | ## 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 | 308 | Two variables are available in filesystem scopes: | |
| 309 | 309 | ||
| @@ -373,6 +373,24 @@ Data-only agent: | |||
| 373 | 373 | ||
| 374 | 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 | 394 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | |
| 377 | 395 | ||
| 378 | 396 | ## Bash permissions | |
| @@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape | |||||
| 303 | 303 | ||||
| 304 | ## Filesystem permissions | 304 | ## 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 permissions | 396 | ## Bash permissions | ||
M packages/agent/build.go +8 -5
| @@ -1113,6 +1113,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1113 | 1113 | v.Sandbox = s | |
| 1114 | 1114 | case *tools.BashTool: | |
| 1115 | 1115 | v.Sandbox = s | |
| 1116 | + | case *tools.CreateWorktreeTool: | |
| 1117 | + | v.Sandbox = s | |
| 1116 | 1118 | case *tools.LSPTool: | |
| 1117 | 1119 | v.Sandbox = s | |
| 1118 | 1120 | } | |
| @@ -1144,10 +1146,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1144 | 1146 | manager = lsp.NewManagerWithOptions(options) | |
| 1145 | 1147 | } | |
| 1146 | 1148 | all := map[string]core.Tool{ | |
| 1147 | - | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1148 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1149 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1150 | - | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1149 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1150 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1151 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1152 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1153 | + | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1151 | 1154 | } | |
| 1152 | 1155 | if manager != nil { | |
| 1153 | 1156 | lspTool := tools.NewLSPTool(cwd, manager) | |
| @@ -1218,7 +1221,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1218 | 1221 | } | |
| 1219 | 1222 | ||
| 1220 | 1223 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1221 | - | order := []string{"read", "write", "edit", "bash", "lsp"} | |
| 1224 | + | order := []string{"read", "write", "edit", "bash", "create_worktree", "lsp"} | |
| 1222 | 1225 | var out []ToolSummary | |
| 1223 | 1226 | for _, name := range order { | |
| 1224 | 1227 | if t, ok := reg[name]; ok { | |
| @@ -1113,6 +1113,8 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 1113 | v.Sandbox = s | 1113 | v.Sandbox = s | ||
| 1114 | case *tools.BashTool: | 1114 | case *tools.BashTool: | ||
| 1115 | v.Sandbox = s | 1115 | v.Sandbox = s | ||
| 1116 | + | case *tools.CreateWorktreeTool: | |||
| 1117 | + | v.Sandbox = s | |||
| 1116 | case *tools.LSPTool: | 1118 | case *tools.LSPTool: | ||
| 1117 | v.Sandbox = s | 1119 | v.Sandbox = s | ||
| 1118 | } | 1120 | } | ||
| @@ -1144,10 +1146,11 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1144 | manager = lsp.NewManagerWithOptions(options) | 1146 | manager = lsp.NewManagerWithOptions(options) | ||
| 1145 | } | 1147 | } | ||
| 1146 | all := map[string]core.Tool{ | 1148 | all := map[string]core.Tool{ | ||
| 1147 | - | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | 1149 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, |
| 1148 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | 1150 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, |
| 1149 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | 1151 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, |
| 1150 | - | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | 1152 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, |
| 1153 | + | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |||
| 1151 | } | 1154 | } | ||
| 1152 | if manager != nil { | 1155 | if manager != nil { | ||
| 1153 | lspTool := tools.NewLSPTool(cwd, manager) | 1156 | lspTool := tools.NewLSPTool(cwd, manager) | ||
| @@ -1218,7 +1221,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1218 | } | 1221 | } | ||
| 1219 | 1222 | ||||
| 1220 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1223 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1221 | - | order := []string{"read", "write", "edit", "bash", "lsp"} | 1224 | + | order := []string{"read", "write", "edit", "bash", "create_worktree", "lsp"} |
| 1222 | var out []ToolSummary | 1225 | var out []ToolSummary | ||
| 1223 | for _, name := range order { | 1226 | for _, name := range order { | ||
| 1224 | if t, ok := reg[name]; ok { | 1227 | if t, ok := reg[name]; ok { | ||
M packages/agent/build_test.go +48 -3
| @@ -80,7 +80,8 @@ func TestMergeExtensionToolsRetainsPonytailPromptAddendum(t *testing.T) { | |||
| 80 | 80 | ||
| 81 | 81 | func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |
| 82 | 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 | 85 | lspTool, ok := registry["lsp"].(*tools.LSPTool) | |
| 85 | 86 | if !ok || lspTool.Manager == nil { | |
| 86 | 87 | t.Fatalf("lsp tool = %#v", registry["lsp"]) | |
| @@ -89,6 +90,10 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 89 | 90 | if _, ok := registry["lsp"]; !ok { | |
| 90 | 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 | 97 | write, ok := registry["write"].(*tools.WriteTool) | |
| 93 | 98 | if !ok || write.LSP == nil || !write.LSPDiagnostics { | |
| 94 | 99 | t.Fatalf("write tool LSP wiring = %#v", write) | |
| @@ -97,13 +102,53 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 97 | 102 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | |
| 98 | 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 { | |
| 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)) | |
| 102 | 107 | } | |
| 103 | 108 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | |
| 104 | 109 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | |
| 105 | 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 | 154 | 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) { | |||
| 420 | 420 | // OpenAI accept). Call this BEFORE Run(); zut folds extension tools | |
| 421 | 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, | |
| 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. | |
| 425 | 425 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 426 | 426 | e.registerTool(name, description, schema, false, fn) | |
| 427 | 427 | } | |
| @@ -420,8 +420,8 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||||
| 420 | // OpenAI accept). Call this BEFORE Run(); zut folds extension tools | 420 | // 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 { | |||
| 76 | 76 | BaseURL string | |
| 77 | 77 | ||
| 78 | 78 | // 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 | |
| 80 | 80 | // (e.g. []string{}) plus NoTools=true to disable everything. | |
| 81 | 81 | Tools []string | |
| 82 | 82 | ||
| @@ -76,7 +76,7 @@ type Config struct { | |||||
| 76 | BaseURL string | 76 | BaseURL string | ||
| 77 | 77 | ||||
| 78 | // Tools is the list of tools to enable. Nil/empty = all built-in | 78 | // 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 |
| 80 | // (e.g. []string{}) plus NoTools=true to disable everything. | 80 | // (e.g. []string{}) plus NoTools=true to disable everything. | ||
| 81 | Tools []string | 81 | 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): | |||
| 29 | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | |
| 30 | 30 | JSON Schema; zut routes the model's `tool_call` to the | |
| 31 | 31 | 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 | 34 | 3. **Lifecycle hooks** — subscribe to events | |
| 35 | 35 | (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 is | 29 | 2. **Tools** — register tools the LLM itself calls. Schema is | ||
| 30 | JSON Schema; zut routes the model's `tool_call` to the | 30 | JSON Schema; zut routes the model's `tool_call` to the | ||
| 31 | extension's `tool_result`. Same lifecycle as built-in tools | 31 | 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 events | 34 | 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. | |
| 2 | 2 | package tools | |
| 3 | 3 | ||
| 4 | 4 | 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 tools | 2 | package tools | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||