feat(worktrees): centralize lifecycle management (#211)
* feat(worktrees): centralize lifecycle management * test(worktrees): canonicalize platform paths
- commit
- 33c980b2f9afb0fa9ec49cf94d5927c5a4de1768
- author
- bnema · · 2026-09-16 17:20 UTC
- committer
- GitHub · · 2026-09-16 17:20 UTC
- parent
- 69d07a5
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.39.0
19 files · +1282 -922
M README.md +7 -7
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||
| 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 | - | - seventeen core built-in tools (read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. | |
| 12 | + | - seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. | |
| 13 | 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). | |
| @@ -268,7 +268,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 268 | 268 | - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available. | |
| 269 | 269 | - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | |
| 270 | 270 | - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below). | |
| 271 | - | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | |
| 271 | + | - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work. | |
| 272 | 272 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | |
| 273 | 273 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 274 | 274 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | |
| @@ -277,7 +277,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 277 | 277 | ||
| 278 | 278 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | |
| 279 | 279 | ||
| 280 | - | When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress. | |
| 280 | + | When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress. | |
| 281 | 281 | ||
| 282 | 282 | ## Modes | |
| 283 | 283 | ||
| @@ -519,7 +519,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac | |||
| 519 | 519 | ||
| 520 | 520 | ### `/jail` | |
| 521 | 521 | ||
| 522 | - | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. | |
| 522 | + | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. | |
| 523 | 523 | ||
| 524 | 524 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | |
| 525 | 525 | ||
| @@ -901,7 +901,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||
| 901 | 901 | ||
| 902 | 902 | ## Tool rendering | |
| 903 | 903 | ||
| 904 | - | By default each tool call (bash, read, write, edit, create_worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. | |
| 904 | + | By default each tool call (bash, read, write, edit, worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. | |
| 905 | 905 | ||
| 906 | 906 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | |
| 907 | 907 | ||
| @@ -989,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 989 | 989 | | `ctrl+d` | Exit on empty input. | | |
| 990 | 990 | | `ctrl+l` | Redraw the screen. | | |
| 991 | 991 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | |
| 992 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, glob, lsp, and web_search outputs over ~12 lines). | | |
| 992 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, worktree, grep, glob, lsp, and web_search outputs over ~12 lines). | | |
| 993 | 993 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | |
| 994 | 994 | | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. | | |
| 995 | 995 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| @@ -1173,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types | |||
| 1173 | 1173 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1174 | 1174 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1175 | 1175 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1176 | - | packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox | |
| 1176 | + | packages/agent/tools/ read, write, edit, bash, worktree, grep, glob, lsp, web search, goals, sandbox | |
| 1177 | 1177 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1178 | 1178 | packages/agent/subagents/ named profiles and resident background runtime | |
| 1179 | 1179 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | |
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 9 | 9 | ||||
| 10 | - one static binary. | 10 | - one static binary. | ||
| 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | 11 | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | ||
| 12 | - | - seventeen core built-in tools (read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. | 12 | + | - seventeen core built-in tools (read, write, edit, bash, python, worktree, grep, glob, ast, lsp, web_search, web_open, web_find, web_click, update_goal, update_plan, and schedule; `schedule` is interactive-only); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for public-web egress and availability boundaries. |
| 13 | - three run modes (interactive tui, print, json). | 13 | - three run modes (interactive tui, print, json). | ||
| 14 | - built-in telegram bot. | 14 | - built-in telegram bot. | ||
| 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | 15 | - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md). | ||
| @@ -268,7 +268,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 268 | - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available. | 268 | - `ast`: search or rewrite source code structurally with an [ast-grep](https://ast-grep.github.io/) pattern. Prefer it when the operation depends on code syntax; use `grep`/`edit` for plain text. Zut starts an installed `ast-grep` or `sg` executable directly without a shell. Searches return compact `path:line` matches. Rewrites are planned in memory, shown as a diff for confirmation, checked against the active filesystem scope, applied with file permissions preserved, and followed by bounded diagnostics. Install ast-grep separately; the tool returns a clear error when neither executable is available. | ||
| 269 | - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | 269 | - `bash`: run a command in the session cwd with merged stdout/stderr. Every call must include a positive integer `timeout` in seconds; calls without it or with `timeout <= 0` are rejected, and the deadline is always enforced. On Unix, zut uses `/bin/bash -c` when available, then `bash -c` from `PATH`, and falls back to POSIX `/bin/sh -c` when Bash is unavailable. On Windows, it uses `cmd /C`. macOS ships Bash 3.2 by default, so newer Bash features may be unavailable. | ||
| 270 | - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below). | 270 | - `python`: execute inline Python 3 code with an existing interpreter, never installing one. Every call must include nonblank `code` and a positive integer `timeout` in seconds (shared by interpreter discovery and execution). Each call runs a fresh stateless subprocess directly (`-u -B -`, code on stdin) in the session cwd with merged stdout/stderr; there is no persistent interpreter state, no script file, and no bytecode cache. Interactive input is unsupported (stdin observes EOF). Output is bounded (50 KiB, 2,000 lines) with explicit truncation marks; truncated results persist the byte-capped buffer to a temp file referenced as `full output` with its path in `full_output_path`, mirroring `bash`. Discovery order: `python_interpreter` in `$ZUT_HOME/config.json` (one executable path; relative paths resolve against the session cwd), then the active `VIRTUAL_ENV`, then `./.venv`, then `python3`/`python` on `PATH`. On Windows, Store aliases, `py` launchers, and install-manager entrypoints are rejected; only positively identified installed interpreters run. Missing packages fail with the ordinary import traceback. Tool arguments and output are recorded in the session like other tools; files the code deliberately writes persist. Save reusable scripts with `write`/`edit` instead of using `python` as an opaque file-edit path. Unavailable to packaged agents, and rejected while jailed (see below). | ||
| 271 | - | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | 271 | + | - `worktree`: manage Git worktrees with the `create`, `list`, and `cleanup` actions. Creation checks out a new branch under `$ZUT_HOME/worktrees/<repo-id>`; an absolute local Git setting, `zut.worktrees.path`, overrides that root per repository. Listing inventories registered worktrees. Cleanup requires exact paths, confirmation through the normal preview gate, and revalidation immediately before removal. It refuses the primary worktree, dirty or detached checkouts, and commits not merged into the current branch. It removes neither local branches nor unmerged work. |
| 272 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | 272 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. Successful native edits, writes, renames, and applied code actions automatically attach bounded diagnostics when available. See [docs/lsp.md](docs/lsp.md). | ||
| 273 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 273 | - `web_search`: search DuckDuckGo HTML and return bounded source titles, URLs, snippets, and opaque references. `web_open` opens either a complete public URL or a referenced page, while `web_find` and `web_click` respectively find literal text in an opened page and follow a displayed link. The four tools are enabled together by default for normal CLI sessions and unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 274 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | 274 | - `update_goal`: let the main agent start its own mission when none is active, set the next bounded goal within an active mission, mark the active goal `done` or `blocked`, or atomically supersede an invalid goal with a necessary in-mission replacement. Pausing, resuming, and clearing missions remain user-controlled through `/goal`. | ||
| @@ -277,7 +277,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 277 | 277 | ||||
| 278 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | 278 | Tool results carry resource provenance in the durable session. Before each provider request, Zut replaces an older read only when an equivalent newer view supersedes it, and replaces a multi-file search only after every discovered file has been consumed or mutated. This provider-only projection removes known-stale context without rewriting session history. Because an older projected result changes, prompt caching restarts from that point; compact deterministic markers limit the cost. | ||
| 279 | 279 | ||||
| 280 | - | When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress. | 280 | + | When the sandbox is on (see `/jail`), filesystem tools, `grep`, `glob`, `ast`, and LSP workspace edits refuse paths outside the session cwd. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to remain inside the jail. `python` is rejected outright while jailed: arbitrary Python cannot be confined by the current jail (admission-time check; use `/unjail`). When admitted, `python` has unrestricted host filesystem/network/process access like `bash`. Jail does not sandbox web-search network egress. |
| 281 | 281 | ||||
| 282 | ## Modes | 282 | ## Modes | ||
| 283 | 283 | ||||
| @@ -519,7 +519,7 @@ To prevent tool-heavy runs from exhausting context, provider requests and compac | |||||
| 519 | 519 | ||||
| 520 | ### `/jail` | 520 | ### `/jail` | ||
| 521 | 521 | ||||
| 522 | - | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `create_worktree` also requires its repository root, Git metadata, optional `.gitignore`, and configured worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. | 522 | + | Enforces a sandbox rooted at the cwd shown in the status bar. `read`, `write`, `edit`, and `grep` resolve their target path (including through symlinks) and refuse anything outside the sandbox. `worktree` also requires its repository root, Git metadata, and resolved worktree destination to be inside the sandbox. `bash` refuses obvious escape patterns (`sudo`, `rm -rf /`, leading `cd /`, `cd ..`, `cd ~`, `chmod -R`, `dd of=/`, and similar) and rejects shell arguments or redirections that point outside the sandbox. The status bar shows `jailed, ~/your/cwd` while active. Enable **jail new sessions by default** in `/settings` to persist this behavior across launches; `/unjail` then unlocks only the current session. |
| 523 | 523 | ||||
| 524 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | 524 | This is a guardrail against accidents, not a hard security boundary. If you need real isolation, run zut under docker or a proper sandbox. | ||
| 525 | 525 | ||||
| @@ -901,7 +901,7 @@ Frames containing images are full-repainted (no differential diff) to prevent st | |||||
| 901 | 901 | ||||
| 902 | ## Tool rendering | 902 | ## Tool rendering | ||
| 903 | 903 | ||||
| 904 | - | By default each tool call (bash, read, write, edit, create_worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. | 904 | + | By default each tool call (bash, read, write, edit, worktree, grep, lsp) renders inside a bordered panel — a `┌─ header ─┐`, `│`-prefixed body rows, and a `└─┘` footer. On a screen with many calls the borders can read as busy, so zut also offers a **flat** mode: a single quiet header line per call (`▌ bash …`) with indented, border-free output. Same information — tool name, arg summary, streamed output, the `... (N more lines, ctrl+o to expand)` truncation — just no frame. |
| 905 | 905 | ||||
| 906 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | 906 | Set the `tool_render` key in `$ZUT_HOME/config.json`: | ||
| 907 | 907 | ||||
| @@ -989,7 +989,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 989 | | `ctrl+d` | Exit on empty input. | | 989 | | `ctrl+d` | Exit on empty input. | | ||
| 990 | | `ctrl+l` | Redraw the screen. | | 990 | | `ctrl+l` | Redraw the screen. | | ||
| 991 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | 991 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | ||
| 992 | - | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, glob, lsp, and web_search outputs over ~12 lines). | | 992 | + | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, worktree, grep, glob, lsp, and web_search outputs over ~12 lines). | |
| 993 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | 993 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | ||
| 994 | | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. | | 994 | | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning + fast-mode profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. | | ||
| 995 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 995 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
| @@ -1173,7 +1173,7 @@ packages/agent/extproto/ extension wire-format types | |||||
| 1173 | packages/agent/modes/ interactive tui, print, json, dialogs | 1173 | packages/agent/modes/ interactive tui, print, json, dialogs | ||
| 1174 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | 1174 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | ||
| 1175 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1175 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1176 | - | packages/agent/tools/ read, write, edit, bash, create_worktree, grep, glob, lsp, web search, goals, sandbox | 1176 | + | packages/agent/tools/ read, write, edit, bash, worktree, grep, glob, lsp, web search, goals, sandbox |
| 1177 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1177 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1178 | packages/agent/subagents/ named profiles and resident background runtime | 1178 | packages/agent/subagents/ named profiles and resident background runtime | ||
| 1179 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | 1179 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | ||
M docs/extensions.md +1 -1
| @@ -225,7 +225,7 @@ describing the tool's args (the same shape Anthropic and OpenAI accept). | |||
| 225 | 225 | ``` | |
| 226 | 226 | ||
| 227 | 227 | Tool names live in the same namespace as built-in tools (`read`, | |
| 228 | - | `write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `ast`, `lsp`, `web_search`, | |
| 228 | + | `write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`, | |
| 229 | 229 | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`, | |
| 230 | 230 | `skill`). Conflicts with active built-ins are silently shadowed by the built-in. | |
| 231 | 231 | Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`, | |
| @@ -225,7 +225,7 @@ describing the tool's args (the same shape Anthropic and OpenAI accept). | |||||
| 225 | ``` | 225 | ``` | ||
| 226 | 226 | ||||
| 227 | Tool names live in the same namespace as built-in tools (`read`, | 227 | Tool names live in the same namespace as built-in tools (`read`, | ||
| 228 | - | `write`, `edit`, `bash`, `python`, `create_worktree`, `grep`, `ast`, `lsp`, `web_search`, | 228 | + | `write`, `edit`, `bash`, `python`, `worktree`, `grep`, `ast`, `lsp`, `web_search`, |
| 229 | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`, | 229 | `web_open`, `web_find`, `web_click`, `schedule`, `update_goal`, `update_plan`, | ||
| 230 | `skill`). Conflicts with active built-ins are silently shadowed by the built-in. | 230 | `skill`). Conflicts with active built-ins are silently shadowed by the built-in. | ||
| 231 | Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`, | 231 | Every native public-web capability name, plus `grep`, `ast`, `python`, `schedule`, | ||
M docs/zutfiles.md +8 -6
| @@ -303,7 +303,7 @@ In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escape | |||
| 303 | 303 | ||
| 304 | 304 | ## Filesystem permissions | |
| 305 | 305 | ||
| 306 | - | Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `create_worktree` tools. Empty or omitted scopes deny the operation. | |
| 306 | + | Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `worktree` tools. Empty or omitted scopes deny the operation. | |
| 307 | 307 | ||
| 308 | 308 | Two variables are available in filesystem scopes: | |
| 309 | 309 | ||
| @@ -373,23 +373,25 @@ 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`. | |
| 376 | + | The `worktree` tool's `create` action resolves its destination without prompting. A local `zut.worktrees.path` Git configuration entry (an absolute path) overrides the default for that repository. Repositories configured by an older zut release with the relative value `.worktrees` must remove it with `git config --local --unset zut.worktrees.path` before using the new default. Otherwise the tool checks out under `<default-root>/<repo-id>`, where the default root is `$ZUT_HOME/worktrees` and `<repo-id>` combines a readable repository slug with a hash of the canonical Git common directory. The same repository therefore keeps a stable directory across sessions, while repositories that share a name stay separate. The tool changes no repository files, never copies uncommitted or ignored files, and refuses an existing branch or worktree path. | |
| 377 | 377 | ||
| 378 | - | The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell: | |
| 378 | + | The tool needs read and write scopes covering the repository root, Git metadata, and the resolved worktree destination. Because the default destination lives outside the workspace, an unconfigured repository also needs a scope covering `$ZUT_HOME/worktrees`; set `zut.worktrees.path` to a directory inside a declared scope for a fully contained layout. It also requires Bash permission for `git`, even though it invokes Git directly without a shell: | |
| 379 | 379 | ||
| 380 | 380 | ```json | |
| 381 | 381 | { | |
| 382 | 382 | "permissions": { | |
| 383 | 383 | "fs": { | |
| 384 | - | "read": ["${workspace}"], | |
| 385 | - | "write": ["${workspace}"] | |
| 384 | + | "read": ["${workspace}", "/home/you/.local/state/zut/worktrees"], | |
| 385 | + | "write": ["${workspace}", "/home/you/.local/state/zut/worktrees"] | |
| 386 | 386 | }, | |
| 387 | 387 | "bash": { "mode": "allowlist", "allow": ["git"] } | |
| 388 | 388 | } | |
| 389 | 389 | } | |
| 390 | 390 | ``` | |
| 391 | 391 | ||
| 392 | - | The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox. | |
| 392 | + | Replace the worktrees path with the resolved `$ZUT_HOME/worktrees` directory for the host, or configure `zut.worktrees.path` per repository instead. | |
| 393 | + | ||
| 394 | + | The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `worktree` creation as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox. | |
| 393 | 395 | ||
| 394 | 396 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | |
| 395 | 397 | ||
| @@ -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`, `edit`, `grep`, and `create_worktree` tools. Empty or omitted scopes deny the operation. | 306 | + | Filesystem permissions are enforced by the built-in `read`, `write`, `edit`, `grep`, and `worktree` tools. Empty or omitted scopes deny the operation. |
| 307 | 307 | ||||
| 308 | Two variables are available in filesystem scopes: | 308 | Two variables are available in filesystem scopes: | ||
| 309 | 309 | ||||
| @@ -373,23 +373,25 @@ Data-only agent: | |||||
| 373 | 373 | ||||
| 374 | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | 374 | Filesystem checks canonicalize existing paths, or the nearest existing parent for a new path, so symlinks cannot be used to escape a declared scope. | ||
| 375 | 375 | ||||
| 376 | - | `create_worktree` bootstraps a repository without an existing worktree root in two calls. Its first branch-only call returns no-write guidance for the agent to ask the user whether to use `.worktrees` or an external directory. The reply is sent as `bootstrap_root`: `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the repository root's `.gitignore`; an absolute external path creates `<external-root>/<branch>` without changing repository files. The selected root is stored privately as `zut.worktrees.path` in local Git config, so later calls only need `branch`. | 376 | + | The `worktree` tool's `create` action resolves its destination without prompting. A local `zut.worktrees.path` Git configuration entry (an absolute path) overrides the default for that repository. Repositories configured by an older zut release with the relative value `.worktrees` must remove it with `git config --local --unset zut.worktrees.path` before using the new default. Otherwise the tool checks out under `<default-root>/<repo-id>`, where the default root is `$ZUT_HOME/worktrees` and `<repo-id>` combines a readable repository slug with a hash of the canonical Git common directory. The same repository therefore keeps a stable directory across sessions, while repositories that share a name stay separate. The tool changes no repository files, never copies uncommitted or ignored files, and refuses an existing branch or worktree path. |
| 377 | 377 | ||||
| 378 | - | The tool needs read and write scopes covering the repository root, Git metadata, and the selected worktree root. It also requires Bash permission for `git`, even though it invokes Git directly without a shell: | 378 | + | The tool needs read and write scopes covering the repository root, Git metadata, and the resolved worktree destination. Because the default destination lives outside the workspace, an unconfigured repository also needs a scope covering `$ZUT_HOME/worktrees`; set `zut.worktrees.path` to a directory inside a declared scope for a fully contained layout. It also requires Bash permission for `git`, even though it invokes Git directly without a shell: |
| 379 | 379 | ||||
| 380 | ```json | 380 | ```json | ||
| 381 | { | 381 | { | ||
| 382 | "permissions": { | 382 | "permissions": { | ||
| 383 | "fs": { | 383 | "fs": { | ||
| 384 | - | "read": ["${workspace}"], | 384 | + | "read": ["${workspace}", "/home/you/.local/state/zut/worktrees"], |
| 385 | - | "write": ["${workspace}"] | 385 | + | "write": ["${workspace}", "/home/you/.local/state/zut/worktrees"] |
| 386 | }, | 386 | }, | ||
| 387 | "bash": { "mode": "allowlist", "allow": ["git"] } | 387 | "bash": { "mode": "allowlist", "allow": ["git"] } | ||
| 388 | } | 388 | } | ||
| 389 | } | 389 | } | ||
| 390 | ``` | 390 | ``` | ||
| 391 | 391 | ||||
| 392 | - | The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `create_worktree` as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox. | 392 | + | Replace the worktrees path with the resolved `$ZUT_HOME/worktrees` directory for the host, or configure `zut.worktrees.path` per repository instead. |
| 393 | + | ||||
| 394 | + | The tool disables Git hooks, but a repository checkout can still use configured Git filters. Treat `git` permission for `worktree` creation as permission to run repository-controlled checkout behavior; jail mode remains an accident-prevention guardrail, not a security sandbox. | |||
| 393 | 395 | ||||
| 394 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | 396 | If the working directory changes during a run, `${workspace}` permissions are expanded again for the new directory. | ||
| 395 | 397 | ||||
M examples/extensions/mcp-bridge/bridge.go +1 -1
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | // | |
| 7 | 7 | // The double underscore separates the server name from the tool name, | |
| 8 | 8 | // avoiding collisions with zut's built-in tools (read, write, edit, bash, | |
| 9 | - | // create_worktree, grep, lsp, web_search, skill). The web_search and grep | |
| 9 | + | // worktree, grep, lsp, web_search, skill). The web_search and grep | |
| 10 | 10 | // names are reserved even when those built-ins are disabled. | |
| 11 | 11 | package main | |
| 12 | 12 | ||
| @@ -6,7 +6,7 @@ | |||||
| 6 | // | 6 | // | ||
| 7 | // The double underscore separates the server name from the tool name, | 7 | // The double underscore separates the server name from the tool name, | ||
| 8 | // avoiding collisions with zut's built-in tools (read, write, edit, bash, | 8 | // avoiding collisions with zut's built-in tools (read, write, edit, bash, | ||
| 9 | - | // create_worktree, grep, lsp, web_search, skill). The web_search and grep | 9 | + | // worktree, grep, lsp, web_search, skill). The web_search and grep |
| 10 | // names are reserved even when those built-ins are disabled. | 10 | // names are reserved even when those built-ins are disabled. | ||
| 11 | package main | 11 | package main | ||
| 12 | 12 | ||||
M packages/agent/build.go +27 -13
| @@ -1312,8 +1312,13 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||
| 1312 | 1312 | v.Sandbox = s | |
| 1313 | 1313 | case *tools.PythonTool: | |
| 1314 | 1314 | v.Sandbox = s | |
| 1315 | - | case *tools.CreateWorktreeTool: | |
| 1316 | - | v.Sandbox = s | |
| 1315 | + | case *tools.WorktreeTool: | |
| 1316 | + | if v.Create != nil { | |
| 1317 | + | v.Create.Sandbox = s | |
| 1318 | + | } | |
| 1319 | + | if v.Manage != nil { | |
| 1320 | + | v.Manage.Sandbox = s | |
| 1321 | + | } | |
| 1317 | 1322 | case *tools.LSPTool: | |
| 1318 | 1323 | v.Sandbox = s | |
| 1319 | 1324 | case *tools.GrepTool: | |
| @@ -1336,6 +1341,12 @@ func (r Resolved) NewAgent() *core.Agent { | |||
| 1336 | 1341 | a.Reasoning = r.Reasoning | |
| 1337 | 1342 | a.Temperature = r.Temperature | |
| 1338 | 1343 | a.FastMode = r.FastMode | |
| 1344 | + | if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok { | |
| 1345 | + | cwd := r.CWD | |
| 1346 | + | a.HostContext = func(ctx context.Context) string { | |
| 1347 | + | return tools.WorktreeInventoryContext(ctx, cwd) | |
| 1348 | + | } | |
| 1349 | + | } | |
| 1339 | 1350 | return a | |
| 1340 | 1351 | } | |
| 1341 | 1352 | ||
| @@ -1352,16 +1363,19 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1352 | 1363 | manager = lsp.NewManagerWithOptions(options) | |
| 1353 | 1364 | } | |
| 1354 | 1365 | all := map[string]core.Tool{ | |
| 1355 | - | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1356 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1357 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1358 | - | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1359 | - | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1360 | - | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | |
| 1361 | - | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | |
| 1362 | - | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1363 | - | "update_goal": &tools.UpdateGoalTool{}, | |
| 1364 | - | "update_plan": &tools.UpdatePlanTool{}, | |
| 1366 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | |
| 1367 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | |
| 1368 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1369 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1370 | + | "worktree": &tools.WorktreeTool{ | |
| 1371 | + | Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")}, | |
| 1372 | + | Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox}, | |
| 1373 | + | }, | |
| 1374 | + | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | |
| 1375 | + | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | |
| 1376 | + | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1377 | + | "update_goal": &tools.UpdateGoalTool{}, | |
| 1378 | + | "update_plan": &tools.UpdatePlanTool{}, | |
| 1365 | 1379 | } | |
| 1366 | 1380 | if webSearchAllowedForRegistry(args) { | |
| 1367 | 1381 | for name, tool := range tools.NewWebTools() { | |
| @@ -1508,7 +1522,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1508 | 1522 | return false | |
| 1509 | 1523 | } | |
| 1510 | 1524 | ||
| 1511 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} | |
| 1525 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} | |
| 1512 | 1526 | ||
| 1513 | 1527 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1514 | 1528 | var out []ToolSummary | |
| @@ -1312,8 +1312,13 @@ func (r *Resolved) UseSandbox(s *tools.Sandbox) { | |||||
| 1312 | v.Sandbox = s | 1312 | v.Sandbox = s | ||
| 1313 | case *tools.PythonTool: | 1313 | case *tools.PythonTool: | ||
| 1314 | v.Sandbox = s | 1314 | v.Sandbox = s | ||
| 1315 | - | case *tools.CreateWorktreeTool: | 1315 | + | case *tools.WorktreeTool: |
| 1316 | - | v.Sandbox = s | 1316 | + | if v.Create != nil { |
| 1317 | + | v.Create.Sandbox = s | |||
| 1318 | + | } | |||
| 1319 | + | if v.Manage != nil { | |||
| 1320 | + | v.Manage.Sandbox = s | |||
| 1321 | + | } | |||
| 1317 | case *tools.LSPTool: | 1322 | case *tools.LSPTool: | ||
| 1318 | v.Sandbox = s | 1323 | v.Sandbox = s | ||
| 1319 | case *tools.GrepTool: | 1324 | case *tools.GrepTool: | ||
| @@ -1336,6 +1341,12 @@ func (r Resolved) NewAgent() *core.Agent { | |||||
| 1336 | a.Reasoning = r.Reasoning | 1341 | a.Reasoning = r.Reasoning | ||
| 1337 | a.Temperature = r.Temperature | 1342 | a.Temperature = r.Temperature | ||
| 1338 | a.FastMode = r.FastMode | 1343 | a.FastMode = r.FastMode | ||
| 1344 | + | if _, ok := r.ToolRegistry["worktree"].(*tools.WorktreeTool); ok { | |||
| 1345 | + | cwd := r.CWD | |||
| 1346 | + | a.HostContext = func(ctx context.Context) string { | |||
| 1347 | + | return tools.WorktreeInventoryContext(ctx, cwd) | |||
| 1348 | + | } | |||
| 1349 | + | } | |||
| 1339 | return a | 1350 | return a | ||
| 1340 | } | 1351 | } | ||
| 1341 | 1352 | ||||
| @@ -1352,16 +1363,19 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1352 | manager = lsp.NewManagerWithOptions(options) | 1363 | manager = lsp.NewManagerWithOptions(options) | ||
| 1353 | } | 1364 | } | ||
| 1354 | all := map[string]core.Tool{ | 1365 | all := map[string]core.Tool{ | ||
| 1355 | - | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, | 1366 | + | "read": &tools.ReadTool{CWD: cwd, Sandbox: sandbox}, |
| 1356 | - | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, | 1367 | + | "write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnWrite}, |
| 1357 | - | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | 1368 | + | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, |
| 1358 | - | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | 1369 | + | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, |
| 1359 | - | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | 1370 | + | "worktree": &tools.WorktreeTool{ |
| 1360 | - | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, | 1371 | + | Create: &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox, DefaultRoot: filepath.Join(ZutHome(), "worktrees")}, |
| 1361 | - | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | 1372 | + | Manage: &tools.ManageWorktreesTool{CWD: cwd, Sandbox: sandbox}, |
| 1362 | - | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | 1373 | + | }, |
| 1363 | - | "update_goal": &tools.UpdateGoalTool{}, | 1374 | + | "grep": &tools.GrepTool{CWD: cwd, Sandbox: sandbox}, |
| 1364 | - | "update_plan": &tools.UpdatePlanTool{}, | 1375 | + | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, |
| 1376 | + | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |||
| 1377 | + | "update_goal": &tools.UpdateGoalTool{}, | |||
| 1378 | + | "update_plan": &tools.UpdatePlanTool{}, | |||
| 1365 | } | 1379 | } | ||
| 1366 | if webSearchAllowedForRegistry(args) { | 1380 | if webSearchAllowedForRegistry(args) { | ||
| 1367 | for name, tool := range tools.NewWebTools() { | 1381 | for name, tool := range tools.NewWebTools() { | ||
| @@ -1508,7 +1522,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1508 | return false | 1522 | return false | ||
| 1509 | } | 1523 | } | ||
| 1510 | 1524 | ||||
| 1511 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "create_worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} | 1525 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "grep", "glob", "ast", "bash", "python", "worktree", "lsp", "web_search", "web_open", "web_find", "web_click", "update_goal", "update_plan"} |
| 1512 | 1526 | ||||
| 1513 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1527 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1514 | var out []ToolSummary | 1528 | var out []ToolSummary | ||
M packages/agent/ext/ext.go +1 -1
| @@ -421,7 +421,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 421 | 421 | // into the agent's registry once the extension's ready frame fires. | |
| 422 | 422 | // | |
| 423 | 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | |
| 424 | - | // python, create_worktree, grep, glob, ast, lsp, web_search, web_open, | |
| 424 | + | // python, worktree, grep, glob, ast, lsp, web_search, web_open, | |
| 425 | 425 | // web_find, web_click, schedule, update_goal, update_plan, and skill) are | |
| 426 | 426 | // silently shadowed by the built-in. Every native public-web name, plus | |
| 427 | 427 | // grep, glob, ast, python, schedule, | |
| @@ -421,7 +421,7 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||||
| 421 | // into the agent's registry once the extension's ready frame fires. | 421 | // into the agent's registry once the extension's ready frame fires. | ||
| 422 | // | 422 | // | ||
| 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | ||
| 424 | - | // python, create_worktree, grep, glob, ast, lsp, web_search, web_open, | 424 | + | // python, worktree, grep, glob, ast, lsp, web_search, web_open, |
| 425 | // web_find, web_click, schedule, update_goal, update_plan, and skill) are | 425 | // web_find, web_click, schedule, update_goal, update_plan, and skill) are | ||
| 426 | // silently shadowed by the built-in. Every native public-web name, plus | 426 | // silently shadowed by the built-in. Every native public-web name, plus | ||
| 427 | // grep, glob, ast, python, schedule, | 427 | // grep, glob, ast, python, schedule, | ||
M packages/agent/sdk/sdk.go +1 -1
| @@ -78,7 +78,7 @@ type Config struct { | |||
| 78 | 78 | BaseURL string | |
| 79 | 79 | ||
| 80 | 80 | // Tools is the list of tools to enable. Nil/empty enables the ordinary | |
| 81 | - | // built-ins: read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, | |
| 81 | + | // built-ins: read, write, edit, bash, python, worktree, grep, glob, ast, lsp, | |
| 82 | 82 | // update_goal, and update_plan. The public-web capability is excluded unless this list | |
| 83 | 83 | // explicitly contains "web_search"; that selector enables web_search, | |
| 84 | 84 | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. | |
| @@ -78,7 +78,7 @@ type Config struct { | |||||
| 78 | BaseURL string | 78 | BaseURL string | ||
| 79 | 79 | ||||
| 80 | // Tools is the list of tools to enable. Nil/empty enables the ordinary | 80 | // Tools is the list of tools to enable. Nil/empty enables the ordinary | ||
| 81 | - | // built-ins: read, write, edit, bash, python, create_worktree, grep, glob, ast, lsp, | 81 | + | // built-ins: read, write, edit, bash, python, worktree, grep, glob, ast, lsp, |
| 82 | // update_goal, and update_plan. The public-web capability is excluded unless this list | 82 | // update_goal, and update_plan. The public-web capability is excluded unless this list | ||
| 83 | // explicitly contains "web_search"; that selector enables web_search, | 83 | // explicitly contains "web_search"; that selector enables web_search, | ||
| 84 | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. | 84 | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. | ||
M packages/agent/skills/builtin/write-zut-extension/SKILL.md +1 -1
| @@ -29,7 +29,7 @@ Three things an extension can do (any combination): | |||
| 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/create_worktree/grep/lsp/web_search/skill). | |
| 32 | + | (read/write/edit/bash/worktree/grep/lsp/web_search/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/create_worktree/grep/lsp/web_search/skill). | 32 | + | (read/write/edit/bash/worktree/grep/lsp/web_search/skill). |
| 33 | 33 | ||||
| 34 | 3. **Lifecycle hooks** — subscribe to 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, | ||
M packages/agent/tools/bash_unix.go +2 -2
| @@ -57,8 +57,8 @@ func killBashProcessGroup(cmd *exec.Cmd) error { | |||
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | 59 | // killProcessGroup sends SIGTERM then SIGKILL to the entire process group. | |
| 60 | - | // It is also used by the create_worktree helper, which keeps its existing | |
| 61 | - | // graceful cancellation behavior. | |
| 60 | + | // It is also used by the worktree Git helper, which keeps graceful | |
| 61 | + | // cancellation behavior. | |
| 62 | 62 | func killProcessGroup(cmd *exec.Cmd) { | |
| 63 | 63 | if cmd == nil || cmd.Process == nil { | |
| 64 | 64 | return | |
| @@ -57,8 +57,8 @@ func killBashProcessGroup(cmd *exec.Cmd) error { | |||||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | // killProcessGroup sends SIGTERM then SIGKILL to the entire process group. | 59 | // killProcessGroup sends SIGTERM then SIGKILL to the entire process group. | ||
| 60 | - | // It is also used by the create_worktree helper, which keeps its existing | 60 | + | // It is also used by the worktree Git helper, which keeps graceful |
| 61 | - | // graceful cancellation behavior. | 61 | + | // cancellation behavior. |
| 62 | func killProcessGroup(cmd *exec.Cmd) { | 62 | func killProcessGroup(cmd *exec.Cmd) { | ||
| 63 | if cmd == nil || cmd.Process == nil { | 63 | if cmd == nil || cmd.Process == nil { | ||
| 64 | return | 64 | return | ||
M packages/agent/tools/create_worktree.go +113 -359
| @@ -3,6 +3,8 @@ package tools | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "encoding/json" | |
| 7 | 9 | "errors" | |
| 8 | 10 | "fmt" | |
| @@ -19,30 +21,31 @@ import ( | |||
| 19 | 21 | ) | |
| 20 | 22 | ||
| 21 | 23 | const ( | |
| 22 | - | worktreesIgnoreEntry = "/.worktrees/" | |
| 23 | - | worktreesUnanchoredIgnoreEntry = ".worktrees/" | |
| 24 | - | defaultWorktreeRoot = ".worktrees" | |
| 25 | - | worktreeConfigKey = "zut.worktrees.path" | |
| 26 | - | maxCreateWorktreeGitOutput = 64 * 1024 | |
| 27 | - | maxCreateWorktreeIgnoreBytes = 1 << 20 | |
| 24 | + | worktreeConfigKey = "zut.worktrees.path" | |
| 25 | + | maxCreateWorktreeGitOutput = 64 * 1024 | |
| 26 | + | // worktreeRepoIDHashLength keeps the hash short enough to read but wide | |
| 27 | + | // enough that distinct repositories do not collide in practice. | |
| 28 | + | worktreeRepoIDHashLength = 12 | |
| 29 | + | worktreeRepoSlugMaxLen = 40 | |
| 28 | 30 | ) | |
| 29 | 31 | ||
| 30 | 32 | var createWorktreeOperationMu sync.Mutex | |
| 31 | 33 | ||
| 32 | - | // CreateWorktreeTool creates a persistent branch checkout. A repository that | |
| 33 | - | // has neither a local worktree configuration nor an existing .worktrees | |
| 34 | - | // directory returns a bootstrap request without changing any files. | |
| 34 | + | // CreateWorktreeTool creates a persistent branch checkout. An unconfigured | |
| 35 | + | // repository checks out under a stable per-repository directory beneath | |
| 36 | + | // DefaultRoot. A local zut.worktrees.path Git configuration entry overrides | |
| 37 | + | // that root for the repository. | |
| 35 | 38 | type CreateWorktreeTool struct { | |
| 36 | - | CWD string | |
| 37 | - | Sandbox *Sandbox | |
| 39 | + | CWD string | |
| 40 | + | Sandbox *Sandbox | |
| 41 | + | DefaultRoot string | |
| 38 | 42 | ||
| 39 | 43 | previewMu sync.Mutex | |
| 40 | 44 | preview *createWorktreePreview | |
| 41 | 45 | } | |
| 42 | 46 | ||
| 43 | 47 | type createWorktreeArgs struct { | |
| 44 | - | Branch string `json:"branch"` | |
| 45 | - | BootstrapRoot string `json:"bootstrap_root,omitempty"` | |
| 48 | + | Branch string `json:"branch"` | |
| 46 | 49 | } | |
| 47 | 50 | ||
| 48 | 51 | const createWorktreeSchema = `{ | |
| @@ -51,10 +54,6 @@ const createWorktreeSchema = `{ | |||
| 51 | 54 | "branch":{ | |
| 52 | 55 | "type":"string", | |
| 53 | 56 | "description":"Name of the new Git branch." | |
| 54 | - | }, | |
| 55 | - | "bootstrap_root":{ | |
| 56 | - | "type":"string", | |
| 57 | - | "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files." | |
| 58 | 57 | } | |
| 59 | 58 | }, | |
| 60 | 59 | "required":["branch"] | |
| @@ -62,12 +61,12 @@ const createWorktreeSchema = `{ | |||
| 62 | 61 | ||
| 63 | 62 | func (t *CreateWorktreeTool) Name() string { return "create_worktree" } | |
| 64 | 63 | func (t *CreateWorktreeTool) Description() string { | |
| 65 | - | return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root." | |
| 64 | + | return "Create a persistent Git branch worktree under the repository's configured worktree root or zut's global worktrees root." | |
| 66 | 65 | } | |
| 67 | 66 | func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) } | |
| 68 | 67 | ||
| 69 | - | // Preview reports the exact bootstrap request or checkout that Execute will | |
| 70 | - | // produce without modifying the repository. | |
| 68 | + | // Preview reports the checkout that Execute will produce without modifying the | |
| 69 | + | // repository or filesystem. | |
| 71 | 70 | func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |
| 72 | 71 | plan, err := t.plan(ctx, raw) | |
| 73 | 72 | if err != nil { | |
| @@ -87,20 +86,13 @@ func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, p | |||
| 87 | 86 | if err != nil { | |
| 88 | 87 | return core.ToolResult{}, err | |
| 89 | 88 | } | |
| 90 | - | if plan.bootstrapRequired { | |
| 91 | - | return plan.preview, nil | |
| 92 | - | } | |
| 93 | 89 | return executeCreateWorktreePlan(ctx, plan, progress) | |
| 94 | 90 | } | |
| 95 | 91 | ||
| 96 | 92 | func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) { | |
| 97 | - | configSaved := false | |
| 98 | - | var createdWorktreeParents []string | |
| 99 | - | if plan.setConfig { | |
| 100 | - | if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil { | |
| 101 | - | return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err) | |
| 102 | - | } | |
| 103 | - | configSaved = true | |
| 93 | + | createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath) | |
| 94 | + | if err != nil { | |
| 95 | + | return core.ToolResult{}, fmt.Errorf("create_worktree: inspect worktree directories: %w", err) | |
| 104 | 96 | } | |
| 105 | 97 | rollback := func(cause error) error { | |
| 106 | 98 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| @@ -111,24 +103,9 @@ func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, pro | |||
| 111 | 103 | if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil { | |
| 112 | 104 | cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err) | |
| 113 | 105 | } | |
| 114 | - | if err := plan.ignore.restore(); err != nil { | |
| 115 | - | cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err) | |
| 116 | - | } | |
| 117 | - | if configSaved { | |
| 118 | - | if err := plan.removeBootstrapConfig(cleanupCtx); err != nil { | |
| 119 | - | cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err) | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | 106 | return cause | |
| 123 | 107 | } | |
| 124 | - | if err := plan.ignore.apply(); err != nil { | |
| 125 | - | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err)) | |
| 126 | - | } | |
| 127 | 108 | ||
| 128 | - | createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath) | |
| 129 | - | if err != nil { | |
| 130 | - | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err)) | |
| 131 | - | } | |
| 132 | 109 | if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil { | |
| 133 | 110 | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err)) | |
| 134 | 111 | } | |
| @@ -173,18 +150,15 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes | |||
| 173 | 150 | } | |
| 174 | 151 | ||
| 175 | 152 | type createWorktreePlan struct { | |
| 176 | - | repoRoot string | |
| 177 | - | branch string | |
| 178 | - | base string | |
| 179 | - | worktreeRoot string | |
| 180 | - | worktreePath string | |
| 181 | - | rootSource string | |
| 182 | - | configValue string | |
| 183 | - | setConfig bool | |
| 184 | - | bootstrapRequired bool | |
| 185 | - | previewText string | |
| 186 | - | preview core.ToolResult | |
| 187 | - | ignore worktreesIgnoreUpdate | |
| 153 | + | repoRoot string | |
| 154 | + | branch string | |
| 155 | + | base string | |
| 156 | + | worktreeRoot string | |
| 157 | + | worktreePath string | |
| 158 | + | rootSource string | |
| 159 | + | repoID string | |
| 160 | + | previewText string | |
| 161 | + | preview core.ToolResult | |
| 188 | 162 | } | |
| 189 | 163 | ||
| 190 | 164 | func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | |
| @@ -251,67 +225,28 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 251 | 225 | return createWorktreePlan{}, err | |
| 252 | 226 | } | |
| 253 | 227 | ||
| 228 | + | repoID := worktreeRepoID(commonDir) | |
| 254 | 229 | configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey) | |
| 255 | 230 | if err != nil { | |
| 256 | 231 | return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err) | |
| 257 | 232 | } | |
| 258 | - | bootstrapRoot := strings.TrimSpace(args.BootstrapRoot) | |
| 259 | - | ||
| 260 | - | rootSource := "existing .worktrees directory" | |
| 261 | - | configValue := "" | |
| 262 | - | setConfig := false | |
| 263 | - | defaultRoot := false | |
| 264 | 233 | var worktreeRoot string | |
| 234 | + | rootSource := "global default root" | |
| 265 | 235 | if configured { | |
| 266 | - | worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot) | |
| 236 | + | worktreeRoot, err = resolveConfiguredWorktreeRoot(configuredRoot) | |
| 267 | 237 | if err != nil { | |
| 268 | 238 | return createWorktreePlan{}, err | |
| 269 | 239 | } | |
| 270 | - | if bootstrapRoot != "" { | |
| 271 | - | requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot) | |
| 272 | - | if err != nil { | |
| 273 | - | return createWorktreePlan{}, err | |
| 274 | - | } | |
| 275 | - | configuredCanonical, err := canonicalOrParent(worktreeRoot) | |
| 276 | - | if err != nil { | |
| 277 | - | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err) | |
| 278 | - | } | |
| 279 | - | requestedCanonical, err := canonicalOrParent(requestedRoot) | |
| 280 | - | if err != nil { | |
| 281 | - | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err) | |
| 282 | - | } | |
| 283 | - | if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical { | |
| 284 | - | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again") | |
| 285 | - | } | |
| 286 | - | } | |
| 287 | 240 | rootSource = "local Git config" | |
| 288 | 241 | } else { | |
| 289 | - | defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot) | |
| 290 | - | exists, err := inspectWorktreeRoot(defaultPath) | |
| 242 | + | defaultRoot, err := t.defaultRoot() | |
| 291 | 243 | if err != nil { | |
| 292 | 244 | return createWorktreePlan{}, err | |
| 293 | 245 | } | |
| 294 | - | if !exists && bootstrapRoot == "" { | |
| 295 | - | return bootstrapRequiredPlan(branch, repoRoot), nil | |
| 296 | - | } | |
| 297 | - | if exists { | |
| 298 | - | if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot { | |
| 299 | - | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root") | |
| 300 | - | } | |
| 301 | - | worktreeRoot = defaultPath | |
| 302 | - | defaultRoot = true | |
| 303 | - | } else { | |
| 304 | - | worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot) | |
| 305 | - | if err != nil { | |
| 306 | - | return createWorktreePlan{}, err | |
| 307 | - | } | |
| 308 | - | setConfig = true | |
| 309 | - | rootSource = "bootstrap selection" | |
| 310 | - | } | |
| 246 | + | worktreeRoot = filepath.Join(defaultRoot, repoID) | |
| 311 | 247 | } | |
| 312 | 248 | ||
| 313 | - | worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot) | |
| 314 | - | if err != nil { | |
| 249 | + | if err := validateWorktreeRoot(worktreeRoot); err != nil { | |
| 315 | 250 | return createWorktreePlan{}, err | |
| 316 | 251 | } | |
| 317 | 252 | worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch)) | |
| @@ -319,24 +254,18 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 319 | 254 | if err != nil { | |
| 320 | 255 | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err) | |
| 321 | 256 | } | |
| 322 | - | if !defaultRoot && isUnder(repoRoot, rootCanonical) { | |
| 323 | - | return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository") | |
| 257 | + | if isUnder(repoRoot, rootCanonical) { | |
| 258 | + | return createWorktreePlan{}, errors.New("create_worktree: worktree root must be outside the repository") | |
| 324 | 259 | } | |
| 325 | 260 | pathCanonical, err := canonicalOrParent(worktreePath) | |
| 326 | 261 | if err != nil { | |
| 327 | 262 | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err) | |
| 328 | 263 | } | |
| 329 | 264 | if !isUnder(rootCanonical, pathCanonical) { | |
| 330 | - | return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root") | |
| 265 | + | return createWorktreePlan{}, errors.New("create_worktree: branch path escapes the worktree root") | |
| 331 | 266 | } | |
| 332 | 267 | ||
| 333 | - | ignorePath := "" | |
| 334 | - | accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath} | |
| 335 | - | if defaultRoot { | |
| 336 | - | ignorePath = filepath.Join(repoRoot, ".gitignore") | |
| 337 | - | accessPaths = append(accessPaths, ignorePath) | |
| 338 | - | } | |
| 339 | - | if err := t.checkReadWriteAccess(accessPaths...); err != nil { | |
| 268 | + | if err := t.checkReadWriteAccess(repoRoot, commonDir, worktreeRoot, worktreePath); err != nil { | |
| 340 | 269 | return createWorktreePlan{}, err | |
| 341 | 270 | } | |
| 342 | 271 | if _, err := os.Lstat(worktreePath); err == nil { | |
| @@ -352,29 +281,9 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 352 | 281 | return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch) | |
| 353 | 282 | } | |
| 354 | 283 | ||
| 355 | - | ignore := worktreesIgnoreUpdate{} | |
| 356 | - | ignoreAction := "not modified (external worktree root)" | |
| 357 | - | if defaultRoot { | |
| 358 | - | ignore, err = newWorktreesIgnoreUpdate(ignorePath) | |
| 359 | - | if err != nil { | |
| 360 | - | return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err) | |
| 361 | - | } | |
| 362 | - | ignoreAction = "already ignores " + defaultWorktreeRoot + "/" | |
| 363 | - | if ignore.changed { | |
| 364 | - | ignoreAction = "add " + worktreesIgnoreEntry | |
| 365 | - | } | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | displayRoot := worktreeRoot | |
| 369 | - | if defaultRoot { | |
| 370 | - | displayRoot = defaultWorktreeRoot | |
| 371 | - | } | |
| 372 | - | displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot) | |
| 373 | - | configAction := "" | |
| 374 | - | if setConfig { | |
| 375 | - | configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue) | |
| 376 | - | } | |
| 377 | - | previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction) | |
| 284 | + | displayRoot := filepath.ToSlash(worktreeRoot) | |
| 285 | + | displayPath := filepath.ToSlash(worktreePath) | |
| 286 | + | previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath) | |
| 378 | 287 | return createWorktreePlan{ | |
| 379 | 288 | repoRoot: repoRoot, | |
| 380 | 289 | branch: branch, | |
| @@ -382,40 +291,72 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||
| 382 | 291 | worktreeRoot: worktreeRoot, | |
| 383 | 292 | worktreePath: worktreePath, | |
| 384 | 293 | rootSource: rootSource, | |
| 385 | - | configValue: configValue, | |
| 386 | - | setConfig: setConfig, | |
| 294 | + | repoID: repoID, | |
| 387 | 295 | previewText: previewText, | |
| 388 | 296 | preview: core.ToolResult{ | |
| 389 | 297 | Content: []provider.Content{provider.TextBlock{Text: previewText}}, | |
| 390 | 298 | Details: map[string]any{ | |
| 391 | - | "state": "ready", | |
| 392 | - | "branch": branch, | |
| 393 | - | "base": base, | |
| 394 | - | "worktree_root": displayRoot, | |
| 395 | - | "root_source": rootSource, | |
| 396 | - | "path": displayPath, | |
| 397 | - | "bootstrap_saved": setConfig, | |
| 299 | + | "state": "ready", | |
| 300 | + | "branch": branch, | |
| 301 | + | "base": base, | |
| 302 | + | "worktree_root": displayRoot, | |
| 303 | + | "root_source": rootSource, | |
| 304 | + | "repo_id": repoID, | |
| 305 | + | "path": displayPath, | |
| 398 | 306 | }, | |
| 399 | 307 | }, | |
| 400 | - | ignore: ignore, | |
| 401 | 308 | }, nil | |
| 402 | 309 | } | |
| 403 | 310 | ||
| 404 | - | func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan { | |
| 405 | - | text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot)) | |
| 406 | - | return createWorktreePlan{ | |
| 407 | - | branch: branch, | |
| 408 | - | bootstrapRequired: true, | |
| 409 | - | previewText: text, | |
| 410 | - | preview: core.ToolResult{ | |
| 411 | - | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 412 | - | Details: map[string]any{ | |
| 413 | - | "state": "bootstrap_required", | |
| 414 | - | "branch": branch, | |
| 415 | - | "default_bootstrap_root": defaultWorktreeRoot, | |
| 416 | - | }, | |
| 417 | - | }, | |
| 311 | + | func (t *CreateWorktreeTool) defaultRoot() (string, error) { | |
| 312 | + | root := strings.TrimSpace(t.DefaultRoot) | |
| 313 | + | if root == "" { | |
| 314 | + | return "", errors.New("create_worktree: default worktree root is not configured") | |
| 315 | + | } | |
| 316 | + | return canonicalOrParent(root) | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // worktreeRepoID derives a stable, collision-resistant directory name for the | |
| 320 | + | // repository that owns commonDir. The name pairs a readable slug with a hash of | |
| 321 | + | // the canonical common directory so repositories sharing a base name still get | |
| 322 | + | // distinct roots while one repository keeps the same directory across sessions. | |
| 323 | + | func worktreeRepoID(commonDir string) string { | |
| 324 | + | sum := sha256.Sum256([]byte(filepath.ToSlash(commonDir))) | |
| 325 | + | return worktreeRepoSlug(commonDir) + "-" + hex.EncodeToString(sum[:])[:worktreeRepoIDHashLength] | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | func worktreeRepoSlug(commonDir string) string { | |
| 329 | + | base := filepath.Base(commonDir) | |
| 330 | + | if trimmed := strings.TrimSuffix(base, ".git"); trimmed != "" { | |
| 331 | + | base = trimmed | |
| 332 | + | } else { | |
| 333 | + | base = filepath.Base(filepath.Dir(commonDir)) | |
| 334 | + | } | |
| 335 | + | var builder strings.Builder | |
| 336 | + | dashed := false | |
| 337 | + | for _, r := range base { | |
| 338 | + | switch { | |
| 339 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |
| 340 | + | builder.WriteRune(r) | |
| 341 | + | dashed = false | |
| 342 | + | case r >= 'A' && r <= 'Z': | |
| 343 | + | builder.WriteRune(r + ('a' - 'A')) | |
| 344 | + | dashed = false | |
| 345 | + | default: | |
| 346 | + | if !dashed { | |
| 347 | + | builder.WriteByte('-') | |
| 348 | + | dashed = true | |
| 349 | + | } | |
| 350 | + | } | |
| 418 | 351 | } | |
| 352 | + | slug := strings.Trim(builder.String(), "-._") | |
| 353 | + | if len(slug) > worktreeRepoSlugMaxLen { | |
| 354 | + | slug = strings.Trim(slug[:worktreeRepoSlugMaxLen], "-._") | |
| 355 | + | } | |
| 356 | + | if slug == "" { | |
| 357 | + | slug = "repo" | |
| 358 | + | } | |
| 359 | + | return slug | |
| 419 | 360 | } | |
| 420 | 361 | ||
| 421 | 362 | func (t *CreateWorktreeTool) accessibleCWD() (string, error) { | |
| @@ -476,95 +417,42 @@ func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error { | |||
| 476 | 417 | return nil | |
| 477 | 418 | } | |
| 478 | 419 | ||
| 479 | - | func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) { | |
| 420 | + | func resolveConfiguredWorktreeRoot(value string) (string, error) { | |
| 480 | 421 | value = strings.TrimSpace(value) | |
| 481 | - | if value == defaultWorktreeRoot { | |
| 482 | - | return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil | |
| 422 | + | if value == "" { | |
| 423 | + | return "", errors.New("create_worktree: local worktree configuration is empty") | |
| 483 | 424 | } | |
| 484 | 425 | if !filepath.IsAbs(value) { | |
| 485 | - | return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path") | |
| 426 | + | return "", errors.New("create_worktree: local worktree configuration must be an absolute path; remove a legacy relative value with `git config --local --unset zut.worktrees.path`") | |
| 486 | 427 | } | |
| 487 | - | return value, false, nil | |
| 428 | + | return filepath.Clean(value), nil | |
| 488 | 429 | } | |
| 489 | 430 | ||
| 490 | - | func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) { | |
| 491 | - | if value == defaultWorktreeRoot { | |
| 492 | - | return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil | |
| 493 | - | } | |
| 494 | - | if !filepath.IsAbs(value) { | |
| 495 | - | return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path") | |
| 496 | - | } | |
| 497 | - | root, err := canonicalOrParent(value) | |
| 498 | - | if err != nil { | |
| 499 | - | return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err) | |
| 500 | - | } | |
| 501 | - | if isUnder(repoRoot, root) { | |
| 502 | - | return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository") | |
| 503 | - | } | |
| 504 | - | return root, false, root, nil | |
| 505 | - | } | |
| 506 | - | ||
| 507 | - | func validateWorktreeRoot(path string, defaultRoot bool) (string, error) { | |
| 431 | + | func validateWorktreeRoot(path string) error { | |
| 508 | 432 | info, err := os.Lstat(path) | |
| 509 | 433 | if os.IsNotExist(err) { | |
| 510 | - | return path, nil | |
| 511 | - | } | |
| 512 | - | if err != nil { | |
| 513 | - | return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err) | |
| 514 | - | } | |
| 515 | - | if info.Mode()&os.ModeSymlink != 0 { | |
| 516 | - | return "", errors.New("create_worktree: worktree root must not be a symbolic link") | |
| 517 | - | } | |
| 518 | - | if !info.IsDir() { | |
| 519 | - | return "", errors.New("create_worktree: worktree root exists but is not a directory") | |
| 520 | - | } | |
| 521 | - | if defaultRoot && filepath.Base(path) != defaultWorktreeRoot { | |
| 522 | - | return "", errors.New("create_worktree: invalid default worktree root") | |
| 523 | - | } | |
| 524 | - | return path, nil | |
| 525 | - | } | |
| 526 | - | ||
| 527 | - | func inspectWorktreeRoot(path string) (bool, error) { | |
| 528 | - | info, err := os.Lstat(path) | |
| 529 | - | if os.IsNotExist(err) { | |
| 530 | - | return false, nil | |
| 434 | + | return nil | |
| 531 | 435 | } | |
| 532 | 436 | if err != nil { | |
| 533 | - | return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err) | |
| 437 | + | return fmt.Errorf("create_worktree: inspect worktree root: %w", err) | |
| 534 | 438 | } | |
| 535 | 439 | if info.Mode()&os.ModeSymlink != 0 { | |
| 536 | - | return false, errors.New("create_worktree: .worktrees must not be a symbolic link") | |
| 440 | + | return errors.New("create_worktree: worktree root must not be a symbolic link") | |
| 537 | 441 | } | |
| 538 | 442 | if !info.IsDir() { | |
| 539 | - | return false, errors.New("create_worktree: .worktrees exists but is not a directory") | |
| 443 | + | return errors.New("create_worktree: worktree root exists but is not a directory") | |
| 540 | 444 | } | |
| 541 | - | return true, nil | |
| 542 | - | } | |
| 543 | - | ||
| 544 | - | func displayWorktreePath(repoRoot, path string, defaultRoot bool) string { | |
| 545 | - | if defaultRoot { | |
| 546 | - | relative, err := filepath.Rel(repoRoot, path) | |
| 547 | - | if err == nil { | |
| 548 | - | return filepath.ToSlash(relative) | |
| 549 | - | } | |
| 550 | - | } | |
| 551 | - | return filepath.ToSlash(path) | |
| 445 | + | return nil | |
| 552 | 446 | } | |
| 553 | 447 | ||
| 554 | 448 | func sameCreateWorktreePlan(a, b createWorktreePlan) bool { | |
| 555 | - | return a.bootstrapRequired == b.bootstrapRequired && | |
| 556 | - | a.repoRoot == b.repoRoot && | |
| 449 | + | return a.repoRoot == b.repoRoot && | |
| 557 | 450 | a.branch == b.branch && | |
| 558 | 451 | a.base == b.base && | |
| 559 | 452 | a.worktreeRoot == b.worktreeRoot && | |
| 560 | 453 | a.worktreePath == b.worktreePath && | |
| 561 | 454 | a.rootSource == b.rootSource && | |
| 562 | - | a.configValue == b.configValue && | |
| 563 | - | a.setConfig == b.setConfig && | |
| 564 | - | a.ignore.path == b.ignore.path && | |
| 565 | - | a.ignore.existed == b.ignore.existed && | |
| 566 | - | a.ignore.changed == b.ignore.changed && | |
| 567 | - | bytes.Equal(a.ignore.before, b.ignore.before) | |
| 455 | + | a.repoID == b.repoID | |
| 568 | 456 | } | |
| 569 | 457 | ||
| 570 | 458 | func missingWorktreeParents(worktreePath string) ([]string, error) { | |
| @@ -601,21 +489,6 @@ func removeCreatedWorktreeParents(parents []string) error { | |||
| 601 | 489 | return nil | |
| 602 | 490 | } | |
| 603 | 491 | ||
| 604 | - | func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error { | |
| 605 | - | value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey) | |
| 606 | - | if err != nil { | |
| 607 | - | return err | |
| 608 | - | } | |
| 609 | - | if !configured { | |
| 610 | - | return nil | |
| 611 | - | } | |
| 612 | - | if value != p.configValue { | |
| 613 | - | return errors.New("local worktree configuration changed during creation") | |
| 614 | - | } | |
| 615 | - | _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey) | |
| 616 | - | return err | |
| 617 | - | } | |
| 618 | - | ||
| 619 | 492 | func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error { | |
| 620 | 493 | registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath) | |
| 621 | 494 | if err != nil { | |
| @@ -803,122 +676,3 @@ func createWorktreeGitEnv() []string { | |||
| 803 | 676 | "SSH_ASKPASS=", | |
| 804 | 677 | ) | |
| 805 | 678 | } | |
| 806 | - | ||
| 807 | - | type worktreesIgnoreUpdate struct { | |
| 808 | - | path string | |
| 809 | - | before []byte | |
| 810 | - | after []byte | |
| 811 | - | existed bool | |
| 812 | - | mode os.FileMode | |
| 813 | - | changed bool | |
| 814 | - | } | |
| 815 | - | ||
| 816 | - | func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) { | |
| 817 | - | contents, mode, existed, err := readWorktreesIgnore(path) | |
| 818 | - | if err != nil { | |
| 819 | - | return worktreesIgnoreUpdate{}, err | |
| 820 | - | } | |
| 821 | - | for _, line := range strings.Split(string(contents), "\n") { | |
| 822 | - | switch strings.TrimSuffix(line, "\r") { | |
| 823 | - | case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry: | |
| 824 | - | return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil | |
| 825 | - | } | |
| 826 | - | } | |
| 827 | - | lineEnding := "\n" | |
| 828 | - | if bytes.Contains(contents, []byte("\r\n")) { | |
| 829 | - | lineEnding = "\r\n" | |
| 830 | - | } | |
| 831 | - | after := append([]byte(nil), contents...) | |
| 832 | - | if len(after) > 0 && !strings.HasSuffix(string(after), "\n") { | |
| 833 | - | after = append(after, lineEnding...) | |
| 834 | - | } | |
| 835 | - | after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...) | |
| 836 | - | return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil | |
| 837 | - | } | |
| 838 | - | ||
| 839 | - | func (u worktreesIgnoreUpdate) apply() error { | |
| 840 | - | if !u.changed { | |
| 841 | - | return nil | |
| 842 | - | } | |
| 843 | - | current, _, existed, err := readWorktreesIgnore(u.path) | |
| 844 | - | if err != nil { | |
| 845 | - | return err | |
| 846 | - | } | |
| 847 | - | if existed != u.existed || !bytes.Equal(current, u.before) { | |
| 848 | - | return errors.New(".gitignore changed since preview; inspect it and retry") | |
| 849 | - | } | |
| 850 | - | return writeWorktreesIgnore(u.path, u.after, u.mode) | |
| 851 | - | } | |
| 852 | - | ||
| 853 | - | func (u worktreesIgnoreUpdate) restore() error { | |
| 854 | - | if !u.changed { | |
| 855 | - | return nil | |
| 856 | - | } | |
| 857 | - | current, _, existed, err := readWorktreesIgnore(u.path) | |
| 858 | - | if err != nil { | |
| 859 | - | return err | |
| 860 | - | } | |
| 861 | - | if !existed || !bytes.Equal(current, u.after) { | |
| 862 | - | return nil | |
| 863 | - | } | |
| 864 | - | if !u.existed { | |
| 865 | - | if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) { | |
| 866 | - | return err | |
| 867 | - | } | |
| 868 | - | return nil | |
| 869 | - | } | |
| 870 | - | return writeWorktreesIgnore(u.path, u.before, u.mode) | |
| 871 | - | } | |
| 872 | - | ||
| 873 | - | func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) { | |
| 874 | - | info, err := os.Lstat(path) | |
| 875 | - | if os.IsNotExist(err) { | |
| 876 | - | return nil, 0o644, false, nil | |
| 877 | - | } | |
| 878 | - | if err != nil { | |
| 879 | - | return nil, 0, false, err | |
| 880 | - | } | |
| 881 | - | if info.Mode()&os.ModeSymlink != 0 { | |
| 882 | - | return nil, 0, false, errors.New(".gitignore must not be a symbolic link") | |
| 883 | - | } | |
| 884 | - | if !info.Mode().IsRegular() { | |
| 885 | - | return nil, 0, false, errors.New(".gitignore must be a regular file") | |
| 886 | - | } | |
| 887 | - | if info.Size() > maxCreateWorktreeIgnoreBytes { | |
| 888 | - | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |
| 889 | - | } | |
| 890 | - | file, err := os.Open(path) | |
| 891 | - | if err != nil { | |
| 892 | - | return nil, 0, false, err | |
| 893 | - | } | |
| 894 | - | defer file.Close() | |
| 895 | - | contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1)) | |
| 896 | - | if err != nil { | |
| 897 | - | return nil, 0, false, err | |
| 898 | - | } | |
| 899 | - | if len(contents) > maxCreateWorktreeIgnoreBytes { | |
| 900 | - | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |
| 901 | - | } | |
| 902 | - | return contents, info.Mode().Perm(), true, nil | |
| 903 | - | } | |
| 904 | - | ||
| 905 | - | func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error { | |
| 906 | - | file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*") | |
| 907 | - | if err != nil { | |
| 908 | - | return err | |
| 909 | - | } | |
| 910 | - | tempPath := file.Name() | |
| 911 | - | defer os.Remove(tempPath) | |
| 912 | - | if err := file.Chmod(mode); err != nil { | |
| 913 | - | _ = file.Close() | |
| 914 | - | return err | |
| 915 | - | } | |
| 916 | - | if _, err := file.Write(contents); err != nil { | |
| 917 | - | _ = file.Close() | |
| 918 | - | return err | |
| 919 | - | } | |
| 920 | - | if err := file.Close(); err != nil { | |
| 921 | - | return err | |
| 922 | - | } | |
| 923 | - | return os.Rename(tempPath, path) | |
| 924 | - | } | |
| @@ -3,6 +3,8 @@ package tools | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 7 | + | "encoding/hex" | |||
| 6 | "encoding/json" | 8 | "encoding/json" | ||
| 7 | "errors" | 9 | "errors" | ||
| 8 | "fmt" | 10 | "fmt" | ||
| @@ -19,30 +21,31 @@ import ( | |||||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| 21 | const ( | 23 | const ( | ||
| 22 | - | worktreesIgnoreEntry = "/.worktrees/" | 24 | + | worktreeConfigKey = "zut.worktrees.path" |
| 23 | - | worktreesUnanchoredIgnoreEntry = ".worktrees/" | 25 | + | maxCreateWorktreeGitOutput = 64 * 1024 |
| 24 | - | defaultWorktreeRoot = ".worktrees" | 26 | + | // worktreeRepoIDHashLength keeps the hash short enough to read but wide |
| 25 | - | worktreeConfigKey = "zut.worktrees.path" | 27 | + | // enough that distinct repositories do not collide in practice. |
| 26 | - | maxCreateWorktreeGitOutput = 64 * 1024 | 28 | + | worktreeRepoIDHashLength = 12 |
| 27 | - | maxCreateWorktreeIgnoreBytes = 1 << 20 | 29 | + | worktreeRepoSlugMaxLen = 40 |
| 28 | ) | 30 | ) | ||
| 29 | 31 | ||||
| 30 | var createWorktreeOperationMu sync.Mutex | 32 | var createWorktreeOperationMu sync.Mutex | ||
| 31 | 33 | ||||
| 32 | - | // CreateWorktreeTool creates a persistent branch checkout. A repository that | 34 | + | // CreateWorktreeTool creates a persistent branch checkout. An unconfigured |
| 33 | - | // has neither a local worktree configuration nor an existing .worktrees | 35 | + | // repository checks out under a stable per-repository directory beneath |
| 34 | - | // directory returns a bootstrap request without changing any files. | 36 | + | // DefaultRoot. A local zut.worktrees.path Git configuration entry overrides |
| 37 | + | // that root for the repository. | |||
| 35 | type CreateWorktreeTool struct { | 38 | type CreateWorktreeTool struct { | ||
| 36 | - | CWD string | 39 | + | CWD string |
| 37 | - | Sandbox *Sandbox | 40 | + | Sandbox *Sandbox |
| 41 | + | DefaultRoot string | |||
| 38 | 42 | ||||
| 39 | previewMu sync.Mutex | 43 | previewMu sync.Mutex | ||
| 40 | preview *createWorktreePreview | 44 | preview *createWorktreePreview | ||
| 41 | } | 45 | } | ||
| 42 | 46 | ||||
| 43 | type createWorktreeArgs struct { | 47 | type createWorktreeArgs struct { | ||
| 44 | - | Branch string `json:"branch"` | 48 | + | Branch string `json:"branch"` |
| 45 | - | BootstrapRoot string `json:"bootstrap_root,omitempty"` | |||
| 46 | } | 49 | } | ||
| 47 | 50 | ||||
| 48 | const createWorktreeSchema = `{ | 51 | const createWorktreeSchema = `{ | ||
| @@ -51,10 +54,6 @@ const createWorktreeSchema = `{ | |||||
| 51 | "branch":{ | 54 | "branch":{ | ||
| 52 | "type":"string", | 55 | "type":"string", | ||
| 53 | "description":"Name of the new Git branch." | 56 | "description":"Name of the new Git branch." | ||
| 54 | - | }, | |||
| 55 | - | "bootstrap_root":{ | |||
| 56 | - | "type":"string", | |||
| 57 | - | "description":"Only for an unconfigured repository: use .worktrees to initialize the repository-root default, or provide an absolute external worktree root. The choice is stored in local Git config. Omit it on the first call to request user guidance instead of changing files." | |||
| 58 | } | 57 | } | ||
| 59 | }, | 58 | }, | ||
| 60 | "required":["branch"] | 59 | "required":["branch"] | ||
| @@ -62,12 +61,12 @@ const createWorktreeSchema = `{ | |||||
| 62 | 61 | ||||
| 63 | func (t *CreateWorktreeTool) Name() string { return "create_worktree" } | 62 | func (t *CreateWorktreeTool) Name() string { return "create_worktree" } | ||
| 64 | func (t *CreateWorktreeTool) Description() string { | 63 | func (t *CreateWorktreeTool) Description() string { | ||
| 65 | - | return "Create a persistent Git branch worktree. An unconfigured repository returns bootstrap guidance; ask the user for .worktrees or an absolute external root, then call again with bootstrap_root." | 64 | + | return "Create a persistent Git branch worktree under the repository's configured worktree root or zut's global worktrees root." |
| 66 | } | 65 | } | ||
| 67 | func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) } | 66 | func (t *CreateWorktreeTool) Schema() json.RawMessage { return json.RawMessage(createWorktreeSchema) } | ||
| 68 | 67 | ||||
| 69 | - | // Preview reports the exact bootstrap request or checkout that Execute will | 68 | + | // Preview reports the checkout that Execute will produce without modifying the |
| 70 | - | // produce without modifying the repository. | 69 | + | // repository or filesystem. |
| 71 | func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | 70 | func (t *CreateWorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | ||
| 72 | plan, err := t.plan(ctx, raw) | 71 | plan, err := t.plan(ctx, raw) | ||
| 73 | if err != nil { | 72 | if err != nil { | ||
| @@ -87,20 +86,13 @@ func (t *CreateWorktreeTool) Execute(ctx context.Context, raw json.RawMessage, p | |||||
| 87 | if err != nil { | 86 | if err != nil { | ||
| 88 | return core.ToolResult{}, err | 87 | return core.ToolResult{}, err | ||
| 89 | } | 88 | } | ||
| 90 | - | if plan.bootstrapRequired { | |||
| 91 | - | return plan.preview, nil | |||
| 92 | - | } | |||
| 93 | return executeCreateWorktreePlan(ctx, plan, progress) | 89 | return executeCreateWorktreePlan(ctx, plan, progress) | ||
| 94 | } | 90 | } | ||
| 95 | 91 | ||||
| 96 | func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) { | 92 | func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, progress func(string)) (core.ToolResult, error) { | ||
| 97 | - | configSaved := false | 93 | + | createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath) |
| 98 | - | var createdWorktreeParents []string | 94 | + | if err != nil { |
| 99 | - | if plan.setConfig { | 95 | + | return core.ToolResult{}, fmt.Errorf("create_worktree: inspect worktree directories: %w", err) |
| 100 | - | if _, err := createWorktreeGitOutput(ctx, plan.repoRoot, "config", "--local", "--replace-all", worktreeConfigKey, plan.configValue); err != nil { | |||
| 101 | - | return core.ToolResult{}, fmt.Errorf("create_worktree: save worktree root: %w", err) | |||
| 102 | - | } | |||
| 103 | - | configSaved = true | |||
| 104 | } | 96 | } | ||
| 105 | rollback := func(cause error) error { | 97 | rollback := func(cause error) error { | ||
| 106 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | 98 | cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| @@ -111,24 +103,9 @@ func executeCreateWorktreePlan(ctx context.Context, plan createWorktreePlan, pro | |||||
| 111 | if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil { | 103 | if err := removeCreatedWorktreeParents(createdWorktreeParents); err != nil { | ||
| 112 | cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err) | 104 | cause = fmt.Errorf("%w; remove created worktree directories: %v", cause, err) | ||
| 113 | } | 105 | } | ||
| 114 | - | if err := plan.ignore.restore(); err != nil { | |||
| 115 | - | cause = fmt.Errorf("%w; restore .gitignore: %v", cause, err) | |||
| 116 | - | } | |||
| 117 | - | if configSaved { | |||
| 118 | - | if err := plan.removeBootstrapConfig(cleanupCtx); err != nil { | |||
| 119 | - | cause = fmt.Errorf("%w; remove local worktree configuration: %v", cause, err) | |||
| 120 | - | } | |||
| 121 | - | } | |||
| 122 | return cause | 106 | return cause | ||
| 123 | } | 107 | } | ||
| 124 | - | if err := plan.ignore.apply(); err != nil { | |||
| 125 | - | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: update .gitignore: %w", err)) | |||
| 126 | - | } | |||
| 127 | 108 | ||||
| 128 | - | createdWorktreeParents, err := missingWorktreeParents(plan.worktreePath) | |||
| 129 | - | if err != nil { | |||
| 130 | - | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: inspect worktree directories: %w", err)) | |||
| 131 | - | } | |||
| 132 | if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil { | 109 | if err := os.MkdirAll(filepath.Dir(plan.worktreePath), 0o755); err != nil { | ||
| 133 | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err)) | 110 | return core.ToolResult{}, rollback(fmt.Errorf("create_worktree: make worktree directory: %w", err)) | ||
| 134 | } | 111 | } | ||
| @@ -173,18 +150,15 @@ func (t *CreateWorktreeTool) planForExecute(ctx context.Context, raw json.RawMes | |||||
| 173 | } | 150 | } | ||
| 174 | 151 | ||||
| 175 | type createWorktreePlan struct { | 152 | type createWorktreePlan struct { | ||
| 176 | - | repoRoot string | 153 | + | repoRoot string |
| 177 | - | branch string | 154 | + | branch string |
| 178 | - | base string | 155 | + | base string |
| 179 | - | worktreeRoot string | 156 | + | worktreeRoot string |
| 180 | - | worktreePath string | 157 | + | worktreePath string |
| 181 | - | rootSource string | 158 | + | rootSource string |
| 182 | - | configValue string | 159 | + | repoID string |
| 183 | - | setConfig bool | 160 | + | previewText string |
| 184 | - | bootstrapRequired bool | 161 | + | preview core.ToolResult |
| 185 | - | previewText string | |||
| 186 | - | preview core.ToolResult | |||
| 187 | - | ignore worktreesIgnoreUpdate | |||
| 188 | } | 162 | } | ||
| 189 | 163 | ||||
| 190 | func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | 164 | func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (createWorktreePlan, error) { | ||
| @@ -251,67 +225,28 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||||
| 251 | return createWorktreePlan{}, err | 225 | return createWorktreePlan{}, err | ||
| 252 | } | 226 | } | ||
| 253 | 227 | ||||
| 228 | + | repoID := worktreeRepoID(commonDir) | |||
| 254 | configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey) | 229 | configuredRoot, configured, err := createWorktreeGitConfigValue(ctx, repoRoot, worktreeConfigKey) | ||
| 255 | if err != nil { | 230 | if err != nil { | ||
| 256 | return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err) | 231 | return createWorktreePlan{}, fmt.Errorf("create_worktree: read local worktree configuration: %w", err) | ||
| 257 | } | 232 | } | ||
| 258 | - | bootstrapRoot := strings.TrimSpace(args.BootstrapRoot) | |||
| 259 | - | ||||
| 260 | - | rootSource := "existing .worktrees directory" | |||
| 261 | - | configValue := "" | |||
| 262 | - | setConfig := false | |||
| 263 | - | defaultRoot := false | |||
| 264 | var worktreeRoot string | 233 | var worktreeRoot string | ||
| 234 | + | rootSource := "global default root" | |||
| 265 | if configured { | 235 | if configured { | ||
| 266 | - | worktreeRoot, defaultRoot, err = resolveConfiguredWorktreeRoot(repoRoot, configuredRoot) | 236 | + | worktreeRoot, err = resolveConfiguredWorktreeRoot(configuredRoot) |
| 267 | if err != nil { | 237 | if err != nil { | ||
| 268 | return createWorktreePlan{}, err | 238 | return createWorktreePlan{}, err | ||
| 269 | } | 239 | } | ||
| 270 | - | if bootstrapRoot != "" { | |||
| 271 | - | requestedRoot, requestedDefault, _, err := resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot) | |||
| 272 | - | if err != nil { | |||
| 273 | - | return createWorktreePlan{}, err | |||
| 274 | - | } | |||
| 275 | - | configuredCanonical, err := canonicalOrParent(worktreeRoot) | |||
| 276 | - | if err != nil { | |||
| 277 | - | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve configured worktree root: %w", err) | |||
| 278 | - | } | |||
| 279 | - | requestedCanonical, err := canonicalOrParent(requestedRoot) | |||
| 280 | - | if err != nil { | |||
| 281 | - | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err) | |||
| 282 | - | } | |||
| 283 | - | if defaultRoot != requestedDefault || configuredCanonical != requestedCanonical { | |||
| 284 | - | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already configured; change the local Git configuration explicitly before bootstrapping again") | |||
| 285 | - | } | |||
| 286 | - | } | |||
| 287 | rootSource = "local Git config" | 240 | rootSource = "local Git config" | ||
| 288 | } else { | 241 | } else { | ||
| 289 | - | defaultPath := filepath.Join(repoRoot, defaultWorktreeRoot) | 242 | + | defaultRoot, err := t.defaultRoot() |
| 290 | - | exists, err := inspectWorktreeRoot(defaultPath) | |||
| 291 | if err != nil { | 243 | if err != nil { | ||
| 292 | return createWorktreePlan{}, err | 244 | return createWorktreePlan{}, err | ||
| 293 | } | 245 | } | ||
| 294 | - | if !exists && bootstrapRoot == "" { | 246 | + | worktreeRoot = filepath.Join(defaultRoot, repoID) |
| 295 | - | return bootstrapRequiredPlan(branch, repoRoot), nil | |||
| 296 | - | } | |||
| 297 | - | if exists { | |||
| 298 | - | if bootstrapRoot != "" && bootstrapRoot != defaultWorktreeRoot { | |||
| 299 | - | return createWorktreePlan{}, errors.New("create_worktree: worktree root is already established; omit bootstrap_root") | |||
| 300 | - | } | |||
| 301 | - | worktreeRoot = defaultPath | |||
| 302 | - | defaultRoot = true | |||
| 303 | - | } else { | |||
| 304 | - | worktreeRoot, defaultRoot, configValue, err = resolveBootstrapWorktreeRoot(repoRoot, bootstrapRoot) | |||
| 305 | - | if err != nil { | |||
| 306 | - | return createWorktreePlan{}, err | |||
| 307 | - | } | |||
| 308 | - | setConfig = true | |||
| 309 | - | rootSource = "bootstrap selection" | |||
| 310 | - | } | |||
| 311 | } | 247 | } | ||
| 312 | 248 | ||||
| 313 | - | worktreeRoot, err = validateWorktreeRoot(worktreeRoot, defaultRoot) | 249 | + | if err := validateWorktreeRoot(worktreeRoot); err != nil { |
| 314 | - | if err != nil { | |||
| 315 | return createWorktreePlan{}, err | 250 | return createWorktreePlan{}, err | ||
| 316 | } | 251 | } | ||
| 317 | worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch)) | 252 | worktreePath := filepath.Join(worktreeRoot, filepath.FromSlash(branch)) | ||
| @@ -319,24 +254,18 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||||
| 319 | if err != nil { | 254 | if err != nil { | ||
| 320 | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err) | 255 | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree root: %w", err) | ||
| 321 | } | 256 | } | ||
| 322 | - | if !defaultRoot && isUnder(repoRoot, rootCanonical) { | 257 | + | if isUnder(repoRoot, rootCanonical) { |
| 323 | - | return createWorktreePlan{}, errors.New("create_worktree: external worktree root must be outside the repository") | 258 | + | return createWorktreePlan{}, errors.New("create_worktree: worktree root must be outside the repository") |
| 324 | } | 259 | } | ||
| 325 | pathCanonical, err := canonicalOrParent(worktreePath) | 260 | pathCanonical, err := canonicalOrParent(worktreePath) | ||
| 326 | if err != nil { | 261 | if err != nil { | ||
| 327 | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err) | 262 | return createWorktreePlan{}, fmt.Errorf("create_worktree: resolve worktree path: %w", err) | ||
| 328 | } | 263 | } | ||
| 329 | if !isUnder(rootCanonical, pathCanonical) { | 264 | if !isUnder(rootCanonical, pathCanonical) { | ||
| 330 | - | return createWorktreePlan{}, errors.New("create_worktree: branch path escapes configured worktree root") | 265 | + | return createWorktreePlan{}, errors.New("create_worktree: branch path escapes the worktree root") |
| 331 | } | 266 | } | ||
| 332 | 267 | ||||
| 333 | - | ignorePath := "" | 268 | + | if err := t.checkReadWriteAccess(repoRoot, commonDir, worktreeRoot, worktreePath); err != nil { |
| 334 | - | accessPaths := []string{repoRoot, commonDir, worktreeRoot, worktreePath} | |||
| 335 | - | if defaultRoot { | |||
| 336 | - | ignorePath = filepath.Join(repoRoot, ".gitignore") | |||
| 337 | - | accessPaths = append(accessPaths, ignorePath) | |||
| 338 | - | } | |||
| 339 | - | if err := t.checkReadWriteAccess(accessPaths...); err != nil { | |||
| 340 | return createWorktreePlan{}, err | 269 | return createWorktreePlan{}, err | ||
| 341 | } | 270 | } | ||
| 342 | if _, err := os.Lstat(worktreePath); err == nil { | 271 | if _, err := os.Lstat(worktreePath); err == nil { | ||
| @@ -352,29 +281,9 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||||
| 352 | return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch) | 281 | return createWorktreePlan{}, fmt.Errorf("create_worktree: branch %q already exists", branch) | ||
| 353 | } | 282 | } | ||
| 354 | 283 | ||||
| 355 | - | ignore := worktreesIgnoreUpdate{} | 284 | + | displayRoot := filepath.ToSlash(worktreeRoot) |
| 356 | - | ignoreAction := "not modified (external worktree root)" | 285 | + | displayPath := filepath.ToSlash(worktreePath) |
| 357 | - | if defaultRoot { | 286 | + | previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n", branch, base, displayRoot, rootSource, displayPath) |
| 358 | - | ignore, err = newWorktreesIgnoreUpdate(ignorePath) | |||
| 359 | - | if err != nil { | |||
| 360 | - | return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err) | |||
| 361 | - | } | |||
| 362 | - | ignoreAction = "already ignores " + defaultWorktreeRoot + "/" | |||
| 363 | - | if ignore.changed { | |||
| 364 | - | ignoreAction = "add " + worktreesIgnoreEntry | |||
| 365 | - | } | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | displayRoot := worktreeRoot | |||
| 369 | - | if defaultRoot { | |||
| 370 | - | displayRoot = defaultWorktreeRoot | |||
| 371 | - | } | |||
| 372 | - | displayPath := displayWorktreePath(repoRoot, worktreePath, defaultRoot) | |||
| 373 | - | configAction := "" | |||
| 374 | - | if setConfig { | |||
| 375 | - | configAction = fmt.Sprintf("local config: set %s=%s\n", worktreeConfigKey, configValue) | |||
| 376 | - | } | |||
| 377 | - | previewText := fmt.Sprintf("create worktree\nbranch: %s\nbase: %s\nworktree root: %s\nroot source: %s\npath: %s\n%s.gitignore: %s\n", branch, base, filepath.ToSlash(displayRoot), rootSource, displayPath, configAction, ignoreAction) | |||
| 378 | return createWorktreePlan{ | 287 | return createWorktreePlan{ | ||
| 379 | repoRoot: repoRoot, | 288 | repoRoot: repoRoot, | ||
| 380 | branch: branch, | 289 | branch: branch, | ||
| @@ -382,40 +291,72 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre | |||||
| 382 | worktreeRoot: worktreeRoot, | 291 | worktreeRoot: worktreeRoot, | ||
| 383 | worktreePath: worktreePath, | 292 | worktreePath: worktreePath, | ||
| 384 | rootSource: rootSource, | 293 | rootSource: rootSource, | ||
| 385 | - | configValue: configValue, | 294 | + | repoID: repoID, |
| 386 | - | setConfig: setConfig, | |||
| 387 | previewText: previewText, | 295 | previewText: previewText, | ||
| 388 | preview: core.ToolResult{ | 296 | preview: core.ToolResult{ | ||
| 389 | Content: []provider.Content{provider.TextBlock{Text: previewText}}, | 297 | Content: []provider.Content{provider.TextBlock{Text: previewText}}, | ||
| 390 | Details: map[string]any{ | 298 | Details: map[string]any{ | ||
| 391 | - | "state": "ready", | 299 | + | "state": "ready", |
| 392 | - | "branch": branch, | 300 | + | "branch": branch, |
| 393 | - | "base": base, | 301 | + | "base": base, |
| 394 | - | "worktree_root": displayRoot, | 302 | + | "worktree_root": displayRoot, |
| 395 | - | "root_source": rootSource, | 303 | + | "root_source": rootSource, |
| 396 | - | "path": displayPath, | 304 | + | "repo_id": repoID, |
| 397 | - | "bootstrap_saved": setConfig, | 305 | + | "path": displayPath, |
| 398 | }, | 306 | }, | ||
| 399 | }, | 307 | }, | ||
| 400 | - | ignore: ignore, | |||
| 401 | }, nil | 308 | }, nil | ||
| 402 | } | 309 | } | ||
| 403 | 310 | ||||
| 404 | - | func bootstrapRequiredPlan(branch, repoRoot string) createWorktreePlan { | 311 | + | func (t *CreateWorktreeTool) defaultRoot() (string, error) { |
| 405 | - | text := fmt.Sprintf("Worktree bootstrap is required.\nbranch: %s\nrepository: %s\nChoose a location with the user, then call create_worktree again with bootstrap_root set to .worktrees for the repository default or to an absolute external directory. This call made no changes.\n", branch, filepath.ToSlash(repoRoot)) | 312 | + | root := strings.TrimSpace(t.DefaultRoot) |
| 406 | - | return createWorktreePlan{ | 313 | + | if root == "" { |
| 407 | - | branch: branch, | 314 | + | return "", errors.New("create_worktree: default worktree root is not configured") |
| 408 | - | bootstrapRequired: true, | 315 | + | } |
| 409 | - | previewText: text, | 316 | + | return canonicalOrParent(root) |
| 410 | - | preview: core.ToolResult{ | 317 | + | } |
| 411 | - | Content: []provider.Content{provider.TextBlock{Text: text}}, | 318 | + | |
| 412 | - | Details: map[string]any{ | 319 | + | // worktreeRepoID derives a stable, collision-resistant directory name for the |
| 413 | - | "state": "bootstrap_required", | 320 | + | // repository that owns commonDir. The name pairs a readable slug with a hash of |
| 414 | - | "branch": branch, | 321 | + | // the canonical common directory so repositories sharing a base name still get |
| 415 | - | "default_bootstrap_root": defaultWorktreeRoot, | 322 | + | // distinct roots while one repository keeps the same directory across sessions. |
| 416 | - | }, | 323 | + | func worktreeRepoID(commonDir string) string { |
| 417 | - | }, | 324 | + | sum := sha256.Sum256([]byte(filepath.ToSlash(commonDir))) |
| 325 | + | return worktreeRepoSlug(commonDir) + "-" + hex.EncodeToString(sum[:])[:worktreeRepoIDHashLength] | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | func worktreeRepoSlug(commonDir string) string { | |||
| 329 | + | base := filepath.Base(commonDir) | |||
| 330 | + | if trimmed := strings.TrimSuffix(base, ".git"); trimmed != "" { | |||
| 331 | + | base = trimmed | |||
| 332 | + | } else { | |||
| 333 | + | base = filepath.Base(filepath.Dir(commonDir)) | |||
| 334 | + | } | |||
| 335 | + | var builder strings.Builder | |||
| 336 | + | dashed := false | |||
| 337 | + | for _, r := range base { | |||
| 338 | + | switch { | |||
| 339 | + | case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': | |||
| 340 | + | builder.WriteRune(r) | |||
| 341 | + | dashed = false | |||
| 342 | + | case r >= 'A' && r <= 'Z': | |||
| 343 | + | builder.WriteRune(r + ('a' - 'A')) | |||
| 344 | + | dashed = false | |||
| 345 | + | default: | |||
| 346 | + | if !dashed { | |||
| 347 | + | builder.WriteByte('-') | |||
| 348 | + | dashed = true | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 418 | } | 351 | } | ||
| 352 | + | slug := strings.Trim(builder.String(), "-._") | |||
| 353 | + | if len(slug) > worktreeRepoSlugMaxLen { | |||
| 354 | + | slug = strings.Trim(slug[:worktreeRepoSlugMaxLen], "-._") | |||
| 355 | + | } | |||
| 356 | + | if slug == "" { | |||
| 357 | + | slug = "repo" | |||
| 358 | + | } | |||
| 359 | + | return slug | |||
| 419 | } | 360 | } | ||
| 420 | 361 | ||||
| 421 | func (t *CreateWorktreeTool) accessibleCWD() (string, error) { | 362 | func (t *CreateWorktreeTool) accessibleCWD() (string, error) { | ||
| @@ -476,95 +417,42 @@ func (t *CreateWorktreeTool) checkReadWriteAccess(paths ...string) error { | |||||
| 476 | return nil | 417 | return nil | ||
| 477 | } | 418 | } | ||
| 478 | 419 | ||||
| 479 | - | func resolveConfiguredWorktreeRoot(repoRoot, value string) (string, bool, error) { | 420 | + | func resolveConfiguredWorktreeRoot(value string) (string, error) { |
| 480 | value = strings.TrimSpace(value) | 421 | value = strings.TrimSpace(value) | ||
| 481 | - | if value == defaultWorktreeRoot { | 422 | + | if value == "" { |
| 482 | - | return filepath.Join(repoRoot, defaultWorktreeRoot), true, nil | 423 | + | return "", errors.New("create_worktree: local worktree configuration is empty") |
| 483 | } | 424 | } | ||
| 484 | if !filepath.IsAbs(value) { | 425 | if !filepath.IsAbs(value) { | ||
| 485 | - | return "", false, errors.New("create_worktree: local worktree configuration must be .worktrees or an absolute external path") | 426 | + | return "", errors.New("create_worktree: local worktree configuration must be an absolute path; remove a legacy relative value with `git config --local --unset zut.worktrees.path`") |
| 486 | } | 427 | } | ||
| 487 | - | return value, false, nil | 428 | + | return filepath.Clean(value), nil |
| 488 | } | 429 | } | ||
| 489 | 430 | ||||
| 490 | - | func resolveBootstrapWorktreeRoot(repoRoot, value string) (string, bool, string, error) { | 431 | + | func validateWorktreeRoot(path string) error { |
| 491 | - | if value == defaultWorktreeRoot { | |||
| 492 | - | return filepath.Join(repoRoot, defaultWorktreeRoot), true, defaultWorktreeRoot, nil | |||
| 493 | - | } | |||
| 494 | - | if !filepath.IsAbs(value) { | |||
| 495 | - | return "", false, "", errors.New("create_worktree: bootstrap_root must be .worktrees or an absolute external path") | |||
| 496 | - | } | |||
| 497 | - | root, err := canonicalOrParent(value) | |||
| 498 | - | if err != nil { | |||
| 499 | - | return "", false, "", fmt.Errorf("create_worktree: resolve bootstrap_root: %w", err) | |||
| 500 | - | } | |||
| 501 | - | if isUnder(repoRoot, root) { | |||
| 502 | - | return "", false, "", errors.New("create_worktree: external bootstrap_root must be outside the repository") | |||
| 503 | - | } | |||
| 504 | - | return root, false, root, nil | |||
| 505 | - | } | |||
| 506 | - | ||||
| 507 | - | func validateWorktreeRoot(path string, defaultRoot bool) (string, error) { | |||
| 508 | info, err := os.Lstat(path) | 432 | info, err := os.Lstat(path) | ||
| 509 | if os.IsNotExist(err) { | 433 | if os.IsNotExist(err) { | ||
| 510 | - | return path, nil | 434 | + | return nil |
| 511 | - | } | |||
| 512 | - | if err != nil { | |||
| 513 | - | return "", fmt.Errorf("create_worktree: inspect worktree root: %w", err) | |||
| 514 | - | } | |||
| 515 | - | if info.Mode()&os.ModeSymlink != 0 { | |||
| 516 | - | return "", errors.New("create_worktree: worktree root must not be a symbolic link") | |||
| 517 | - | } | |||
| 518 | - | if !info.IsDir() { | |||
| 519 | - | return "", errors.New("create_worktree: worktree root exists but is not a directory") | |||
| 520 | - | } | |||
| 521 | - | if defaultRoot && filepath.Base(path) != defaultWorktreeRoot { | |||
| 522 | - | return "", errors.New("create_worktree: invalid default worktree root") | |||
| 523 | - | } | |||
| 524 | - | return path, nil | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | func inspectWorktreeRoot(path string) (bool, error) { | |||
| 528 | - | info, err := os.Lstat(path) | |||
| 529 | - | if os.IsNotExist(err) { | |||
| 530 | - | return false, nil | |||
| 531 | } | 435 | } | ||
| 532 | if err != nil { | 436 | if err != nil { | ||
| 533 | - | return false, fmt.Errorf("create_worktree: inspect .worktrees: %w", err) | 437 | + | return fmt.Errorf("create_worktree: inspect worktree root: %w", err) |
| 534 | } | 438 | } | ||
| 535 | if info.Mode()&os.ModeSymlink != 0 { | 439 | if info.Mode()&os.ModeSymlink != 0 { | ||
| 536 | - | return false, errors.New("create_worktree: .worktrees must not be a symbolic link") | 440 | + | return errors.New("create_worktree: worktree root must not be a symbolic link") |
| 537 | } | 441 | } | ||
| 538 | if !info.IsDir() { | 442 | if !info.IsDir() { | ||
| 539 | - | return false, errors.New("create_worktree: .worktrees exists but is not a directory") | 443 | + | return errors.New("create_worktree: worktree root exists but is not a directory") |
| 540 | } | 444 | } | ||
| 541 | - | return true, nil | 445 | + | return nil |
| 542 | - | } | |||
| 543 | - | ||||
| 544 | - | func displayWorktreePath(repoRoot, path string, defaultRoot bool) string { | |||
| 545 | - | if defaultRoot { | |||
| 546 | - | relative, err := filepath.Rel(repoRoot, path) | |||
| 547 | - | if err == nil { | |||
| 548 | - | return filepath.ToSlash(relative) | |||
| 549 | - | } | |||
| 550 | - | } | |||
| 551 | - | return filepath.ToSlash(path) | |||
| 552 | } | 446 | } | ||
| 553 | 447 | ||||
| 554 | func sameCreateWorktreePlan(a, b createWorktreePlan) bool { | 448 | func sameCreateWorktreePlan(a, b createWorktreePlan) bool { | ||
| 555 | - | return a.bootstrapRequired == b.bootstrapRequired && | 449 | + | return a.repoRoot == b.repoRoot && |
| 556 | - | a.repoRoot == b.repoRoot && | |||
| 557 | a.branch == b.branch && | 450 | a.branch == b.branch && | ||
| 558 | a.base == b.base && | 451 | a.base == b.base && | ||
| 559 | a.worktreeRoot == b.worktreeRoot && | 452 | a.worktreeRoot == b.worktreeRoot && | ||
| 560 | a.worktreePath == b.worktreePath && | 453 | a.worktreePath == b.worktreePath && | ||
| 561 | a.rootSource == b.rootSource && | 454 | a.rootSource == b.rootSource && | ||
| 562 | - | a.configValue == b.configValue && | 455 | + | a.repoID == b.repoID |
| 563 | - | a.setConfig == b.setConfig && | |||
| 564 | - | a.ignore.path == b.ignore.path && | |||
| 565 | - | a.ignore.existed == b.ignore.existed && | |||
| 566 | - | a.ignore.changed == b.ignore.changed && | |||
| 567 | - | bytes.Equal(a.ignore.before, b.ignore.before) | |||
| 568 | } | 456 | } | ||
| 569 | 457 | ||||
| 570 | func missingWorktreeParents(worktreePath string) ([]string, error) { | 458 | func missingWorktreeParents(worktreePath string) ([]string, error) { | ||
| @@ -601,21 +489,6 @@ func removeCreatedWorktreeParents(parents []string) error { | |||||
| 601 | return nil | 489 | return nil | ||
| 602 | } | 490 | } | ||
| 603 | 491 | ||||
| 604 | - | func (p createWorktreePlan) removeBootstrapConfig(ctx context.Context) error { | |||
| 605 | - | value, configured, err := createWorktreeGitConfigValue(ctx, p.repoRoot, worktreeConfigKey) | |||
| 606 | - | if err != nil { | |||
| 607 | - | return err | |||
| 608 | - | } | |||
| 609 | - | if !configured { | |||
| 610 | - | return nil | |||
| 611 | - | } | |||
| 612 | - | if value != p.configValue { | |||
| 613 | - | return errors.New("local worktree configuration changed during creation") | |||
| 614 | - | } | |||
| 615 | - | _, err = createWorktreeGitOutput(ctx, p.repoRoot, "config", "--local", "--unset-all", worktreeConfigKey) | |||
| 616 | - | return err | |||
| 617 | - | } | |||
| 618 | - | ||||
| 619 | func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error { | 492 | func (p createWorktreePlan) cleanupFailedCheckout(ctx context.Context) error { | ||
| 620 | registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath) | 493 | registered, err := createWorktreeRegistered(ctx, p.repoRoot, p.worktreePath) | ||
| 621 | if err != nil { | 494 | if err != nil { | ||
| @@ -803,122 +676,3 @@ func createWorktreeGitEnv() []string { | |||||
| 803 | "SSH_ASKPASS=", | 676 | "SSH_ASKPASS=", | ||
| 804 | ) | 677 | ) | ||
| 805 | } | 678 | } | ||
| 806 | - | ||||
| 807 | - | type worktreesIgnoreUpdate struct { | |||
| 808 | - | path string | |||
| 809 | - | before []byte | |||
| 810 | - | after []byte | |||
| 811 | - | existed bool | |||
| 812 | - | mode os.FileMode | |||
| 813 | - | changed bool | |||
| 814 | - | } | |||
| 815 | - | ||||
| 816 | - | func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) { | |||
| 817 | - | contents, mode, existed, err := readWorktreesIgnore(path) | |||
| 818 | - | if err != nil { | |||
| 819 | - | return worktreesIgnoreUpdate{}, err | |||
| 820 | - | } | |||
| 821 | - | for _, line := range strings.Split(string(contents), "\n") { | |||
| 822 | - | switch strings.TrimSuffix(line, "\r") { | |||
| 823 | - | case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry: | |||
| 824 | - | return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil | |||
| 825 | - | } | |||
| 826 | - | } | |||
| 827 | - | lineEnding := "\n" | |||
| 828 | - | if bytes.Contains(contents, []byte("\r\n")) { | |||
| 829 | - | lineEnding = "\r\n" | |||
| 830 | - | } | |||
| 831 | - | after := append([]byte(nil), contents...) | |||
| 832 | - | if len(after) > 0 && !strings.HasSuffix(string(after), "\n") { | |||
| 833 | - | after = append(after, lineEnding...) | |||
| 834 | - | } | |||
| 835 | - | after = append(after, []byte(worktreesIgnoreEntry+lineEnding)...) | |||
| 836 | - | return worktreesIgnoreUpdate{path: path, before: contents, after: after, existed: existed, mode: mode, changed: true}, nil | |||
| 837 | - | } | |||
| 838 | - | ||||
| 839 | - | func (u worktreesIgnoreUpdate) apply() error { | |||
| 840 | - | if !u.changed { | |||
| 841 | - | return nil | |||
| 842 | - | } | |||
| 843 | - | current, _, existed, err := readWorktreesIgnore(u.path) | |||
| 844 | - | if err != nil { | |||
| 845 | - | return err | |||
| 846 | - | } | |||
| 847 | - | if existed != u.existed || !bytes.Equal(current, u.before) { | |||
| 848 | - | return errors.New(".gitignore changed since preview; inspect it and retry") | |||
| 849 | - | } | |||
| 850 | - | return writeWorktreesIgnore(u.path, u.after, u.mode) | |||
| 851 | - | } | |||
| 852 | - | ||||
| 853 | - | func (u worktreesIgnoreUpdate) restore() error { | |||
| 854 | - | if !u.changed { | |||
| 855 | - | return nil | |||
| 856 | - | } | |||
| 857 | - | current, _, existed, err := readWorktreesIgnore(u.path) | |||
| 858 | - | if err != nil { | |||
| 859 | - | return err | |||
| 860 | - | } | |||
| 861 | - | if !existed || !bytes.Equal(current, u.after) { | |||
| 862 | - | return nil | |||
| 863 | - | } | |||
| 864 | - | if !u.existed { | |||
| 865 | - | if err := os.Remove(u.path); err != nil && !os.IsNotExist(err) { | |||
| 866 | - | return err | |||
| 867 | - | } | |||
| 868 | - | return nil | |||
| 869 | - | } | |||
| 870 | - | return writeWorktreesIgnore(u.path, u.before, u.mode) | |||
| 871 | - | } | |||
| 872 | - | ||||
| 873 | - | func readWorktreesIgnore(path string) ([]byte, os.FileMode, bool, error) { | |||
| 874 | - | info, err := os.Lstat(path) | |||
| 875 | - | if os.IsNotExist(err) { | |||
| 876 | - | return nil, 0o644, false, nil | |||
| 877 | - | } | |||
| 878 | - | if err != nil { | |||
| 879 | - | return nil, 0, false, err | |||
| 880 | - | } | |||
| 881 | - | if info.Mode()&os.ModeSymlink != 0 { | |||
| 882 | - | return nil, 0, false, errors.New(".gitignore must not be a symbolic link") | |||
| 883 | - | } | |||
| 884 | - | if !info.Mode().IsRegular() { | |||
| 885 | - | return nil, 0, false, errors.New(".gitignore must be a regular file") | |||
| 886 | - | } | |||
| 887 | - | if info.Size() > maxCreateWorktreeIgnoreBytes { | |||
| 888 | - | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |||
| 889 | - | } | |||
| 890 | - | file, err := os.Open(path) | |||
| 891 | - | if err != nil { | |||
| 892 | - | return nil, 0, false, err | |||
| 893 | - | } | |||
| 894 | - | defer file.Close() | |||
| 895 | - | contents, err := io.ReadAll(io.LimitReader(file, maxCreateWorktreeIgnoreBytes+1)) | |||
| 896 | - | if err != nil { | |||
| 897 | - | return nil, 0, false, err | |||
| 898 | - | } | |||
| 899 | - | if len(contents) > maxCreateWorktreeIgnoreBytes { | |||
| 900 | - | return nil, 0, false, fmt.Errorf(".gitignore exceeds %d bytes", maxCreateWorktreeIgnoreBytes) | |||
| 901 | - | } | |||
| 902 | - | return contents, info.Mode().Perm(), true, nil | |||
| 903 | - | } | |||
| 904 | - | ||||
| 905 | - | func writeWorktreesIgnore(path string, contents []byte, mode os.FileMode) error { | |||
| 906 | - | file, err := os.CreateTemp(filepath.Dir(path), ".zut-gitignore-*") | |||
| 907 | - | if err != nil { | |||
| 908 | - | return err | |||
| 909 | - | } | |||
| 910 | - | tempPath := file.Name() | |||
| 911 | - | defer os.Remove(tempPath) | |||
| 912 | - | if err := file.Chmod(mode); err != nil { | |||
| 913 | - | _ = file.Close() | |||
| 914 | - | return err | |||
| 915 | - | } | |||
| 916 | - | if _, err := file.Write(contents); err != nil { | |||
| 917 | - | _ = file.Close() | |||
| 918 | - | return err | |||
| 919 | - | } | |||
| 920 | - | if err := file.Close(); err != nil { | |||
| 921 | - | return err | |||
| 922 | - | } | |||
| 923 | - | return os.Rename(tempPath, path) | |||
| 924 | - | } | |||
M packages/agent/tools/create_worktree_test.go +416 -526
| @@ -15,179 +15,158 @@ import ( | |||
| 15 | 15 | "github.com/bnema/zut/packages/provider" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) { | |
| 18 | + | func TestCreateWorktreeUsesGlobalDefaultRoot(t *testing.T) { | |
| 19 | 19 | if _, err := exec.LookPath("git"); err != nil { | |
| 20 | 20 | t.Skip("git is required") | |
| 21 | 21 | } | |
| 22 | 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 | - | } | |
| 23 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 30 | 24 | branchBefore := gitTestOutput(t, repo, "branch", "--show-current") | |
| 31 | - | sandbox := NewSandbox(repo) | |
| 32 | - | sandbox.Lock() | |
| 25 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 33 | 26 | ||
| 34 | - | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |
| 35 | - | args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"}) | |
| 27 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/default-root"}`), nil) | |
| 36 | 28 | if err != nil { | |
| 37 | 29 | t.Fatal(err) | |
| 38 | 30 | } | |
| 39 | - | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |
| 40 | - | t.Fatal(err) | |
| 41 | - | } | |
| 42 | 31 | ||
| 43 | - | worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout") | |
| 32 | + | repoID := testWorktreeRepoID(t, repo) | |
| 33 | + | worktree := filepath.Join(defaultRoot, repoID, "feature", "default-root") | |
| 44 | 34 | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 45 | 35 | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 46 | 36 | } | |
| 47 | - | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" { | |
| 48 | - | t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout") | |
| 37 | + | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/default-root" { | |
| 38 | + | t.Fatalf("worktree branch = %q, want %q", got, "feature/default-root") | |
| 49 | 39 | } | |
| 50 | 40 | if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore { | |
| 51 | 41 | t.Fatalf("source branch changed from %q to %q", branchBefore, got) | |
| 52 | 42 | } | |
| 53 | - | ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |
| 54 | - | if err != nil { | |
| 55 | - | t.Fatal(err) | |
| 43 | + | details := result.Details.(map[string]any) | |
| 44 | + | if state, _ := details["state"].(string); state != "created" { | |
| 45 | + | t.Fatalf("state = %q, want created", state) | |
| 56 | 46 | } | |
| 57 | - | if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 { | |
| 58 | - | t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore) | |
| 47 | + | if source, _ := details["root_source"].(string); source != "global default root" { | |
| 48 | + | t.Fatalf("root source = %q", source) | |
| 59 | 49 | } | |
| 60 | - | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot { | |
| 61 | - | t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot) | |
| 50 | + | if id, _ := details["repo_id"].(string); id != repoID { | |
| 51 | + | t.Fatalf("repo_id = %q, want %q", id, repoID) | |
| 62 | 52 | } | |
| 63 | - | followupArgs := mustJSON(t, map[string]string{ | |
| 64 | - | "branch": "feature/default-followup", | |
| 65 | - | "bootstrap_root": defaultWorktreeRoot, | |
| 66 | - | }) | |
| 67 | - | if _, err := tool.Preview(context.Background(), followupArgs); err != nil { | |
| 53 | + | wantRoot, err := filepath.EvalSymlinks(filepath.Join(defaultRoot, repoID)) | |
| 54 | + | if err != nil { | |
| 68 | 55 | t.Fatal(err) | |
| 69 | 56 | } | |
| 70 | - | if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil { | |
| 71 | - | t.Fatal(err) | |
| 57 | + | if got, _ := details["worktree_root"].(string); got != filepath.ToSlash(wantRoot) { | |
| 58 | + | t.Fatalf("worktree_root = %q, want %q", got, filepath.ToSlash(wantRoot)) | |
| 59 | + | } | |
| 60 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 61 | + | t.Fatalf("default root saved local config: %#v", got) | |
| 72 | 62 | } | |
| 73 | - | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil { | |
| 74 | - | t.Fatalf("configured default root was not reused: %v", err) | |
| 63 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 64 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 65 | + | t.Fatalf("default root created repository file %s: %v", path, statErr) | |
| 66 | + | } | |
| 75 | 67 | } | |
| 76 | 68 | } | |
| 77 | 69 | ||
| 78 | - | func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) { | |
| 70 | + | func TestCreateWorktreeDefaultRootIsStableAcrossInvocations(t *testing.T) { | |
| 79 | 71 | if _, err := exec.LookPath("git"); err != nil { | |
| 80 | 72 | t.Skip("git is required") | |
| 81 | 73 | } | |
| 82 | 74 | repo := initWorktreeTestRepo(t) | |
| 83 | - | tool := newCreateWorktreeTestTool(repo) | |
| 84 | - | ||
| 85 | - | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil) | |
| 86 | - | if err != nil { | |
| 75 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 76 | + | nested := filepath.Join(repo, "nested", "dir") | |
| 77 | + | if err := os.MkdirAll(nested, 0o755); err != nil { | |
| 87 | 78 | t.Fatal(err) | |
| 88 | 79 | } | |
| 89 | - | text := result.Content[0].(provider.TextBlock).Text | |
| 90 | - | if !strings.Contains(text, "Worktree bootstrap is required") { | |
| 91 | - | t.Fatalf("bootstrap result = %q", text) | |
| 80 | + | repoID := testWorktreeRepoID(t, repo) | |
| 81 | + | ||
| 82 | + | nestedTool := newCreateWorktreeTestTool(t, nested, defaultRoot) | |
| 83 | + | if _, err := nestedTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-nested"}`), nil); err != nil { | |
| 84 | + | t.Fatal(err) | |
| 92 | 85 | } | |
| 93 | - | if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" { | |
| 94 | - | t.Fatalf("bootstrap state = %q", state) | |
| 86 | + | rootTool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 87 | + | if _, err := rootTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-root"}`), nil); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 95 | 89 | } | |
| 96 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 97 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 98 | - | t.Fatalf("bootstrap created %s: %v", path, statErr) | |
| 90 | + | ||
| 91 | + | for _, branch := range []string{"feature/from-nested", "feature/from-root"} { | |
| 92 | + | worktree := filepath.Join(defaultRoot, repoID, filepath.FromSlash(branch)) | |
| 93 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 94 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 99 | 95 | } | |
| 100 | 96 | } | |
| 101 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 102 | - | t.Fatalf("local worktree config exit = %d, want 1", got.exitCode) | |
| 103 | - | } | |
| 104 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 { | |
| 105 | - | t.Fatalf("bootstrap created branch: %#v", got) | |
| 106 | - | } | |
| 107 | 97 | } | |
| 108 | 98 | ||
| 109 | - | func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) { | |
| 99 | + | func TestCreateWorktreeDefaultRootSlugIsReadable(t *testing.T) { | |
| 110 | 100 | if _, err := exec.LookPath("git"); err != nil { | |
| 111 | 101 | t.Skip("git is required") | |
| 112 | 102 | } | |
| 113 | - | repo := initWorktreeTestRepo(t) | |
| 114 | - | if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil { | |
| 103 | + | parent := t.TempDir() | |
| 104 | + | repo := filepath.Join(parent, "My Project!") | |
| 105 | + | if err := os.Mkdir(repo, 0o755); err != nil { | |
| 115 | 106 | t.Fatal(err) | |
| 116 | 107 | } | |
| 117 | - | tool := newCreateWorktreeTestTool(repo) | |
| 118 | - | args := mustJSON(t, map[string]string{ | |
| 119 | - | "branch": "feature/existing-default-root", | |
| 120 | - | "bootstrap_root": defaultWorktreeRoot, | |
| 121 | - | }) | |
| 108 | + | initWorktreeTestRepoAt(t, repo) | |
| 109 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 110 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 122 | 111 | ||
| 123 | - | if _, err := tool.Preview(context.Background(), args); err != nil { | |
| 112 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/slug"}`), nil); err != nil { | |
| 124 | 113 | t.Fatal(err) | |
| 125 | 114 | } | |
| 126 | - | result, err := tool.Execute(context.Background(), args, nil) | |
| 115 | + | entries, err := os.ReadDir(defaultRoot) | |
| 127 | 116 | if err != nil { | |
| 128 | 117 | t.Fatal(err) | |
| 129 | 118 | } | |
| 130 | - | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil { | |
| 131 | - | t.Fatalf("worktree was not created: %v", err) | |
| 132 | - | } | |
| 133 | - | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" { | |
| 134 | - | t.Fatalf("root source = %q", source) | |
| 119 | + | if len(entries) != 1 { | |
| 120 | + | t.Fatalf("default root entries = %d, want 1", len(entries)) | |
| 135 | 121 | } | |
| 136 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 137 | - | t.Fatalf("existing default root saved config: %#v", got) | |
| 122 | + | if name := entries[0].Name(); !strings.HasPrefix(name, "my-project-") { | |
| 123 | + | t.Fatalf("repo directory %q does not carry a readable slug", name) | |
| 138 | 124 | } | |
| 139 | 125 | } | |
| 140 | 126 | ||
| 141 | - | func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) { | |
| 127 | + | func TestCreateWorktreeSeparatesSameNamedRepositories(t *testing.T) { | |
| 142 | 128 | if _, err := exec.LookPath("git"); err != nil { | |
| 143 | 129 | t.Skip("git is required") | |
| 144 | 130 | } | |
| 145 | - | repo := initWorktreeTestRepo(t) | |
| 146 | - | if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil { | |
| 147 | - | t.Fatal(err) | |
| 131 | + | parent := t.TempDir() | |
| 132 | + | repoA := filepath.Join(parent, "a", "shared") | |
| 133 | + | repoB := filepath.Join(parent, "b", "shared") | |
| 134 | + | for _, repo := range []string{repoA, repoB} { | |
| 135 | + | if err := os.MkdirAll(repo, 0o755); err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | initWorktreeTestRepoAt(t, repo) | |
| 148 | 139 | } | |
| 149 | - | tool := newCreateWorktreeTestTool(repo) | |
| 140 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 150 | 141 | ||
| 151 | - | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{ | |
| 152 | - | "branch": "feature/conflicting-bootstrap", | |
| 153 | - | "bootstrap_root": t.TempDir(), | |
| 154 | - | }), nil) | |
| 155 | - | if err == nil || !strings.Contains(err.Error(), "already established") { | |
| 156 | - | t.Fatalf("error = %v, want established-root error", err) | |
| 157 | - | } | |
| 158 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 159 | - | t.Fatalf("conflicting bootstrap saved config: %#v", got) | |
| 142 | + | toolA := newCreateWorktreeTestTool(t, repoA, defaultRoot) | |
| 143 | + | if _, err := toolA.Execute(context.Background(), json.RawMessage(`{"branch":"feature/a"}`), nil); err != nil { | |
| 144 | + | t.Fatal(err) | |
| 160 | 145 | } | |
| 161 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 { | |
| 162 | - | t.Fatalf("conflicting bootstrap created branch: %#v", got) | |
| 146 | + | toolB := newCreateWorktreeTestTool(t, repoB, defaultRoot) | |
| 147 | + | if _, err := toolB.Execute(context.Background(), json.RawMessage(`{"branch":"feature/b"}`), nil); err != nil { | |
| 148 | + | t.Fatal(err) | |
| 163 | 149 | } | |
| 164 | - | } | |
| 165 | 150 | ||
| 166 | - | func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) { | |
| 167 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 168 | - | t.Skip("git is required") | |
| 151 | + | if idA, idB := testWorktreeRepoID(t, repoA), testWorktreeRepoID(t, repoB); idA == idB { | |
| 152 | + | t.Fatalf("same-named repositories share repo ID %q", idA) | |
| 169 | 153 | } | |
| 170 | - | repo := initWorktreeTestRepo(t) | |
| 171 | - | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot) | |
| 172 | - | tool := newCreateWorktreeTestTool(repo) | |
| 173 | - | args := mustJSON(t, map[string]string{ | |
| 174 | - | "branch": "feature/conflicting-configured-bootstrap", | |
| 175 | - | "bootstrap_root": t.TempDir(), | |
| 176 | - | }) | |
| 177 | - | ||
| 178 | - | if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") { | |
| 179 | - | t.Fatalf("preview error = %v, want configured-root error", err) | |
| 154 | + | entries, err := os.ReadDir(defaultRoot) | |
| 155 | + | if err != nil { | |
| 156 | + | t.Fatal(err) | |
| 180 | 157 | } | |
| 181 | - | _, err := tool.Execute(context.Background(), args, nil) | |
| 182 | - | if err == nil || !strings.Contains(err.Error(), "already configured") { | |
| 183 | - | t.Fatalf("error = %v, want configured-root error", err) | |
| 158 | + | if len(entries) != 2 { | |
| 159 | + | t.Fatalf("default root entries = %d, want 2", len(entries)) | |
| 184 | 160 | } | |
| 185 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 { | |
| 186 | - | t.Fatalf("conflicting bootstrap created branch: %#v", got) | |
| 161 | + | for repo, branch := range map[string]string{repoA: "feature/a", repoB: "feature/b"} { | |
| 162 | + | worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), filepath.FromSlash(branch)) | |
| 163 | + | if _, err := os.Stat(worktree); err != nil { | |
| 164 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 165 | + | } | |
| 187 | 166 | } | |
| 188 | 167 | } | |
| 189 | 168 | ||
| 190 | - | func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) { | |
| 169 | + | func TestCreateWorktreeConfiguredRootOverridesDefaultRoot(t *testing.T) { | |
| 191 | 170 | if _, err := exec.LookPath("git"); err != nil { | |
| 192 | 171 | t.Skip("git is required") | |
| 193 | 172 | } | |
| @@ -196,10 +175,11 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing | |||
| 196 | 175 | if err != nil { | |
| 197 | 176 | t.Fatal(err) | |
| 198 | 177 | } | |
| 199 | - | tool := newCreateWorktreeTestTool(repo) | |
| 200 | - | args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot}) | |
| 178 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 179 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, externalRoot) | |
| 180 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 201 | 181 | ||
| 202 | - | result, err := tool.Execute(context.Background(), args, nil) | |
| 182 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external"}`), nil) | |
| 203 | 183 | if err != nil { | |
| 204 | 184 | t.Fatal(err) | |
| 205 | 185 | } | |
| @@ -210,51 +190,119 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing | |||
| 210 | 190 | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" { | |
| 211 | 191 | t.Fatalf("external branch = %q", got) | |
| 212 | 192 | } | |
| 213 | - | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot { | |
| 214 | - | t.Fatalf("saved external root = %q, want %q", got, externalRoot) | |
| 193 | + | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "local Git config" { | |
| 194 | + | t.Fatalf("root source = %q", source) | |
| 215 | 195 | } | |
| 216 | - | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |
| 217 | - | t.Fatalf("external bootstrap changed .gitignore: %v", err) | |
| 196 | + | if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) { | |
| 197 | + | t.Fatalf("configured root still touched the default root: %v", err) | |
| 218 | 198 | } | |
| 219 | - | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" { | |
| 220 | - | t.Fatalf("root source = %q", source) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestCreateWorktreeRejectsRelativeConfiguredRoot(t *testing.T) { | |
| 202 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 203 | + | t.Skip("git is required") | |
| 221 | 204 | } | |
| 222 | - | if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" { | |
| 223 | - | t.Fatalf("state = %q, want created", state) | |
| 205 | + | repo := initWorktreeTestRepo(t) | |
| 206 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, ".worktrees") | |
| 207 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |
| 208 | + | ||
| 209 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/relative"}`), nil) | |
| 210 | + | if err == nil || !strings.Contains(err.Error(), "absolute path") { | |
| 211 | + | t.Fatalf("error = %v, want absolute-path error", err) | |
| 224 | 212 | } | |
| 225 | - | followupArgs := mustJSON(t, map[string]string{ | |
| 226 | - | "branch": "feature/external-followup", | |
| 227 | - | "bootstrap_root": externalRoot, | |
| 228 | - | }) | |
| 229 | - | if _, err := tool.Preview(context.Background(), followupArgs); err != nil { | |
| 230 | - | t.Fatal(err) | |
| 213 | + | if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) { | |
| 214 | + | t.Fatalf("relative configured root created directory: %v", statErr) | |
| 231 | 215 | } | |
| 232 | - | followup, err := tool.Execute(context.Background(), followupArgs, nil) | |
| 233 | - | if err != nil { | |
| 234 | - | t.Fatal(err) | |
| 216 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/relative"); got.exitCode != 1 { | |
| 217 | + | t.Fatalf("relative configured root created branch: %#v", got) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestCreateWorktreeRejectsConfiguredRootResolvingInsideRepository(t *testing.T) { | |
| 222 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 223 | + | t.Skip("git is required") | |
| 235 | 224 | } | |
| 236 | - | if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil { | |
| 237 | - | t.Fatalf("configured external root was not reused: %v", err) | |
| 225 | + | repo := initWorktreeTestRepo(t) | |
| 226 | + | link := filepath.Join(t.TempDir(), "repository-link") | |
| 227 | + | if err := os.Symlink(repo, link); err != nil { | |
| 228 | + | t.Skipf("symbolic links are unavailable: %v", err) | |
| 238 | 229 | } | |
| 239 | - | if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" { | |
| 240 | - | t.Fatalf("follow-up root source = %q", source) | |
| 230 | + | root := filepath.Join(link, "external-worktrees") | |
| 231 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root) | |
| 232 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |
| 233 | + | ||
| 234 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil) | |
| 235 | + | if err == nil || !strings.Contains(err.Error(), "must be outside the repository") { | |
| 236 | + | t.Fatalf("error = %v, want outside-repository error", err) | |
| 241 | 237 | } | |
| 242 | 238 | } | |
| 243 | 239 | ||
| 244 | - | func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) { | |
| 240 | + | func TestCreateWorktreeRequiresDefaultRoot(t *testing.T) { | |
| 245 | 241 | if _, err := exec.LookPath("git"); err != nil { | |
| 246 | 242 | t.Skip("git is required") | |
| 247 | 243 | } | |
| 248 | 244 | repo := initWorktreeTestRepo(t) | |
| 249 | - | tool := newCreateWorktreeTestTool(repo) | |
| 250 | - | for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} { | |
| 251 | - | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil) | |
| 252 | - | if err == nil || !strings.Contains(err.Error(), "bootstrap_root") { | |
| 253 | - | t.Fatalf("bootstrap_root %q error = %v", root, err) | |
| 254 | - | } | |
| 245 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo)} | |
| 246 | + | ||
| 247 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-default-root"}`), nil) | |
| 248 | + | if err == nil || !strings.Contains(err.Error(), "default worktree root is not configured") { | |
| 249 | + | t.Fatalf("error = %v, want default-root error", err) | |
| 255 | 250 | } | |
| 256 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 257 | - | t.Fatalf("invalid bootstrap saved config: %#v", got) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestCreateWorktreeSchemaDropsBootstrapRoot(t *testing.T) { | |
| 254 | + | schema := string((&CreateWorktreeTool{}).Schema()) | |
| 255 | + | if strings.Contains(schema, "bootstrap_root") { | |
| 256 | + | t.Fatalf("schema still exposes bootstrap_root:\n%s", schema) | |
| 257 | + | } | |
| 258 | + | var parsed struct { | |
| 259 | + | Properties map[string]json.RawMessage `json:"properties"` | |
| 260 | + | Required []string `json:"required"` | |
| 261 | + | } | |
| 262 | + | if err := json.Unmarshal([]byte(schema), &parsed); err != nil { | |
| 263 | + | t.Fatal(err) | |
| 264 | + | } | |
| 265 | + | if _, ok := parsed.Properties["branch"]; !ok { | |
| 266 | + | t.Fatalf("schema lacks branch: %s", schema) | |
| 267 | + | } | |
| 268 | + | if len(parsed.Properties) != 1 { | |
| 269 | + | t.Fatalf("schema properties = %v, want only branch", parsed.Properties) | |
| 270 | + | } | |
| 271 | + | if len(parsed.Required) != 1 || parsed.Required[0] != "branch" { | |
| 272 | + | t.Fatalf("schema required = %v", parsed.Required) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func TestWorktreeRepoIDIsReadableStableAndDistinct(t *testing.T) { | |
| 277 | + | tests := []struct { | |
| 278 | + | name string | |
| 279 | + | commonDir string | |
| 280 | + | slug string | |
| 281 | + | }{ | |
| 282 | + | {name: "worktree metadata", commonDir: filepath.Join("home", "u", "src", "my-repo", ".git"), slug: "my-repo"}, | |
| 283 | + | {name: "bare repository", commonDir: filepath.Join("srv", "git", "tools.git"), slug: "tools"}, | |
| 284 | + | {name: "unusual characters", commonDir: filepath.Join("home", "u", "My Project!", ".git"), slug: "my-project"}, | |
| 285 | + | {name: "empty slug", commonDir: filepath.Join("home", "@@@", ".git"), slug: "repo"}, | |
| 286 | + | } | |
| 287 | + | for _, test := range tests { | |
| 288 | + | t.Run(test.name, func(t *testing.T) { | |
| 289 | + | id := worktreeRepoID(test.commonDir) | |
| 290 | + | if want := test.slug + "-"; !strings.HasPrefix(id, want) { | |
| 291 | + | t.Fatalf("repo ID = %q, want prefix %q", id, want) | |
| 292 | + | } | |
| 293 | + | if got := len(id) - len(test.slug) - 1; got != worktreeRepoIDHashLength { | |
| 294 | + | t.Fatalf("repo ID hash length = %d, want %d", got, worktreeRepoIDHashLength) | |
| 295 | + | } | |
| 296 | + | if again := worktreeRepoID(test.commonDir); again != id { | |
| 297 | + | t.Fatalf("repo ID is not stable: %q then %q", id, again) | |
| 298 | + | } | |
| 299 | + | }) | |
| 300 | + | } | |
| 301 | + | if a, b := worktreeRepoID(filepath.Join("a", "shared", ".git")), worktreeRepoID(filepath.Join("b", "shared", ".git")); a == b { | |
| 302 | + | t.Fatalf("distinct repositories share repo ID %q", a) | |
| 303 | + | } | |
| 304 | + | if a, b := worktreeRepoID(filepath.Join("a", "Shared", ".git")), worktreeRepoID(filepath.Join("a", "shared", ".git")); a == b { | |
| 305 | + | t.Fatalf("case-different repositories share repo ID %q", a) | |
| 258 | 306 | } | |
| 259 | 307 | } | |
| 260 | 308 | ||
| @@ -263,7 +311,7 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) { | |||
| 263 | 311 | t.Skip("git is required") | |
| 264 | 312 | } | |
| 265 | 313 | repo := initWorktreeTestRepo(t) | |
| 266 | - | tool := newCreateWorktreeTestTool(repo) | |
| 314 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |
| 267 | 315 | for _, branch := range []string{"-option", "topic@{1}"} { | |
| 268 | 316 | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil) | |
| 269 | 317 | if err == nil || !strings.Contains(err.Error(), "invalid branch") { | |
| @@ -272,22 +320,43 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) { | |||
| 272 | 320 | } | |
| 273 | 321 | } | |
| 274 | 322 | ||
| 275 | - | func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) { | |
| 323 | + | func TestCreateWorktreeRejectsNonRepository(t *testing.T) { | |
| 324 | + | dir := t.TempDir() | |
| 325 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 326 | + | tool := newCreateWorktreeTestTool(t, dir, defaultRoot) | |
| 327 | + | ||
| 328 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil) | |
| 329 | + | if err == nil || !strings.Contains(err.Error(), "not a Git repository") { | |
| 330 | + | t.Fatalf("error = %v, want repository error", err) | |
| 331 | + | } | |
| 332 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |
| 333 | + | t.Fatalf("non-repository call created the default root: %v", statErr) | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func TestCreateWorktreeRequiresSandbox(t *testing.T) { | |
| 338 | + | tool := &CreateWorktreeTool{CWD: t.TempDir(), DefaultRoot: filepath.Join(t.TempDir(), "worktrees")} | |
| 339 | + | ||
| 340 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil) | |
| 341 | + | if err == nil || !strings.Contains(err.Error(), "sandbox is required") { | |
| 342 | + | t.Fatalf("error = %v, want sandbox error", err) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func TestCreateWorktreeRejectsExistingBranch(t *testing.T) { | |
| 276 | 347 | if _, err := exec.LookPath("git"); err != nil { | |
| 277 | 348 | t.Skip("git is required") | |
| 278 | 349 | } | |
| 279 | 350 | repo := initWorktreeTestRepo(t) | |
| 280 | - | link := filepath.Join(t.TempDir(), "repository-link") | |
| 281 | - | if err := os.Symlink(repo, link); err != nil { | |
| 282 | - | t.Skipf("symbolic links are unavailable: %v", err) | |
| 351 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |
| 352 | + | args := json.RawMessage(`{"branch":"feature/existing"}`) | |
| 353 | + | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |
| 354 | + | t.Fatal(err) | |
| 283 | 355 | } | |
| 284 | - | root := filepath.Join(link, "external-worktrees") | |
| 285 | - | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root) | |
| 286 | - | tool := newCreateWorktreeTestTool(repo) | |
| 287 | 356 | ||
| 288 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil) | |
| 289 | - | if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") { | |
| 290 | - | t.Fatalf("error = %v, want external-root error", err) | |
| 357 | + | _, err := tool.Execute(context.Background(), args, nil) | |
| 358 | + | if err == nil || !strings.Contains(err.Error(), "already exists") { | |
| 359 | + | t.Fatalf("error = %v, want collision error", err) | |
| 291 | 360 | } | |
| 292 | 361 | } | |
| 293 | 362 | ||
| @@ -299,21 +368,19 @@ func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) { | |||
| 299 | 368 | t.Skip("git is required") | |
| 300 | 369 | } | |
| 301 | 370 | repo := initWorktreeTestRepo(t) | |
| 302 | - | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |
| 303 | - | t.Fatal(err) | |
| 304 | - | } | |
| 305 | 371 | sentinel := filepath.Join(repo, "hook-ran") | |
| 306 | 372 | hook := filepath.Join(repo, ".git", "hooks", "post-checkout") | |
| 307 | 373 | script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n" | |
| 308 | 374 | if err := os.WriteFile(hook, []byte(script), 0o755); err != nil { | |
| 309 | 375 | t.Fatal(err) | |
| 310 | 376 | } | |
| 311 | - | tool := newCreateWorktreeTestTool(repo) | |
| 377 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 378 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 312 | 379 | ||
| 313 | 380 | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil { | |
| 314 | 381 | t.Fatal(err) | |
| 315 | 382 | } | |
| 316 | - | worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook") | |
| 383 | + | worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "no-hook") | |
| 317 | 384 | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 318 | 385 | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 319 | 386 | } | |
| @@ -327,7 +394,7 @@ func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) { | |||
| 327 | 394 | t.Skip("git is required") | |
| 328 | 395 | } | |
| 329 | 396 | repo := initWorktreeTestRepo(t) | |
| 330 | - | worktreePath := filepath.Join(repo, ".worktrees", "partial") | |
| 397 | + | worktreePath := filepath.Join(t.TempDir(), "worktrees", "partial") | |
| 331 | 398 | if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { | |
| 332 | 399 | t.Fatal(err) | |
| 333 | 400 | } | |
| @@ -357,220 +424,264 @@ func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *test | |||
| 357 | 424 | } | |
| 358 | 425 | repo := initWorktreeTestRepo(t) | |
| 359 | 426 | branch := "feature/checkout-failure" | |
| 427 | + | root := filepath.Join(t.TempDir(), "worktrees", "repo-id") | |
| 360 | 428 | plan := createWorktreePlan{ | |
| 361 | 429 | repoRoot: repo, | |
| 362 | 430 | branch: branch, | |
| 363 | 431 | base: "not-a-commit", | |
| 364 | - | worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"), | |
| 365 | - | configValue: defaultWorktreeRoot, | |
| 366 | - | setConfig: true, | |
| 432 | + | worktreeRoot: root, | |
| 433 | + | worktreePath: filepath.Join(root, "feature", "checkout-failure"), | |
| 434 | + | rootSource: "global default root", | |
| 367 | 435 | } | |
| 368 | 436 | ||
| 369 | 437 | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | |
| 370 | 438 | if err == nil || !strings.Contains(err.Error(), "create worktree") { | |
| 371 | 439 | t.Fatalf("error = %v, want worktree creation error", err) | |
| 372 | 440 | } | |
| 373 | - | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) { | |
| 441 | + | if _, err := os.Stat(root); !os.IsNotExist(err) { | |
| 374 | 442 | t.Fatalf("failed checkout left worktree directories: %v", err) | |
| 375 | 443 | } | |
| 376 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 377 | - | t.Fatalf("failed checkout kept local config: %#v", got) | |
| 378 | - | } | |
| 379 | 444 | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 { | |
| 380 | 445 | t.Fatalf("failed checkout created branch: %#v", got) | |
| 381 | 446 | } | |
| 382 | 447 | } | |
| 383 | 448 | ||
| 384 | - | func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) { | |
| 449 | + | func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) { | |
| 385 | 450 | if _, err := exec.LookPath("git"); err != nil { | |
| 386 | 451 | t.Skip("git is required") | |
| 387 | 452 | } | |
| 388 | 453 | repo := initWorktreeTestRepo(t) | |
| 389 | - | ignorePath := filepath.Join(repo, ".gitignore") | |
| 390 | - | before := []byte("generated/\n") | |
| 391 | - | if err := os.WriteFile(ignorePath, before, 0o644); err != nil { | |
| 454 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 455 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 456 | + | args := json.RawMessage(`{"branch":"feature/preview"}`) | |
| 457 | + | ||
| 458 | + | preview, err := tool.Preview(context.Background(), args) | |
| 459 | + | if err != nil { | |
| 392 | 460 | t.Fatal(err) | |
| 393 | 461 | } | |
| 394 | - | if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil { | |
| 395 | - | t.Fatal(err) | |
| 462 | + | text := preview.Content[0].(provider.TextBlock).Text | |
| 463 | + | for _, want := range []string{"create worktree", "feature/preview", "global default root", testWorktreeRepoID(t, repo)} { | |
| 464 | + | if !strings.Contains(text, want) { | |
| 465 | + | t.Fatalf("preview missing %q:\n%s", want, text) | |
| 466 | + | } | |
| 396 | 467 | } | |
| 397 | - | base := gitTestOutput(t, repo, "rev-parse", "HEAD") | |
| 398 | - | plan := createWorktreePlan{ | |
| 399 | - | repoRoot: repo, | |
| 400 | - | branch: "feature/ignore-failure", | |
| 401 | - | base: base, | |
| 402 | - | worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"), | |
| 403 | - | configValue: defaultWorktreeRoot, | |
| 404 | - | setConfig: true, | |
| 405 | - | ignore: worktreesIgnoreUpdate{ | |
| 406 | - | path: ignorePath, | |
| 407 | - | before: before, | |
| 408 | - | after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...), | |
| 409 | - | existed: true, | |
| 410 | - | mode: 0o644, | |
| 411 | - | changed: true, | |
| 412 | - | }, | |
| 468 | + | if state, _ := preview.Details.(map[string]any)["state"].(string); state != "ready" { | |
| 469 | + | t.Fatalf("preview state = %q, want ready", state) | |
| 413 | 470 | } | |
| 414 | - | ||
| 415 | - | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | |
| 416 | - | if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") { | |
| 417 | - | t.Fatalf("error = %v, want stale .gitignore error", err) | |
| 471 | + | if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) { | |
| 472 | + | t.Fatalf("preview created the default root: %v", err) | |
| 418 | 473 | } | |
| 419 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 420 | - | t.Fatalf("failed bootstrap kept local config: %#v", got) | |
| 474 | + | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 475 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 476 | + | t.Fatalf("preview created repository file %s: %v", path, statErr) | |
| 477 | + | } | |
| 478 | + | } | |
| 479 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview") | |
| 480 | + | if err := cmd.Run(); err == nil { | |
| 481 | + | t.Fatal("preview created the branch") | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func TestCreateWorktreePreviewThenExecuteCreatesWorktree(t *testing.T) { | |
| 486 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 487 | + | t.Skip("git is required") | |
| 421 | 488 | } | |
| 422 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 { | |
| 423 | - | t.Fatalf("failed bootstrap created branch: %#v", got) | |
| 489 | + | repo := initWorktreeTestRepo(t) | |
| 490 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 491 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 492 | + | args := json.RawMessage(`{"branch":"feature/previewed"}`) | |
| 493 | + | ||
| 494 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |
| 495 | + | t.Fatal(err) | |
| 424 | 496 | } | |
| 425 | - | contents, err := os.ReadFile(ignorePath) | |
| 497 | + | result, err := tool.Execute(context.Background(), args, nil) | |
| 426 | 498 | if err != nil { | |
| 427 | 499 | t.Fatal(err) | |
| 428 | 500 | } | |
| 429 | - | if got, want := string(contents), "changed by another process\n"; got != want { | |
| 430 | - | t.Fatalf(".gitignore = %q, want %q", got, want) | |
| 501 | + | worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "previewed") | |
| 502 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |
| 503 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |
| 504 | + | } | |
| 505 | + | if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" { | |
| 506 | + | t.Fatalf("state = %q, want created", state) | |
| 431 | 507 | } | |
| 432 | 508 | } | |
| 433 | 509 | ||
| 434 | - | func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) { | |
| 510 | + | func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) { | |
| 435 | 511 | if _, err := exec.LookPath("git"); err != nil { | |
| 436 | 512 | t.Skip("git is required") | |
| 437 | 513 | } | |
| 438 | - | for _, test := range []struct { | |
| 439 | - | name string | |
| 440 | - | entry string | |
| 441 | - | }{ | |
| 442 | - | {name: "root-anchored", entry: worktreesIgnoreEntry}, | |
| 443 | - | {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry}, | |
| 444 | - | } { | |
| 445 | - | t.Run(test.name, func(t *testing.T) { | |
| 446 | - | repo := initWorktreeTestRepo(t) | |
| 447 | - | ignorePath := filepath.Join(repo, ".gitignore") | |
| 448 | - | before := []byte(test.entry + "\n") | |
| 449 | - | if err := os.WriteFile(ignorePath, before, 0o644); err != nil { | |
| 450 | - | t.Fatal(err) | |
| 451 | - | } | |
| 452 | - | tool := newCreateWorktreeTestTool(repo) | |
| 453 | - | args := mustJSON(t, map[string]string{ | |
| 454 | - | "branch": "feature/" + test.name + "-ignore", | |
| 455 | - | "bootstrap_root": defaultWorktreeRoot, | |
| 456 | - | }) | |
| 457 | - | ||
| 458 | - | result, err := tool.Execute(context.Background(), args, nil) | |
| 459 | - | if err != nil { | |
| 460 | - | t.Fatal(err) | |
| 461 | - | } | |
| 462 | - | text := result.Content[0].(provider.TextBlock).Text | |
| 463 | - | if !strings.Contains(text, ".gitignore: already ignores .worktrees/") { | |
| 464 | - | t.Fatalf("result did not recognize existing ignore entry:\n%s", text) | |
| 465 | - | } | |
| 466 | - | ignore, err := os.ReadFile(ignorePath) | |
| 467 | - | if err != nil { | |
| 468 | - | t.Fatal(err) | |
| 469 | - | } | |
| 470 | - | if string(ignore) != string(before) { | |
| 471 | - | t.Fatalf(".gitignore changed from %q to %q", before, ignore) | |
| 472 | - | } | |
| 473 | - | }) | |
| 514 | + | repo := initWorktreeTestRepo(t) | |
| 515 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |
| 516 | + | args := json.RawMessage(`{"branch":"feature/stale-preview"}`) | |
| 517 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |
| 518 | + | t.Fatal(err) | |
| 474 | 519 | } | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) { | |
| 478 | - | for _, entry := range []string{ | |
| 479 | - | "!.worktrees/\n", | |
| 480 | - | "nested/.worktrees/\n", | |
| 481 | - | "#.worktrees/\n", | |
| 482 | - | ".worktrees-other/\n", | |
| 483 | - | } { | |
| 484 | - | t.Run(strings.TrimSpace(entry), func(t *testing.T) { | |
| 485 | - | ignorePath := filepath.Join(t.TempDir(), ".gitignore") | |
| 486 | - | if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil { | |
| 487 | - | t.Fatal(err) | |
| 488 | - | } | |
| 520 | + | if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil { | |
| 521 | + | t.Fatal(err) | |
| 522 | + | } | |
| 523 | + | gitTestOutput(t, repo, "add", "after-preview.txt") | |
| 524 | + | gitTestOutput(t, repo, "commit", "-qm", "change after preview") | |
| 489 | 525 | ||
| 490 | - | update, err := newWorktreesIgnoreUpdate(ignorePath) | |
| 491 | - | if err != nil { | |
| 492 | - | t.Fatal(err) | |
| 493 | - | } | |
| 494 | - | if !update.changed { | |
| 495 | - | t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry)) | |
| 496 | - | } | |
| 497 | - | if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want { | |
| 498 | - | t.Fatalf("updated .gitignore = %q, want %q", got, want) | |
| 499 | - | } | |
| 500 | - | }) | |
| 526 | + | _, err := tool.Execute(context.Background(), args, nil) | |
| 527 | + | if err == nil || !strings.Contains(err.Error(), "state changed since preview") { | |
| 528 | + | t.Fatalf("error = %v, want stale-preview error", err) | |
| 529 | + | } | |
| 530 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 { | |
| 531 | + | t.Fatalf("stale preview created branch: %#v", got) | |
| 501 | 532 | } | |
| 502 | 533 | } | |
| 503 | 534 | ||
| 504 | - | func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) { | |
| 535 | + | func TestCreateWorktreeRespectsJail(t *testing.T) { | |
| 505 | 536 | if _, err := exec.LookPath("git"); err != nil { | |
| 506 | 537 | t.Skip("git is required") | |
| 507 | 538 | } | |
| 508 | 539 | repo := initWorktreeTestRepo(t) | |
| 509 | - | ignorePath := filepath.Join(repo, ".gitignore") | |
| 510 | - | if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil { | |
| 540 | + | nested := filepath.Join(repo, "nested") | |
| 541 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |
| 511 | 542 | t.Fatal(err) | |
| 512 | 543 | } | |
| 513 | - | tool := newCreateWorktreeTestTool(repo) | |
| 544 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 545 | + | sandbox := NewSandbox(nested) | |
| 546 | + | sandbox.Lock() | |
| 547 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: defaultRoot} | |
| 514 | 548 | ||
| 515 | - | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil { | |
| 549 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil) | |
| 550 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |
| 551 | + | t.Fatalf("error = %v, want jailed error", err) | |
| 552 | + | } | |
| 553 | + | for _, path := range []string{filepath.Join(repo, ".worktrees"), filepath.Join(repo, ".gitignore")} { | |
| 554 | + | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { | |
| 555 | + | t.Fatalf("jailed call created %s: %v", path, statErr) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |
| 559 | + | t.Fatalf("jailed call created the default root: %v", statErr) | |
| 560 | + | } | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) { | |
| 564 | + | if runtime.GOOS == "windows" { | |
| 565 | + | t.Skip("Git invocation fixture uses a POSIX shell") | |
| 566 | + | } | |
| 567 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 568 | + | t.Skip("git is required") | |
| 569 | + | } | |
| 570 | + | repo := initWorktreeTestRepo(t) | |
| 571 | + | nested := filepath.Join(repo, "nested") | |
| 572 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |
| 516 | 573 | t.Fatal(err) | |
| 517 | 574 | } | |
| 518 | - | ignore, err := os.ReadFile(ignorePath) | |
| 519 | - | if err != nil { | |
| 575 | + | sentinel := filepath.Join(repo, "git-was-invoked") | |
| 576 | + | binDir := t.TempDir() | |
| 577 | + | fakeGit := filepath.Join(binDir, "git") | |
| 578 | + | if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil { | |
| 520 | 579 | t.Fatal(err) | |
| 521 | 580 | } | |
| 522 | - | if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want { | |
| 523 | - | t.Fatalf(".gitignore = %q, want %q", got, want) | |
| 581 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |
| 582 | + | sandbox := NewSandbox(nested) | |
| 583 | + | sandbox.Lock() | |
| 584 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: filepath.Join(t.TempDir(), "worktrees")} | |
| 585 | + | ||
| 586 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil) | |
| 587 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |
| 588 | + | t.Fatalf("error = %v, want jailed error", err) | |
| 589 | + | } | |
| 590 | + | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |
| 591 | + | t.Fatalf("Git ran before jailed ancestor was rejected: %v", err) | |
| 524 | 592 | } | |
| 525 | 593 | } | |
| 526 | 594 | ||
| 527 | - | func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) { | |
| 595 | + | func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) { | |
| 528 | 596 | if _, err := exec.LookPath("git"); err != nil { | |
| 529 | 597 | t.Skip("git is required") | |
| 530 | 598 | } | |
| 531 | 599 | repo := initWorktreeTestRepo(t) | |
| 532 | - | target := filepath.Join(repo, "ignore-target") | |
| 533 | - | if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil { | |
| 534 | - | t.Fatal(err) | |
| 600 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 601 | + | sandbox := NewSandbox(repo) | |
| 602 | + | permissions := &PermissionSet{} | |
| 603 | + | permissions.Bash.Mode = "allowlist" | |
| 604 | + | permissions.Bash.Allow = []string{"git"} | |
| 605 | + | sandbox.SetPermissions(permissions) | |
| 606 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot} | |
| 607 | + | ||
| 608 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil) | |
| 609 | + | if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") { | |
| 610 | + | t.Fatalf("error = %v, want filesystem permission error", err) | |
| 535 | 611 | } | |
| 536 | - | ignorePath := filepath.Join(repo, ".gitignore") | |
| 537 | - | if err := os.Symlink(target, ignorePath); err != nil { | |
| 538 | - | t.Skipf("symbolic links are unavailable: %v", err) | |
| 612 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |
| 613 | + | t.Fatalf("restricted call created the default root: %v", statErr) | |
| 539 | 614 | } | |
| 540 | - | tool := newCreateWorktreeTestTool(repo) | |
| 615 | + | } | |
| 541 | 616 | ||
| 542 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil) | |
| 543 | - | if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") { | |
| 544 | - | t.Fatalf("error = %v, want symbolic-link error", err) | |
| 617 | + | func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) { | |
| 618 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 619 | + | t.Skip("git is required") | |
| 545 | 620 | } | |
| 546 | - | info, err := os.Lstat(ignorePath) | |
| 547 | - | if err != nil { | |
| 548 | - | t.Fatal(err) | |
| 621 | + | repo := initWorktreeTestRepo(t) | |
| 622 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 623 | + | sandbox := NewSandbox(repo) | |
| 624 | + | permissions := &PermissionSet{} | |
| 625 | + | permissions.FS.Read = []string{repo} | |
| 626 | + | permissions.FS.Write = []string{repo} | |
| 627 | + | sandbox.SetPermissions(permissions) | |
| 628 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot} | |
| 629 | + | ||
| 630 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil) | |
| 631 | + | if err == nil || !strings.Contains(err.Error(), "no bash permission") { | |
| 632 | + | t.Fatalf("error = %v, want git permission error", err) | |
| 549 | 633 | } | |
| 550 | - | if info.Mode()&os.ModeSymlink == 0 { | |
| 551 | - | t.Fatal("tool replaced .gitignore symbolic link") | |
| 634 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |
| 635 | + | t.Fatalf("restricted call created the default root: %v", statErr) | |
| 552 | 636 | } | |
| 553 | - | if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) { | |
| 554 | - | t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr) | |
| 637 | + | } | |
| 638 | + | ||
| 639 | + | func TestCreateWorktreeWritesOutsideDeclaredScopesAreDenied(t *testing.T) { | |
| 640 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 641 | + | t.Skip("git is required") | |
| 642 | + | } | |
| 643 | + | repo := initWorktreeTestRepo(t) | |
| 644 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 645 | + | sandbox := NewSandbox(repo) | |
| 646 | + | permissions := &PermissionSet{} | |
| 647 | + | permissions.FS.Read = []string{repo} | |
| 648 | + | permissions.FS.Write = []string{repo} | |
| 649 | + | permissions.Bash.Mode = "allowlist" | |
| 650 | + | permissions.Bash.Allow = []string{"git"} | |
| 651 | + | sandbox.SetPermissions(permissions) | |
| 652 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot} | |
| 653 | + | ||
| 654 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/out-of-scope"}`), nil) | |
| 655 | + | if err == nil || !strings.Contains(err.Error(), "outside declared scopes") { | |
| 656 | + | t.Fatalf("error = %v, want scope-denied error", err) | |
| 657 | + | } | |
| 658 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |
| 659 | + | t.Fatalf("denied call created the default root: %v", statErr) | |
| 555 | 660 | } | |
| 556 | 661 | } | |
| 557 | 662 | ||
| 558 | - | func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) { | |
| 663 | + | func TestCreateWorktreeRejectsInvalidWorktreePathBeforeCheckout(t *testing.T) { | |
| 559 | 664 | if _, err := exec.LookPath("git"); err != nil { | |
| 560 | 665 | t.Skip("git is required") | |
| 561 | 666 | } | |
| 562 | 667 | repo := initWorktreeTestRepo(t) | |
| 563 | - | if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil { | |
| 668 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |
| 669 | + | repoRoot := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo)) | |
| 670 | + | if err := os.MkdirAll(repoRoot, 0o755); err != nil { | |
| 671 | + | t.Fatal(err) | |
| 672 | + | } | |
| 673 | + | if err := os.WriteFile(filepath.Join(repoRoot, "blocked"), []byte("not a directory"), 0o644); err != nil { | |
| 564 | 674 | t.Fatal(err) | |
| 565 | 675 | } | |
| 566 | - | tool := newCreateWorktreeTestTool(repo) | |
| 676 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |
| 567 | 677 | ||
| 568 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil) | |
| 569 | - | if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") { | |
| 570 | - | t.Fatalf("error = %v, want regular-file error", err) | |
| 678 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil) | |
| 679 | + | if err == nil || !strings.Contains(err.Error(), "not a directory") { | |
| 680 | + | t.Fatalf("error = %v, want non-directory path error", err) | |
| 571 | 681 | } | |
| 572 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |
| 573 | - | t.Fatalf("non-regular ignore saved config: %#v", got) | |
| 682 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new") | |
| 683 | + | if err := cmd.Run(); err == nil { | |
| 684 | + | t.Fatal("invalid path created the branch") | |
| 574 | 685 | } | |
| 575 | 686 | } | |
| 576 | 687 | ||
| @@ -664,255 +775,34 @@ func TestCreateWorktreeGitCancelsDescendants(t *testing.T) { | |||
| 664 | 775 | } | |
| 665 | 776 | } | |
| 666 | 777 | ||
| 667 | - | func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) { | |
| 668 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 669 | - | t.Skip("git is required") | |
| 670 | - | } | |
| 671 | - | repo := initWorktreeTestRepo(t) | |
| 672 | - | tool := newCreateWorktreeTestTool(repo) | |
| 673 | - | args := json.RawMessage(`{"branch":"feature/preview"}`) | |
| 674 | - | ||
| 675 | - | preview, err := tool.Preview(context.Background(), args) | |
| 676 | - | if err != nil { | |
| 677 | - | t.Fatal(err) | |
| 678 | - | } | |
| 679 | - | text := preview.Content[0].(provider.TextBlock).Text | |
| 680 | - | for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} { | |
| 681 | - | if !strings.Contains(text, want) { | |
| 682 | - | t.Fatalf("preview missing %q:\n%s", want, text) | |
| 683 | - | } | |
| 684 | - | } | |
| 685 | - | if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) { | |
| 686 | - | t.Fatalf("preview created .worktrees: %v", err) | |
| 687 | - | } | |
| 688 | - | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |
| 689 | - | t.Fatalf("preview created .gitignore: %v", err) | |
| 690 | - | } | |
| 691 | - | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview") | |
| 692 | - | if err := cmd.Run(); err == nil { | |
| 693 | - | t.Fatal("preview created the branch") | |
| 694 | - | } | |
| 695 | - | } | |
| 696 | - | ||
| 697 | - | func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) { | |
| 698 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 699 | - | t.Skip("git is required") | |
| 700 | - | } | |
| 701 | - | repo := initWorktreeTestRepo(t) | |
| 702 | - | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |
| 703 | - | t.Fatal(err) | |
| 704 | - | } | |
| 705 | - | tool := newCreateWorktreeTestTool(repo) | |
| 706 | - | args := json.RawMessage(`{"branch":"feature/stale-preview"}`) | |
| 707 | - | if _, err := tool.Preview(context.Background(), args); err != nil { | |
| 708 | - | t.Fatal(err) | |
| 709 | - | } | |
| 710 | - | if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil { | |
| 711 | - | t.Fatal(err) | |
| 712 | - | } | |
| 713 | - | gitTestOutput(t, repo, "add", "after-preview.txt") | |
| 714 | - | gitTestOutput(t, repo, "commit", "-qm", "change after preview") | |
| 715 | - | ||
| 716 | - | _, err := tool.Execute(context.Background(), args, nil) | |
| 717 | - | if err == nil || !strings.Contains(err.Error(), "state changed since preview") { | |
| 718 | - | t.Fatalf("error = %v, want stale-preview error", err) | |
| 719 | - | } | |
| 720 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 { | |
| 721 | - | t.Fatalf("stale preview created branch: %#v", got) | |
| 722 | - | } | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | func TestCreateWorktreeRejectsNonRepository(t *testing.T) { | |
| 726 | - | dir := t.TempDir() | |
| 727 | - | tool := newCreateWorktreeTestTool(dir) | |
| 728 | - | ||
| 729 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil) | |
| 730 | - | if err == nil || !strings.Contains(err.Error(), "not a Git repository") { | |
| 731 | - | t.Fatalf("error = %v, want repository error", err) | |
| 732 | - | } | |
| 733 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 734 | - | if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) { | |
| 735 | - | t.Fatalf("non-repository call created %s: %v", path, statErr) | |
| 736 | - | } | |
| 737 | - | } | |
| 738 | - | } | |
| 739 | - | ||
| 740 | - | func TestCreateWorktreeRequiresSandbox(t *testing.T) { | |
| 741 | - | tool := &CreateWorktreeTool{CWD: t.TempDir()} | |
| 742 | - | ||
| 743 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil) | |
| 744 | - | if err == nil || !strings.Contains(err.Error(), "sandbox is required") { | |
| 745 | - | t.Fatalf("error = %v, want sandbox error", err) | |
| 746 | - | } | |
| 778 | + | func newCreateWorktreeTestTool(t *testing.T, cwd, defaultRoot string) *CreateWorktreeTool { | |
| 779 | + | t.Helper() | |
| 780 | + | return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd), DefaultRoot: defaultRoot} | |
| 747 | 781 | } | |
| 748 | 782 | ||
| 749 | - | func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) { | |
| 750 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 751 | - | t.Skip("git is required") | |
| 752 | - | } | |
| 753 | - | repo := initWorktreeTestRepo(t) | |
| 754 | - | tool := newCreateWorktreeTestTool(repo) | |
| 755 | - | bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`) | |
| 756 | - | if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil { | |
| 757 | - | t.Fatal(err) | |
| 758 | - | } | |
| 759 | - | ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |
| 783 | + | func testWorktreeRepoID(t *testing.T, repo string) string { | |
| 784 | + | t.Helper() | |
| 785 | + | real, err := canonical(repo) | |
| 760 | 786 | if err != nil { | |
| 761 | 787 | t.Fatal(err) | |
| 762 | 788 | } | |
| 763 | - | ||
| 764 | - | _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil) | |
| 765 | - | if err == nil || !strings.Contains(err.Error(), "already exists") { | |
| 766 | - | t.Fatalf("error = %v, want collision error", err) | |
| 767 | - | } | |
| 768 | - | ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |
| 789 | + | commonDir, err := canonical(filepath.Join(real, ".git")) | |
| 769 | 790 | if err != nil { | |
| 770 | 791 | t.Fatal(err) | |
| 771 | 792 | } | |
| 772 | - | if string(ignoreAfter) != string(ignoreBefore) { | |
| 773 | - | t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter) | |
| 774 | - | } | |
| 775 | - | } | |
| 776 | - | ||
| 777 | - | func TestCreateWorktreeRespectsJail(t *testing.T) { | |
| 778 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 779 | - | t.Skip("git is required") | |
| 780 | - | } | |
| 781 | - | repo := initWorktreeTestRepo(t) | |
| 782 | - | nested := filepath.Join(repo, "nested") | |
| 783 | - | if err := os.Mkdir(nested, 0o755); err != nil { | |
| 784 | - | t.Fatal(err) | |
| 785 | - | } | |
| 786 | - | sandbox := NewSandbox(nested) | |
| 787 | - | sandbox.Lock() | |
| 788 | - | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |
| 789 | - | ||
| 790 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil) | |
| 791 | - | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |
| 792 | - | t.Fatalf("error = %v, want jailed error", err) | |
| 793 | - | } | |
| 794 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 795 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 796 | - | t.Fatalf("jailed call created %s: %v", path, statErr) | |
| 797 | - | } | |
| 798 | - | } | |
| 799 | - | } | |
| 800 | - | ||
| 801 | - | func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) { | |
| 802 | - | if runtime.GOOS == "windows" { | |
| 803 | - | t.Skip("Git invocation fixture uses a POSIX shell") | |
| 804 | - | } | |
| 805 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 806 | - | t.Skip("git is required") | |
| 807 | - | } | |
| 808 | - | repo := initWorktreeTestRepo(t) | |
| 809 | - | nested := filepath.Join(repo, "nested") | |
| 810 | - | if err := os.Mkdir(nested, 0o755); err != nil { | |
| 811 | - | t.Fatal(err) | |
| 812 | - | } | |
| 813 | - | sentinel := filepath.Join(repo, "git-was-invoked") | |
| 814 | - | binDir := t.TempDir() | |
| 815 | - | fakeGit := filepath.Join(binDir, "git") | |
| 816 | - | if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil { | |
| 817 | - | t.Fatal(err) | |
| 818 | - | } | |
| 819 | - | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |
| 820 | - | sandbox := NewSandbox(nested) | |
| 821 | - | sandbox.Lock() | |
| 822 | - | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |
| 823 | - | ||
| 824 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil) | |
| 825 | - | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |
| 826 | - | t.Fatalf("error = %v, want jailed error", err) | |
| 827 | - | } | |
| 828 | - | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |
| 829 | - | t.Fatalf("Git ran before jailed ancestor was rejected: %v", err) | |
| 830 | - | } | |
| 831 | - | } | |
| 832 | - | ||
| 833 | - | func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) { | |
| 834 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 835 | - | t.Skip("git is required") | |
| 836 | - | } | |
| 837 | - | repo := initWorktreeTestRepo(t) | |
| 838 | - | sandbox := NewSandbox(repo) | |
| 839 | - | permissions := &PermissionSet{} | |
| 840 | - | permissions.Bash.Mode = "allowlist" | |
| 841 | - | permissions.Bash.Allow = []string{"git"} | |
| 842 | - | sandbox.SetPermissions(permissions) | |
| 843 | - | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |
| 844 | - | ||
| 845 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil) | |
| 846 | - | if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") { | |
| 847 | - | t.Fatalf("error = %v, want filesystem permission error", err) | |
| 848 | - | } | |
| 849 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 850 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 851 | - | t.Fatalf("restricted call created %s: %v", path, statErr) | |
| 852 | - | } | |
| 853 | - | } | |
| 854 | - | } | |
| 855 | - | ||
| 856 | - | func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) { | |
| 857 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 858 | - | t.Skip("git is required") | |
| 859 | - | } | |
| 860 | - | repo := initWorktreeTestRepo(t) | |
| 861 | - | sandbox := NewSandbox(repo) | |
| 862 | - | permissions := &PermissionSet{} | |
| 863 | - | permissions.FS.Read = []string{repo} | |
| 864 | - | permissions.FS.Write = []string{repo} | |
| 865 | - | sandbox.SetPermissions(permissions) | |
| 866 | - | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |
| 867 | - | ||
| 868 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil) | |
| 869 | - | if err == nil || !strings.Contains(err.Error(), "no bash permission") { | |
| 870 | - | t.Fatalf("error = %v, want git permission error", err) | |
| 871 | - | } | |
| 872 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |
| 873 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |
| 874 | - | t.Fatalf("restricted call created %s: %v", path, statErr) | |
| 875 | - | } | |
| 876 | - | } | |
| 877 | - | } | |
| 878 | - | ||
| 879 | - | func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) { | |
| 880 | - | if _, err := exec.LookPath("git"); err != nil { | |
| 881 | - | t.Skip("git is required") | |
| 882 | - | } | |
| 883 | - | repo := initWorktreeTestRepo(t) | |
| 884 | - | worktrees := filepath.Join(repo, ".worktrees") | |
| 885 | - | if err := os.Mkdir(worktrees, 0o755); err != nil { | |
| 886 | - | t.Fatal(err) | |
| 887 | - | } | |
| 888 | - | if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil { | |
| 889 | - | t.Fatal(err) | |
| 890 | - | } | |
| 891 | - | tool := newCreateWorktreeTestTool(repo) | |
| 892 | - | ||
| 893 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil) | |
| 894 | - | if err == nil || !strings.Contains(err.Error(), "not a directory") { | |
| 895 | - | t.Fatalf("error = %v, want non-directory path error", err) | |
| 896 | - | } | |
| 897 | - | if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) { | |
| 898 | - | t.Fatalf("invalid path left .gitignore behind: %v", statErr) | |
| 899 | - | } | |
| 900 | - | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new") | |
| 901 | - | if err := cmd.Run(); err == nil { | |
| 902 | - | t.Fatal("invalid path created the branch") | |
| 903 | - | } | |
| 793 | + | return worktreeRepoID(commonDir) | |
| 904 | 794 | } | |
| 905 | 795 | ||
| 906 | - | func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool { | |
| 907 | - | return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)} | |
| 796 | + | func initWorktreeTestRepo(t *testing.T) string { | |
| 797 | + | t.Helper() | |
| 798 | + | return initWorktreeTestRepoAt(t, t.TempDir()) | |
| 908 | 799 | } | |
| 909 | 800 | ||
| 910 | - | func initWorktreeTestRepo(t *testing.T) string { | |
| 801 | + | func initWorktreeTestRepoAt(t *testing.T, repo string) string { | |
| 911 | 802 | t.Helper() | |
| 912 | 803 | t.Setenv("GIT_CONFIG_NOSYSTEM", "1") | |
| 913 | 804 | t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) | |
| 914 | 805 | t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) | |
| 915 | - | repo := t.TempDir() | |
| 916 | 806 | gitTestOutput(t, repo, "init", "-q") | |
| 917 | 807 | gitTestOutput(t, repo, "config", "user.email", "test@example.invalid") | |
| 918 | 808 | gitTestOutput(t, repo, "config", "user.name", "Test User") | |
| @@ -15,179 +15,158 @@ import ( | |||||
| 15 | "github.com/bnema/zut/packages/provider" | 15 | "github.com/bnema/zut/packages/provider" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | - | func TestCreateWorktreeBootstrapsDefaultAtRepositoryRoot(t *testing.T) { | 18 | + | func TestCreateWorktreeUsesGlobalDefaultRoot(t *testing.T) { |
| 19 | if _, err := exec.LookPath("git"); err != nil { | 19 | if _, err := exec.LookPath("git"); err != nil { | ||
| 20 | t.Skip("git is required") | 20 | t.Skip("git is required") | ||
| 21 | } | 21 | } | ||
| 22 | repo := initWorktreeTestRepo(t) | 22 | repo := initWorktreeTestRepo(t) | ||
| 23 | - | nested := filepath.Join(repo, "nested", "dir") | 23 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 24 | - | if err := os.MkdirAll(nested, 0o755); err != nil { | |||
| 25 | - | t.Fatal(err) | |||
| 26 | - | } | |||
| 27 | - | if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("*.local\n"), 0o644); err != nil { | |||
| 28 | - | t.Fatal(err) | |||
| 29 | - | } | |||
| 30 | branchBefore := gitTestOutput(t, repo, "branch", "--show-current") | 24 | branchBefore := gitTestOutput(t, repo, "branch", "--show-current") | ||
| 31 | - | sandbox := NewSandbox(repo) | 25 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) |
| 32 | - | sandbox.Lock() | |||
| 33 | 26 | ||||
| 34 | - | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | 27 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/default-root"}`), nil) |
| 35 | - | args, err := json.Marshal(map[string]string{"branch": "feature/new-checkout", "bootstrap_root": ".worktrees"}) | |||
| 36 | if err != nil { | 28 | if err != nil { | ||
| 37 | t.Fatal(err) | 29 | t.Fatal(err) | ||
| 38 | } | 30 | } | ||
| 39 | - | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |||
| 40 | - | t.Fatal(err) | |||
| 41 | - | } | |||
| 42 | 31 | ||||
| 43 | - | worktree := filepath.Join(repo, ".worktrees", "feature", "new-checkout") | 32 | + | repoID := testWorktreeRepoID(t, repo) |
| 33 | + | worktree := filepath.Join(defaultRoot, repoID, "feature", "default-root") | |||
| 44 | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | 34 | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | ||
| 45 | t.Fatalf("worktree %q was not created: %v", worktree, err) | 35 | t.Fatalf("worktree %q was not created: %v", worktree, err) | ||
| 46 | } | 36 | } | ||
| 47 | - | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/new-checkout" { | 37 | + | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/default-root" { |
| 48 | - | t.Fatalf("worktree branch = %q, want %q", got, "feature/new-checkout") | 38 | + | t.Fatalf("worktree branch = %q, want %q", got, "feature/default-root") |
| 49 | } | 39 | } | ||
| 50 | if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore { | 40 | if got := gitTestOutput(t, repo, "branch", "--show-current"); got != branchBefore { | ||
| 51 | t.Fatalf("source branch changed from %q to %q", branchBefore, got) | 41 | t.Fatalf("source branch changed from %q to %q", branchBefore, got) | ||
| 52 | } | 42 | } | ||
| 53 | - | ignore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | 43 | + | details := result.Details.(map[string]any) |
| 54 | - | if err != nil { | 44 | + | if state, _ := details["state"].(string); state != "created" { |
| 55 | - | t.Fatal(err) | 45 | + | t.Fatalf("state = %q, want created", state) |
| 56 | } | 46 | } | ||
| 57 | - | if count := strings.Count(string(ignore), "/.worktrees/\n"); count != 1 { | 47 | + | if source, _ := details["root_source"].(string); source != "global default root" { |
| 58 | - | t.Fatalf("/.worktrees/ ignore entries = %d, want 1:\n%s", count, ignore) | 48 | + | t.Fatalf("root source = %q", source) |
| 59 | } | 49 | } | ||
| 60 | - | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != defaultWorktreeRoot { | 50 | + | if id, _ := details["repo_id"].(string); id != repoID { |
| 61 | - | t.Fatalf("saved worktree root = %q, want %q", got, defaultWorktreeRoot) | 51 | + | t.Fatalf("repo_id = %q, want %q", id, repoID) |
| 62 | } | 52 | } | ||
| 63 | - | followupArgs := mustJSON(t, map[string]string{ | 53 | + | wantRoot, err := filepath.EvalSymlinks(filepath.Join(defaultRoot, repoID)) |
| 64 | - | "branch": "feature/default-followup", | 54 | + | if err != nil { |
| 65 | - | "bootstrap_root": defaultWorktreeRoot, | |||
| 66 | - | }) | |||
| 67 | - | if _, err := tool.Preview(context.Background(), followupArgs); err != nil { | |||
| 68 | t.Fatal(err) | 55 | t.Fatal(err) | ||
| 69 | } | 56 | } | ||
| 70 | - | if _, err := tool.Execute(context.Background(), followupArgs, nil); err != nil { | 57 | + | if got, _ := details["worktree_root"].(string); got != filepath.ToSlash(wantRoot) { |
| 71 | - | t.Fatal(err) | 58 | + | t.Fatalf("worktree_root = %q, want %q", got, filepath.ToSlash(wantRoot)) |
| 59 | + | } | |||
| 60 | + | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 61 | + | t.Fatalf("default root saved local config: %#v", got) | |||
| 72 | } | 62 | } | ||
| 73 | - | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "default-followup")); err != nil { | 63 | + | for _, path := range []string{".worktrees", ".gitignore"} { |
| 74 | - | t.Fatalf("configured default root was not reused: %v", err) | 64 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { |
| 65 | + | t.Fatalf("default root created repository file %s: %v", path, statErr) | |||
| 66 | + | } | |||
| 75 | } | 67 | } | ||
| 76 | } | 68 | } | ||
| 77 | 69 | ||||
| 78 | - | func TestCreateWorktreeRequiresBootstrapForUnconfiguredRepository(t *testing.T) { | 70 | + | func TestCreateWorktreeDefaultRootIsStableAcrossInvocations(t *testing.T) { |
| 79 | if _, err := exec.LookPath("git"); err != nil { | 71 | if _, err := exec.LookPath("git"); err != nil { | ||
| 80 | t.Skip("git is required") | 72 | t.Skip("git is required") | ||
| 81 | } | 73 | } | ||
| 82 | repo := initWorktreeTestRepo(t) | 74 | repo := initWorktreeTestRepo(t) | ||
| 83 | - | tool := newCreateWorktreeTestTool(repo) | 75 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 84 | - | 76 | + | nested := filepath.Join(repo, "nested", "dir") | |
| 85 | - | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/needs-bootstrap"}`), nil) | 77 | + | if err := os.MkdirAll(nested, 0o755); err != nil { |
| 86 | - | if err != nil { | |||
| 87 | t.Fatal(err) | 78 | t.Fatal(err) | ||
| 88 | } | 79 | } | ||
| 89 | - | text := result.Content[0].(provider.TextBlock).Text | 80 | + | repoID := testWorktreeRepoID(t, repo) |
| 90 | - | if !strings.Contains(text, "Worktree bootstrap is required") { | 81 | + | |
| 91 | - | t.Fatalf("bootstrap result = %q", text) | 82 | + | nestedTool := newCreateWorktreeTestTool(t, nested, defaultRoot) |
| 83 | + | if _, err := nestedTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-nested"}`), nil); err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 92 | } | 85 | } | ||
| 93 | - | if state, _ := result.Details.(map[string]any)["state"].(string); state != "bootstrap_required" { | 86 | + | rootTool := newCreateWorktreeTestTool(t, repo, defaultRoot) |
| 94 | - | t.Fatalf("bootstrap state = %q", state) | 87 | + | if _, err := rootTool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/from-root"}`), nil); err != nil { |
| 88 | + | t.Fatal(err) | |||
| 95 | } | 89 | } | ||
| 96 | - | for _, path := range []string{".worktrees", ".gitignore"} { | 90 | + | |
| 97 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | 91 | + | for _, branch := range []string{"feature/from-nested", "feature/from-root"} { |
| 98 | - | t.Fatalf("bootstrap created %s: %v", path, statErr) | 92 | + | worktree := filepath.Join(defaultRoot, repoID, filepath.FromSlash(branch)) |
| 93 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | |||
| 94 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |||
| 99 | } | 95 | } | ||
| 100 | } | 96 | } | ||
| 101 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 102 | - | t.Fatalf("local worktree config exit = %d, want 1", got.exitCode) | |||
| 103 | - | } | |||
| 104 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/needs-bootstrap"); got.exitCode != 1 { | |||
| 105 | - | t.Fatalf("bootstrap created branch: %#v", got) | |||
| 106 | - | } | |||
| 107 | } | 97 | } | ||
| 108 | 98 | ||||
| 109 | - | func TestCreateWorktreeAcceptsDefaultBootstrapRootWhenDefaultRootExists(t *testing.T) { | 99 | + | func TestCreateWorktreeDefaultRootSlugIsReadable(t *testing.T) { |
| 110 | if _, err := exec.LookPath("git"); err != nil { | 100 | if _, err := exec.LookPath("git"); err != nil { | ||
| 111 | t.Skip("git is required") | 101 | t.Skip("git is required") | ||
| 112 | } | 102 | } | ||
| 113 | - | repo := initWorktreeTestRepo(t) | 103 | + | parent := t.TempDir() |
| 114 | - | if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil { | 104 | + | repo := filepath.Join(parent, "My Project!") |
| 105 | + | if err := os.Mkdir(repo, 0o755); err != nil { | |||
| 115 | t.Fatal(err) | 106 | t.Fatal(err) | ||
| 116 | } | 107 | } | ||
| 117 | - | tool := newCreateWorktreeTestTool(repo) | 108 | + | initWorktreeTestRepoAt(t, repo) |
| 118 | - | args := mustJSON(t, map[string]string{ | 109 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 119 | - | "branch": "feature/existing-default-root", | 110 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) |
| 120 | - | "bootstrap_root": defaultWorktreeRoot, | |||
| 121 | - | }) | |||
| 122 | 111 | ||||
| 123 | - | if _, err := tool.Preview(context.Background(), args); err != nil { | 112 | + | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/slug"}`), nil); err != nil { |
| 124 | t.Fatal(err) | 113 | t.Fatal(err) | ||
| 125 | } | 114 | } | ||
| 126 | - | result, err := tool.Execute(context.Background(), args, nil) | 115 | + | entries, err := os.ReadDir(defaultRoot) |
| 127 | if err != nil { | 116 | if err != nil { | ||
| 128 | t.Fatal(err) | 117 | t.Fatal(err) | ||
| 129 | } | 118 | } | ||
| 130 | - | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot, "feature", "existing-default-root")); err != nil { | 119 | + | if len(entries) != 1 { |
| 131 | - | t.Fatalf("worktree was not created: %v", err) | 120 | + | t.Fatalf("default root entries = %d, want 1", len(entries)) |
| 132 | - | } | |||
| 133 | - | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "existing .worktrees directory" { | |||
| 134 | - | t.Fatalf("root source = %q", source) | |||
| 135 | } | 121 | } | ||
| 136 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | 122 | + | if name := entries[0].Name(); !strings.HasPrefix(name, "my-project-") { |
| 137 | - | t.Fatalf("existing default root saved config: %#v", got) | 123 | + | t.Fatalf("repo directory %q does not carry a readable slug", name) |
| 138 | } | 124 | } | ||
| 139 | } | 125 | } | ||
| 140 | 126 | ||||
| 141 | - | func TestCreateWorktreeRejectsConflictingBootstrapRootWhenDefaultRootExists(t *testing.T) { | 127 | + | func TestCreateWorktreeSeparatesSameNamedRepositories(t *testing.T) { |
| 142 | if _, err := exec.LookPath("git"); err != nil { | 128 | if _, err := exec.LookPath("git"); err != nil { | ||
| 143 | t.Skip("git is required") | 129 | t.Skip("git is required") | ||
| 144 | } | 130 | } | ||
| 145 | - | repo := initWorktreeTestRepo(t) | 131 | + | parent := t.TempDir() |
| 146 | - | if err := os.Mkdir(filepath.Join(repo, defaultWorktreeRoot), 0o755); err != nil { | 132 | + | repoA := filepath.Join(parent, "a", "shared") |
| 147 | - | t.Fatal(err) | 133 | + | repoB := filepath.Join(parent, "b", "shared") |
| 134 | + | for _, repo := range []string{repoA, repoB} { | |||
| 135 | + | if err := os.MkdirAll(repo, 0o755); err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | initWorktreeTestRepoAt(t, repo) | |||
| 148 | } | 139 | } | ||
| 149 | - | tool := newCreateWorktreeTestTool(repo) | 140 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 150 | 141 | ||||
| 151 | - | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{ | 142 | + | toolA := newCreateWorktreeTestTool(t, repoA, defaultRoot) |
| 152 | - | "branch": "feature/conflicting-bootstrap", | 143 | + | if _, err := toolA.Execute(context.Background(), json.RawMessage(`{"branch":"feature/a"}`), nil); err != nil { |
| 153 | - | "bootstrap_root": t.TempDir(), | 144 | + | t.Fatal(err) |
| 154 | - | }), nil) | |||
| 155 | - | if err == nil || !strings.Contains(err.Error(), "already established") { | |||
| 156 | - | t.Fatalf("error = %v, want established-root error", err) | |||
| 157 | - | } | |||
| 158 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 159 | - | t.Fatalf("conflicting bootstrap saved config: %#v", got) | |||
| 160 | } | 145 | } | ||
| 161 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-bootstrap"); got.exitCode != 1 { | 146 | + | toolB := newCreateWorktreeTestTool(t, repoB, defaultRoot) |
| 162 | - | t.Fatalf("conflicting bootstrap created branch: %#v", got) | 147 | + | if _, err := toolB.Execute(context.Background(), json.RawMessage(`{"branch":"feature/b"}`), nil); err != nil { |
| 148 | + | t.Fatal(err) | |||
| 163 | } | 149 | } | ||
| 164 | - | } | |||
| 165 | 150 | ||||
| 166 | - | func TestCreateWorktreeRejectsConflictingBootstrapRootWhenRootIsConfigured(t *testing.T) { | 151 | + | if idA, idB := testWorktreeRepoID(t, repoA), testWorktreeRepoID(t, repoB); idA == idB { |
| 167 | - | if _, err := exec.LookPath("git"); err != nil { | 152 | + | t.Fatalf("same-named repositories share repo ID %q", idA) |
| 168 | - | t.Skip("git is required") | |||
| 169 | } | 153 | } | ||
| 170 | - | repo := initWorktreeTestRepo(t) | 154 | + | entries, err := os.ReadDir(defaultRoot) |
| 171 | - | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, defaultWorktreeRoot) | 155 | + | if err != nil { |
| 172 | - | tool := newCreateWorktreeTestTool(repo) | 156 | + | t.Fatal(err) |
| 173 | - | args := mustJSON(t, map[string]string{ | |||
| 174 | - | "branch": "feature/conflicting-configured-bootstrap", | |||
| 175 | - | "bootstrap_root": t.TempDir(), | |||
| 176 | - | }) | |||
| 177 | - | ||||
| 178 | - | if _, err := tool.Preview(context.Background(), args); err == nil || !strings.Contains(err.Error(), "already configured") { | |||
| 179 | - | t.Fatalf("preview error = %v, want configured-root error", err) | |||
| 180 | } | 157 | } | ||
| 181 | - | _, err := tool.Execute(context.Background(), args, nil) | 158 | + | if len(entries) != 2 { |
| 182 | - | if err == nil || !strings.Contains(err.Error(), "already configured") { | 159 | + | t.Fatalf("default root entries = %d, want 2", len(entries)) |
| 183 | - | t.Fatalf("error = %v, want configured-root error", err) | |||
| 184 | } | 160 | } | ||
| 185 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/conflicting-configured-bootstrap"); got.exitCode != 1 { | 161 | + | for repo, branch := range map[string]string{repoA: "feature/a", repoB: "feature/b"} { |
| 186 | - | t.Fatalf("conflicting bootstrap created branch: %#v", got) | 162 | + | worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), filepath.FromSlash(branch)) |
| 163 | + | if _, err := os.Stat(worktree); err != nil { | |||
| 164 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |||
| 165 | + | } | |||
| 187 | } | 166 | } | ||
| 188 | } | 167 | } | ||
| 189 | 168 | ||||
| 190 | - | func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing.T) { | 169 | + | func TestCreateWorktreeConfiguredRootOverridesDefaultRoot(t *testing.T) { |
| 191 | if _, err := exec.LookPath("git"); err != nil { | 170 | if _, err := exec.LookPath("git"); err != nil { | ||
| 192 | t.Skip("git is required") | 171 | t.Skip("git is required") | ||
| 193 | } | 172 | } | ||
| @@ -196,10 +175,11 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing | |||||
| 196 | if err != nil { | 175 | if err != nil { | ||
| 197 | t.Fatal(err) | 176 | t.Fatal(err) | ||
| 198 | } | 177 | } | ||
| 199 | - | tool := newCreateWorktreeTestTool(repo) | 178 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 200 | - | args := mustJSON(t, map[string]string{"branch": "feature/external", "bootstrap_root": externalRoot}) | 179 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, externalRoot) |
| 180 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |||
| 201 | 181 | ||||
| 202 | - | result, err := tool.Execute(context.Background(), args, nil) | 182 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/external"}`), nil) |
| 203 | if err != nil { | 183 | if err != nil { | ||
| 204 | t.Fatal(err) | 184 | t.Fatal(err) | ||
| 205 | } | 185 | } | ||
| @@ -210,51 +190,119 @@ func TestCreateWorktreeBootstrapsExternalRootWithoutChangingGitignore(t *testing | |||||
| 210 | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" { | 190 | if got := gitTestOutput(t, worktree, "branch", "--show-current"); got != "feature/external" { | ||
| 211 | t.Fatalf("external branch = %q", got) | 191 | t.Fatalf("external branch = %q", got) | ||
| 212 | } | 192 | } | ||
| 213 | - | if got := gitTestOutput(t, repo, "config", "--local", "--get", worktreeConfigKey); got != externalRoot { | 193 | + | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "local Git config" { |
| 214 | - | t.Fatalf("saved external root = %q, want %q", got, externalRoot) | 194 | + | t.Fatalf("root source = %q", source) |
| 215 | } | 195 | } | ||
| 216 | - | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | 196 | + | if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) { |
| 217 | - | t.Fatalf("external bootstrap changed .gitignore: %v", err) | 197 | + | t.Fatalf("configured root still touched the default root: %v", err) |
| 218 | } | 198 | } | ||
| 219 | - | if source, _ := result.Details.(map[string]any)["root_source"].(string); source != "bootstrap selection" { | 199 | + | } |
| 220 | - | t.Fatalf("root source = %q", source) | 200 | + | |
| 201 | + | func TestCreateWorktreeRejectsRelativeConfiguredRoot(t *testing.T) { | |||
| 202 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 203 | + | t.Skip("git is required") | |||
| 221 | } | 204 | } | ||
| 222 | - | if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" { | 205 | + | repo := initWorktreeTestRepo(t) |
| 223 | - | t.Fatalf("state = %q, want created", state) | 206 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, ".worktrees") |
| 207 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |||
| 208 | + | ||||
| 209 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/relative"}`), nil) | |||
| 210 | + | if err == nil || !strings.Contains(err.Error(), "absolute path") { | |||
| 211 | + | t.Fatalf("error = %v, want absolute-path error", err) | |||
| 224 | } | 212 | } | ||
| 225 | - | followupArgs := mustJSON(t, map[string]string{ | 213 | + | if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) { |
| 226 | - | "branch": "feature/external-followup", | 214 | + | t.Fatalf("relative configured root created directory: %v", statErr) |
| 227 | - | "bootstrap_root": externalRoot, | |||
| 228 | - | }) | |||
| 229 | - | if _, err := tool.Preview(context.Background(), followupArgs); err != nil { | |||
| 230 | - | t.Fatal(err) | |||
| 231 | } | 215 | } | ||
| 232 | - | followup, err := tool.Execute(context.Background(), followupArgs, nil) | 216 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/relative"); got.exitCode != 1 { |
| 233 | - | if err != nil { | 217 | + | t.Fatalf("relative configured root created branch: %#v", got) |
| 234 | - | t.Fatal(err) | 218 | + | } |
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestCreateWorktreeRejectsConfiguredRootResolvingInsideRepository(t *testing.T) { | |||
| 222 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 223 | + | t.Skip("git is required") | |||
| 235 | } | 224 | } | ||
| 236 | - | if _, err := os.Stat(filepath.Join(externalRoot, "feature", "external-followup")); err != nil { | 225 | + | repo := initWorktreeTestRepo(t) |
| 237 | - | t.Fatalf("configured external root was not reused: %v", err) | 226 | + | link := filepath.Join(t.TempDir(), "repository-link") |
| 227 | + | if err := os.Symlink(repo, link); err != nil { | |||
| 228 | + | t.Skipf("symbolic links are unavailable: %v", err) | |||
| 238 | } | 229 | } | ||
| 239 | - | if source, _ := followup.Details.(map[string]any)["root_source"].(string); source != "local Git config" { | 230 | + | root := filepath.Join(link, "external-worktrees") |
| 240 | - | t.Fatalf("follow-up root source = %q", source) | 231 | + | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root) |
| 232 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) | |||
| 233 | + | ||||
| 234 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil) | |||
| 235 | + | if err == nil || !strings.Contains(err.Error(), "must be outside the repository") { | |||
| 236 | + | t.Fatalf("error = %v, want outside-repository error", err) | |||
| 241 | } | 237 | } | ||
| 242 | } | 238 | } | ||
| 243 | 239 | ||||
| 244 | - | func TestCreateWorktreeRejectsInvalidBootstrapRoot(t *testing.T) { | 240 | + | func TestCreateWorktreeRequiresDefaultRoot(t *testing.T) { |
| 245 | if _, err := exec.LookPath("git"); err != nil { | 241 | if _, err := exec.LookPath("git"); err != nil { | ||
| 246 | t.Skip("git is required") | 242 | t.Skip("git is required") | ||
| 247 | } | 243 | } | ||
| 248 | repo := initWorktreeTestRepo(t) | 244 | repo := initWorktreeTestRepo(t) | ||
| 249 | - | tool := newCreateWorktreeTestTool(repo) | 245 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo)} |
| 250 | - | for _, root := range []string{"relative-worktrees", filepath.Join(repo, "company-worktrees")} { | 246 | + | |
| 251 | - | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": "feature/invalid", "bootstrap_root": root}), nil) | 247 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-default-root"}`), nil) |
| 252 | - | if err == nil || !strings.Contains(err.Error(), "bootstrap_root") { | 248 | + | if err == nil || !strings.Contains(err.Error(), "default worktree root is not configured") { |
| 253 | - | t.Fatalf("bootstrap_root %q error = %v", root, err) | 249 | + | t.Fatalf("error = %v, want default-root error", err) |
| 254 | - | } | |||
| 255 | } | 250 | } | ||
| 256 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | 251 | + | } |
| 257 | - | t.Fatalf("invalid bootstrap saved config: %#v", got) | 252 | + | |
| 253 | + | func TestCreateWorktreeSchemaDropsBootstrapRoot(t *testing.T) { | |||
| 254 | + | schema := string((&CreateWorktreeTool{}).Schema()) | |||
| 255 | + | if strings.Contains(schema, "bootstrap_root") { | |||
| 256 | + | t.Fatalf("schema still exposes bootstrap_root:\n%s", schema) | |||
| 257 | + | } | |||
| 258 | + | var parsed struct { | |||
| 259 | + | Properties map[string]json.RawMessage `json:"properties"` | |||
| 260 | + | Required []string `json:"required"` | |||
| 261 | + | } | |||
| 262 | + | if err := json.Unmarshal([]byte(schema), &parsed); err != nil { | |||
| 263 | + | t.Fatal(err) | |||
| 264 | + | } | |||
| 265 | + | if _, ok := parsed.Properties["branch"]; !ok { | |||
| 266 | + | t.Fatalf("schema lacks branch: %s", schema) | |||
| 267 | + | } | |||
| 268 | + | if len(parsed.Properties) != 1 { | |||
| 269 | + | t.Fatalf("schema properties = %v, want only branch", parsed.Properties) | |||
| 270 | + | } | |||
| 271 | + | if len(parsed.Required) != 1 || parsed.Required[0] != "branch" { | |||
| 272 | + | t.Fatalf("schema required = %v", parsed.Required) | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func TestWorktreeRepoIDIsReadableStableAndDistinct(t *testing.T) { | |||
| 277 | + | tests := []struct { | |||
| 278 | + | name string | |||
| 279 | + | commonDir string | |||
| 280 | + | slug string | |||
| 281 | + | }{ | |||
| 282 | + | {name: "worktree metadata", commonDir: filepath.Join("home", "u", "src", "my-repo", ".git"), slug: "my-repo"}, | |||
| 283 | + | {name: "bare repository", commonDir: filepath.Join("srv", "git", "tools.git"), slug: "tools"}, | |||
| 284 | + | {name: "unusual characters", commonDir: filepath.Join("home", "u", "My Project!", ".git"), slug: "my-project"}, | |||
| 285 | + | {name: "empty slug", commonDir: filepath.Join("home", "@@@", ".git"), slug: "repo"}, | |||
| 286 | + | } | |||
| 287 | + | for _, test := range tests { | |||
| 288 | + | t.Run(test.name, func(t *testing.T) { | |||
| 289 | + | id := worktreeRepoID(test.commonDir) | |||
| 290 | + | if want := test.slug + "-"; !strings.HasPrefix(id, want) { | |||
| 291 | + | t.Fatalf("repo ID = %q, want prefix %q", id, want) | |||
| 292 | + | } | |||
| 293 | + | if got := len(id) - len(test.slug) - 1; got != worktreeRepoIDHashLength { | |||
| 294 | + | t.Fatalf("repo ID hash length = %d, want %d", got, worktreeRepoIDHashLength) | |||
| 295 | + | } | |||
| 296 | + | if again := worktreeRepoID(test.commonDir); again != id { | |||
| 297 | + | t.Fatalf("repo ID is not stable: %q then %q", id, again) | |||
| 298 | + | } | |||
| 299 | + | }) | |||
| 300 | + | } | |||
| 301 | + | if a, b := worktreeRepoID(filepath.Join("a", "shared", ".git")), worktreeRepoID(filepath.Join("b", "shared", ".git")); a == b { | |||
| 302 | + | t.Fatalf("distinct repositories share repo ID %q", a) | |||
| 303 | + | } | |||
| 304 | + | if a, b := worktreeRepoID(filepath.Join("a", "Shared", ".git")), worktreeRepoID(filepath.Join("a", "shared", ".git")); a == b { | |||
| 305 | + | t.Fatalf("case-different repositories share repo ID %q", a) | |||
| 258 | } | 306 | } | ||
| 259 | } | 307 | } | ||
| 260 | 308 | ||||
| @@ -263,7 +311,7 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) { | |||||
| 263 | t.Skip("git is required") | 311 | t.Skip("git is required") | ||
| 264 | } | 312 | } | ||
| 265 | repo := initWorktreeTestRepo(t) | 313 | repo := initWorktreeTestRepo(t) | ||
| 266 | - | tool := newCreateWorktreeTestTool(repo) | 314 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) |
| 267 | for _, branch := range []string{"-option", "topic@{1}"} { | 315 | for _, branch := range []string{"-option", "topic@{1}"} { | ||
| 268 | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil) | 316 | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]string{"branch": branch}), nil) | ||
| 269 | if err == nil || !strings.Contains(err.Error(), "invalid branch") { | 317 | if err == nil || !strings.Contains(err.Error(), "invalid branch") { | ||
| @@ -272,22 +320,43 @@ func TestCreateWorktreeRejectsBranchSyntaxThatGitCanInterpret(t *testing.T) { | |||||
| 272 | } | 320 | } | ||
| 273 | } | 321 | } | ||
| 274 | 322 | ||||
| 275 | - | func TestCreateWorktreeRejectsConfiguredExternalRootResolvingInsideRepository(t *testing.T) { | 323 | + | func TestCreateWorktreeRejectsNonRepository(t *testing.T) { |
| 324 | + | dir := t.TempDir() | |||
| 325 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |||
| 326 | + | tool := newCreateWorktreeTestTool(t, dir, defaultRoot) | |||
| 327 | + | ||||
| 328 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil) | |||
| 329 | + | if err == nil || !strings.Contains(err.Error(), "not a Git repository") { | |||
| 330 | + | t.Fatalf("error = %v, want repository error", err) | |||
| 331 | + | } | |||
| 332 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |||
| 333 | + | t.Fatalf("non-repository call created the default root: %v", statErr) | |||
| 334 | + | } | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func TestCreateWorktreeRequiresSandbox(t *testing.T) { | |||
| 338 | + | tool := &CreateWorktreeTool{CWD: t.TempDir(), DefaultRoot: filepath.Join(t.TempDir(), "worktrees")} | |||
| 339 | + | ||||
| 340 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil) | |||
| 341 | + | if err == nil || !strings.Contains(err.Error(), "sandbox is required") { | |||
| 342 | + | t.Fatalf("error = %v, want sandbox error", err) | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func TestCreateWorktreeRejectsExistingBranch(t *testing.T) { | |||
| 276 | if _, err := exec.LookPath("git"); err != nil { | 347 | if _, err := exec.LookPath("git"); err != nil { | ||
| 277 | t.Skip("git is required") | 348 | t.Skip("git is required") | ||
| 278 | } | 349 | } | ||
| 279 | repo := initWorktreeTestRepo(t) | 350 | repo := initWorktreeTestRepo(t) | ||
| 280 | - | link := filepath.Join(t.TempDir(), "repository-link") | 351 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) |
| 281 | - | if err := os.Symlink(repo, link); err != nil { | 352 | + | args := json.RawMessage(`{"branch":"feature/existing"}`) |
| 282 | - | t.Skipf("symbolic links are unavailable: %v", err) | 353 | + | if _, err := tool.Execute(context.Background(), args, nil); err != nil { |
| 354 | + | t.Fatal(err) | |||
| 283 | } | 355 | } | ||
| 284 | - | root := filepath.Join(link, "external-worktrees") | |||
| 285 | - | gitTestOutput(t, repo, "config", "--local", worktreeConfigKey, root) | |||
| 286 | - | tool := newCreateWorktreeTestTool(repo) | |||
| 287 | 356 | ||||
| 288 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/inside-repository"}`), nil) | 357 | + | _, err := tool.Execute(context.Background(), args, nil) |
| 289 | - | if err == nil || !strings.Contains(err.Error(), "external worktree root must be outside the repository") { | 358 | + | if err == nil || !strings.Contains(err.Error(), "already exists") { |
| 290 | - | t.Fatalf("error = %v, want external-root error", err) | 359 | + | t.Fatalf("error = %v, want collision error", err) |
| 291 | } | 360 | } | ||
| 292 | } | 361 | } | ||
| 293 | 362 | ||||
| @@ -299,21 +368,19 @@ func TestCreateWorktreeDisablesPostCheckoutHook(t *testing.T) { | |||||
| 299 | t.Skip("git is required") | 368 | t.Skip("git is required") | ||
| 300 | } | 369 | } | ||
| 301 | repo := initWorktreeTestRepo(t) | 370 | repo := initWorktreeTestRepo(t) | ||
| 302 | - | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |||
| 303 | - | t.Fatal(err) | |||
| 304 | - | } | |||
| 305 | sentinel := filepath.Join(repo, "hook-ran") | 371 | sentinel := filepath.Join(repo, "hook-ran") | ||
| 306 | hook := filepath.Join(repo, ".git", "hooks", "post-checkout") | 372 | hook := filepath.Join(repo, ".git", "hooks", "post-checkout") | ||
| 307 | script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n" | 373 | script := "#!/bin/sh\ntouch " + shellQuoteForTest(sentinel) + "\n" | ||
| 308 | if err := os.WriteFile(hook, []byte(script), 0o755); err != nil { | 374 | if err := os.WriteFile(hook, []byte(script), 0o755); err != nil { | ||
| 309 | t.Fatal(err) | 375 | t.Fatal(err) | ||
| 310 | } | 376 | } | ||
| 311 | - | tool := newCreateWorktreeTestTool(repo) | 377 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 378 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |||
| 312 | 379 | ||||
| 313 | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil { | 380 | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-hook"}`), nil); err != nil { | ||
| 314 | t.Fatal(err) | 381 | t.Fatal(err) | ||
| 315 | } | 382 | } | ||
| 316 | - | worktree := filepath.Join(repo, ".worktrees", "feature", "no-hook") | 383 | + | worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "no-hook") |
| 317 | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | 384 | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { | ||
| 318 | t.Fatalf("worktree %q was not created: %v", worktree, err) | 385 | t.Fatalf("worktree %q was not created: %v", worktree, err) | ||
| 319 | } | 386 | } | ||
| @@ -327,7 +394,7 @@ func TestCreateWorktreeCleanupRemovesPartialCheckoutAndBranch(t *testing.T) { | |||||
| 327 | t.Skip("git is required") | 394 | t.Skip("git is required") | ||
| 328 | } | 395 | } | ||
| 329 | repo := initWorktreeTestRepo(t) | 396 | repo := initWorktreeTestRepo(t) | ||
| 330 | - | worktreePath := filepath.Join(repo, ".worktrees", "partial") | 397 | + | worktreePath := filepath.Join(t.TempDir(), "worktrees", "partial") |
| 331 | if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { | 398 | if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { | ||
| 332 | t.Fatal(err) | 399 | t.Fatal(err) | ||
| 333 | } | 400 | } | ||
| @@ -357,220 +424,264 @@ func TestExecuteCreateWorktreePlanRemovesCreatedParentsOnCheckoutFailure(t *test | |||||
| 357 | } | 424 | } | ||
| 358 | repo := initWorktreeTestRepo(t) | 425 | repo := initWorktreeTestRepo(t) | ||
| 359 | branch := "feature/checkout-failure" | 426 | branch := "feature/checkout-failure" | ||
| 427 | + | root := filepath.Join(t.TempDir(), "worktrees", "repo-id") | |||
| 360 | plan := createWorktreePlan{ | 428 | plan := createWorktreePlan{ | ||
| 361 | repoRoot: repo, | 429 | repoRoot: repo, | ||
| 362 | branch: branch, | 430 | branch: branch, | ||
| 363 | base: "not-a-commit", | 431 | base: "not-a-commit", | ||
| 364 | - | worktreePath: filepath.Join(repo, defaultWorktreeRoot, "feature", "checkout-failure"), | 432 | + | worktreeRoot: root, |
| 365 | - | configValue: defaultWorktreeRoot, | 433 | + | worktreePath: filepath.Join(root, "feature", "checkout-failure"), |
| 366 | - | setConfig: true, | 434 | + | rootSource: "global default root", |
| 367 | } | 435 | } | ||
| 368 | 436 | ||||
| 369 | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | 437 | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | ||
| 370 | if err == nil || !strings.Contains(err.Error(), "create worktree") { | 438 | if err == nil || !strings.Contains(err.Error(), "create worktree") { | ||
| 371 | t.Fatalf("error = %v, want worktree creation error", err) | 439 | t.Fatalf("error = %v, want worktree creation error", err) | ||
| 372 | } | 440 | } | ||
| 373 | - | if _, err := os.Stat(filepath.Join(repo, defaultWorktreeRoot)); !os.IsNotExist(err) { | 441 | + | if _, err := os.Stat(root); !os.IsNotExist(err) { |
| 374 | t.Fatalf("failed checkout left worktree directories: %v", err) | 442 | t.Fatalf("failed checkout left worktree directories: %v", err) | ||
| 375 | } | 443 | } | ||
| 376 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | |||
| 377 | - | t.Fatalf("failed checkout kept local config: %#v", got) | |||
| 378 | - | } | |||
| 379 | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 { | 444 | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); got.exitCode != 1 { | ||
| 380 | t.Fatalf("failed checkout created branch: %#v", got) | 445 | t.Fatalf("failed checkout created branch: %#v", got) | ||
| 381 | } | 446 | } | ||
| 382 | } | 447 | } | ||
| 383 | 448 | ||||
| 384 | - | func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *testing.T) { | 449 | + | func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) { |
| 385 | if _, err := exec.LookPath("git"); err != nil { | 450 | if _, err := exec.LookPath("git"); err != nil { | ||
| 386 | t.Skip("git is required") | 451 | t.Skip("git is required") | ||
| 387 | } | 452 | } | ||
| 388 | repo := initWorktreeTestRepo(t) | 453 | repo := initWorktreeTestRepo(t) | ||
| 389 | - | ignorePath := filepath.Join(repo, ".gitignore") | 454 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 390 | - | before := []byte("generated/\n") | 455 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) |
| 391 | - | if err := os.WriteFile(ignorePath, before, 0o644); err != nil { | 456 | + | args := json.RawMessage(`{"branch":"feature/preview"}`) |
| 457 | + | ||||
| 458 | + | preview, err := tool.Preview(context.Background(), args) | |||
| 459 | + | if err != nil { | |||
| 392 | t.Fatal(err) | 460 | t.Fatal(err) | ||
| 393 | } | 461 | } | ||
| 394 | - | if err := os.WriteFile(ignorePath, []byte("changed by another process\n"), 0o644); err != nil { | 462 | + | text := preview.Content[0].(provider.TextBlock).Text |
| 395 | - | t.Fatal(err) | 463 | + | for _, want := range []string{"create worktree", "feature/preview", "global default root", testWorktreeRepoID(t, repo)} { |
| 464 | + | if !strings.Contains(text, want) { | |||
| 465 | + | t.Fatalf("preview missing %q:\n%s", want, text) | |||
| 466 | + | } | |||
| 396 | } | 467 | } | ||
| 397 | - | base := gitTestOutput(t, repo, "rev-parse", "HEAD") | 468 | + | if state, _ := preview.Details.(map[string]any)["state"].(string); state != "ready" { |
| 398 | - | plan := createWorktreePlan{ | 469 | + | t.Fatalf("preview state = %q, want ready", state) |
| 399 | - | repoRoot: repo, | |||
| 400 | - | branch: "feature/ignore-failure", | |||
| 401 | - | base: base, | |||
| 402 | - | worktreePath: filepath.Join(repo, ".worktrees", "feature", "ignore-failure"), | |||
| 403 | - | configValue: defaultWorktreeRoot, | |||
| 404 | - | setConfig: true, | |||
| 405 | - | ignore: worktreesIgnoreUpdate{ | |||
| 406 | - | path: ignorePath, | |||
| 407 | - | before: before, | |||
| 408 | - | after: append(append([]byte(nil), before...), []byte(worktreesIgnoreEntry+"\n")...), | |||
| 409 | - | existed: true, | |||
| 410 | - | mode: 0o644, | |||
| 411 | - | changed: true, | |||
| 412 | - | }, | |||
| 413 | } | 470 | } | ||
| 414 | - | 471 | + | if _, err := os.Stat(defaultRoot); !os.IsNotExist(err) { | |
| 415 | - | _, err := executeCreateWorktreePlan(context.Background(), plan, nil) | 472 | + | t.Fatalf("preview created the default root: %v", err) |
| 416 | - | if err == nil || !strings.Contains(err.Error(), ".gitignore changed since preview") { | |||
| 417 | - | t.Fatalf("error = %v, want stale .gitignore error", err) | |||
| 418 | } | 473 | } | ||
| 419 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | 474 | + | for _, path := range []string{".worktrees", ".gitignore"} { |
| 420 | - | t.Fatalf("failed bootstrap kept local config: %#v", got) | 475 | + | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { |
| 476 | + | t.Fatalf("preview created repository file %s: %v", path, statErr) | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview") | |||
| 480 | + | if err := cmd.Run(); err == nil { | |||
| 481 | + | t.Fatal("preview created the branch") | |||
| 482 | + | } | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func TestCreateWorktreePreviewThenExecuteCreatesWorktree(t *testing.T) { | |||
| 486 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 487 | + | t.Skip("git is required") | |||
| 421 | } | 488 | } | ||
| 422 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/ignore-failure"); got.exitCode != 1 { | 489 | + | repo := initWorktreeTestRepo(t) |
| 423 | - | t.Fatalf("failed bootstrap created branch: %#v", got) | 490 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 491 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) | |||
| 492 | + | args := json.RawMessage(`{"branch":"feature/previewed"}`) | |||
| 493 | + | ||||
| 494 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |||
| 495 | + | t.Fatal(err) | |||
| 424 | } | 496 | } | ||
| 425 | - | contents, err := os.ReadFile(ignorePath) | 497 | + | result, err := tool.Execute(context.Background(), args, nil) |
| 426 | if err != nil { | 498 | if err != nil { | ||
| 427 | t.Fatal(err) | 499 | t.Fatal(err) | ||
| 428 | } | 500 | } | ||
| 429 | - | if got, want := string(contents), "changed by another process\n"; got != want { | 501 | + | worktree := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo), "feature", "previewed") |
| 430 | - | t.Fatalf(".gitignore = %q, want %q", got, want) | 502 | + | if info, err := os.Stat(worktree); err != nil || !info.IsDir() { |
| 503 | + | t.Fatalf("worktree %q was not created: %v", worktree, err) | |||
| 504 | + | } | |||
| 505 | + | if state, _ := result.Details.(map[string]any)["state"].(string); state != "created" { | |||
| 506 | + | t.Fatalf("state = %q, want created", state) | |||
| 431 | } | 507 | } | ||
| 432 | } | 508 | } | ||
| 433 | 509 | ||||
| 434 | - | func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) { | 510 | + | func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) { |
| 435 | if _, err := exec.LookPath("git"); err != nil { | 511 | if _, err := exec.LookPath("git"); err != nil { | ||
| 436 | t.Skip("git is required") | 512 | t.Skip("git is required") | ||
| 437 | } | 513 | } | ||
| 438 | - | for _, test := range []struct { | 514 | + | repo := initWorktreeTestRepo(t) |
| 439 | - | name string | 515 | + | tool := newCreateWorktreeTestTool(t, repo, filepath.Join(t.TempDir(), "worktrees")) |
| 440 | - | entry string | 516 | + | args := json.RawMessage(`{"branch":"feature/stale-preview"}`) |
| 441 | - | }{ | 517 | + | if _, err := tool.Preview(context.Background(), args); err != nil { |
| 442 | - | {name: "root-anchored", entry: worktreesIgnoreEntry}, | 518 | + | t.Fatal(err) |
| 443 | - | {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry}, | |||
| 444 | - | } { | |||
| 445 | - | t.Run(test.name, func(t *testing.T) { | |||
| 446 | - | repo := initWorktreeTestRepo(t) | |||
| 447 | - | ignorePath := filepath.Join(repo, ".gitignore") | |||
| 448 | - | before := []byte(test.entry + "\n") | |||
| 449 | - | if err := os.WriteFile(ignorePath, before, 0o644); err != nil { | |||
| 450 | - | t.Fatal(err) | |||
| 451 | - | } | |||
| 452 | - | tool := newCreateWorktreeTestTool(repo) | |||
| 453 | - | args := mustJSON(t, map[string]string{ | |||
| 454 | - | "branch": "feature/" + test.name + "-ignore", | |||
| 455 | - | "bootstrap_root": defaultWorktreeRoot, | |||
| 456 | - | }) | |||
| 457 | - | ||||
| 458 | - | result, err := tool.Execute(context.Background(), args, nil) | |||
| 459 | - | if err != nil { | |||
| 460 | - | t.Fatal(err) | |||
| 461 | - | } | |||
| 462 | - | text := result.Content[0].(provider.TextBlock).Text | |||
| 463 | - | if !strings.Contains(text, ".gitignore: already ignores .worktrees/") { | |||
| 464 | - | t.Fatalf("result did not recognize existing ignore entry:\n%s", text) | |||
| 465 | - | } | |||
| 466 | - | ignore, err := os.ReadFile(ignorePath) | |||
| 467 | - | if err != nil { | |||
| 468 | - | t.Fatal(err) | |||
| 469 | - | } | |||
| 470 | - | if string(ignore) != string(before) { | |||
| 471 | - | t.Fatalf(".gitignore changed from %q to %q", before, ignore) | |||
| 472 | - | } | |||
| 473 | - | }) | |||
| 474 | } | 519 | } | ||
| 475 | - | } | 520 | + | if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil { |
| 476 | - | 521 | + | t.Fatal(err) | |
| 477 | - | func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) { | 522 | + | } |
| 478 | - | for _, entry := range []string{ | 523 | + | gitTestOutput(t, repo, "add", "after-preview.txt") |
| 479 | - | "!.worktrees/\n", | 524 | + | gitTestOutput(t, repo, "commit", "-qm", "change after preview") |
| 480 | - | "nested/.worktrees/\n", | |||
| 481 | - | "#.worktrees/\n", | |||
| 482 | - | ".worktrees-other/\n", | |||
| 483 | - | } { | |||
| 484 | - | t.Run(strings.TrimSpace(entry), func(t *testing.T) { | |||
| 485 | - | ignorePath := filepath.Join(t.TempDir(), ".gitignore") | |||
| 486 | - | if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil { | |||
| 487 | - | t.Fatal(err) | |||
| 488 | - | } | |||
| 489 | 525 | ||||
| 490 | - | update, err := newWorktreesIgnoreUpdate(ignorePath) | 526 | + | _, err := tool.Execute(context.Background(), args, nil) |
| 491 | - | if err != nil { | 527 | + | if err == nil || !strings.Contains(err.Error(), "state changed since preview") { |
| 492 | - | t.Fatal(err) | 528 | + | t.Fatalf("error = %v, want stale-preview error", err) |
| 493 | - | } | 529 | + | } |
| 494 | - | if !update.changed { | 530 | + | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 { |
| 495 | - | t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry)) | 531 | + | t.Fatalf("stale preview created branch: %#v", got) |
| 496 | - | } | |||
| 497 | - | if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want { | |||
| 498 | - | t.Fatalf("updated .gitignore = %q, want %q", got, want) | |||
| 499 | - | } | |||
| 500 | - | }) | |||
| 501 | } | 532 | } | ||
| 502 | } | 533 | } | ||
| 503 | 534 | ||||
| 504 | - | func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) { | 535 | + | func TestCreateWorktreeRespectsJail(t *testing.T) { |
| 505 | if _, err := exec.LookPath("git"); err != nil { | 536 | if _, err := exec.LookPath("git"); err != nil { | ||
| 506 | t.Skip("git is required") | 537 | t.Skip("git is required") | ||
| 507 | } | 538 | } | ||
| 508 | repo := initWorktreeTestRepo(t) | 539 | repo := initWorktreeTestRepo(t) | ||
| 509 | - | ignorePath := filepath.Join(repo, ".gitignore") | 540 | + | nested := filepath.Join(repo, "nested") |
| 510 | - | if err := os.WriteFile(ignorePath, []byte("generated/\r\n"), 0o644); err != nil { | 541 | + | if err := os.Mkdir(nested, 0o755); err != nil { |
| 511 | t.Fatal(err) | 542 | t.Fatal(err) | ||
| 512 | } | 543 | } | ||
| 513 | - | tool := newCreateWorktreeTestTool(repo) | 544 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 545 | + | sandbox := NewSandbox(nested) | |||
| 546 | + | sandbox.Lock() | |||
| 547 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: defaultRoot} | |||
| 514 | 548 | ||||
| 515 | - | if _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/crlf","bootstrap_root":".worktrees"}`), nil); err != nil { | 549 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil) |
| 550 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |||
| 551 | + | t.Fatalf("error = %v, want jailed error", err) | |||
| 552 | + | } | |||
| 553 | + | for _, path := range []string{filepath.Join(repo, ".worktrees"), filepath.Join(repo, ".gitignore")} { | |||
| 554 | + | if _, statErr := os.Stat(path); !os.IsNotExist(statErr) { | |||
| 555 | + | t.Fatalf("jailed call created %s: %v", path, statErr) | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |||
| 559 | + | t.Fatalf("jailed call created the default root: %v", statErr) | |||
| 560 | + | } | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) { | |||
| 564 | + | if runtime.GOOS == "windows" { | |||
| 565 | + | t.Skip("Git invocation fixture uses a POSIX shell") | |||
| 566 | + | } | |||
| 567 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 568 | + | t.Skip("git is required") | |||
| 569 | + | } | |||
| 570 | + | repo := initWorktreeTestRepo(t) | |||
| 571 | + | nested := filepath.Join(repo, "nested") | |||
| 572 | + | if err := os.Mkdir(nested, 0o755); err != nil { | |||
| 516 | t.Fatal(err) | 573 | t.Fatal(err) | ||
| 517 | } | 574 | } | ||
| 518 | - | ignore, err := os.ReadFile(ignorePath) | 575 | + | sentinel := filepath.Join(repo, "git-was-invoked") |
| 519 | - | if err != nil { | 576 | + | binDir := t.TempDir() |
| 577 | + | fakeGit := filepath.Join(binDir, "git") | |||
| 578 | + | if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil { | |||
| 520 | t.Fatal(err) | 579 | t.Fatal(err) | ||
| 521 | } | 580 | } | ||
| 522 | - | if got, want := string(ignore), "generated/\r\n/.worktrees/\r\n"; got != want { | 581 | + | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) |
| 523 | - | t.Fatalf(".gitignore = %q, want %q", got, want) | 582 | + | sandbox := NewSandbox(nested) |
| 583 | + | sandbox.Lock() | |||
| 584 | + | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox, DefaultRoot: filepath.Join(t.TempDir(), "worktrees")} | |||
| 585 | + | ||||
| 586 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil) | |||
| 587 | + | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |||
| 588 | + | t.Fatalf("error = %v, want jailed error", err) | |||
| 589 | + | } | |||
| 590 | + | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |||
| 591 | + | t.Fatalf("Git ran before jailed ancestor was rejected: %v", err) | |||
| 524 | } | 592 | } | ||
| 525 | } | 593 | } | ||
| 526 | 594 | ||||
| 527 | - | func TestCreateWorktreeRejectsGitignoreSymbolicLink(t *testing.T) { | 595 | + | func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) { |
| 528 | if _, err := exec.LookPath("git"); err != nil { | 596 | if _, err := exec.LookPath("git"); err != nil { | ||
| 529 | t.Skip("git is required") | 597 | t.Skip("git is required") | ||
| 530 | } | 598 | } | ||
| 531 | repo := initWorktreeTestRepo(t) | 599 | repo := initWorktreeTestRepo(t) | ||
| 532 | - | target := filepath.Join(repo, "ignore-target") | 600 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 533 | - | if err := os.WriteFile(target, []byte("generated/\n"), 0o644); err != nil { | 601 | + | sandbox := NewSandbox(repo) |
| 534 | - | t.Fatal(err) | 602 | + | permissions := &PermissionSet{} |
| 603 | + | permissions.Bash.Mode = "allowlist" | |||
| 604 | + | permissions.Bash.Allow = []string{"git"} | |||
| 605 | + | sandbox.SetPermissions(permissions) | |||
| 606 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot} | |||
| 607 | + | ||||
| 608 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil) | |||
| 609 | + | if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") { | |||
| 610 | + | t.Fatalf("error = %v, want filesystem permission error", err) | |||
| 535 | } | 611 | } | ||
| 536 | - | ignorePath := filepath.Join(repo, ".gitignore") | 612 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { |
| 537 | - | if err := os.Symlink(target, ignorePath); err != nil { | 613 | + | t.Fatalf("restricted call created the default root: %v", statErr) |
| 538 | - | t.Skipf("symbolic links are unavailable: %v", err) | |||
| 539 | } | 614 | } | ||
| 540 | - | tool := newCreateWorktreeTestTool(repo) | 615 | + | } |
| 541 | 616 | ||||
| 542 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/ignore-symlink","bootstrap_root":".worktrees"}`), nil) | 617 | + | func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) { |
| 543 | - | if err == nil || !strings.Contains(err.Error(), ".gitignore must not be a symbolic link") { | 618 | + | if _, err := exec.LookPath("git"); err != nil { |
| 544 | - | t.Fatalf("error = %v, want symbolic-link error", err) | 619 | + | t.Skip("git is required") |
| 545 | } | 620 | } | ||
| 546 | - | info, err := os.Lstat(ignorePath) | 621 | + | repo := initWorktreeTestRepo(t) |
| 547 | - | if err != nil { | 622 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 548 | - | t.Fatal(err) | 623 | + | sandbox := NewSandbox(repo) |
| 624 | + | permissions := &PermissionSet{} | |||
| 625 | + | permissions.FS.Read = []string{repo} | |||
| 626 | + | permissions.FS.Write = []string{repo} | |||
| 627 | + | sandbox.SetPermissions(permissions) | |||
| 628 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot} | |||
| 629 | + | ||||
| 630 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil) | |||
| 631 | + | if err == nil || !strings.Contains(err.Error(), "no bash permission") { | |||
| 632 | + | t.Fatalf("error = %v, want git permission error", err) | |||
| 549 | } | 633 | } | ||
| 550 | - | if info.Mode()&os.ModeSymlink == 0 { | 634 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { |
| 551 | - | t.Fatal("tool replaced .gitignore symbolic link") | 635 | + | t.Fatalf("restricted call created the default root: %v", statErr) |
| 552 | } | 636 | } | ||
| 553 | - | if _, statErr := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(statErr) { | 637 | + | } |
| 554 | - | t.Fatalf("symbolic-link rejection created .worktrees: %v", statErr) | 638 | + | |
| 639 | + | func TestCreateWorktreeWritesOutsideDeclaredScopesAreDenied(t *testing.T) { | |||
| 640 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 641 | + | t.Skip("git is required") | |||
| 642 | + | } | |||
| 643 | + | repo := initWorktreeTestRepo(t) | |||
| 644 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") | |||
| 645 | + | sandbox := NewSandbox(repo) | |||
| 646 | + | permissions := &PermissionSet{} | |||
| 647 | + | permissions.FS.Read = []string{repo} | |||
| 648 | + | permissions.FS.Write = []string{repo} | |||
| 649 | + | permissions.Bash.Mode = "allowlist" | |||
| 650 | + | permissions.Bash.Allow = []string{"git"} | |||
| 651 | + | sandbox.SetPermissions(permissions) | |||
| 652 | + | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox, DefaultRoot: defaultRoot} | |||
| 653 | + | ||||
| 654 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/out-of-scope"}`), nil) | |||
| 655 | + | if err == nil || !strings.Contains(err.Error(), "outside declared scopes") { | |||
| 656 | + | t.Fatalf("error = %v, want scope-denied error", err) | |||
| 657 | + | } | |||
| 658 | + | if _, statErr := os.Stat(defaultRoot); !os.IsNotExist(statErr) { | |||
| 659 | + | t.Fatalf("denied call created the default root: %v", statErr) | |||
| 555 | } | 660 | } | ||
| 556 | } | 661 | } | ||
| 557 | 662 | ||||
| 558 | - | func TestCreateWorktreeRejectsNonRegularGitignore(t *testing.T) { | 663 | + | func TestCreateWorktreeRejectsInvalidWorktreePathBeforeCheckout(t *testing.T) { |
| 559 | if _, err := exec.LookPath("git"); err != nil { | 664 | if _, err := exec.LookPath("git"); err != nil { | ||
| 560 | t.Skip("git is required") | 665 | t.Skip("git is required") | ||
| 561 | } | 666 | } | ||
| 562 | repo := initWorktreeTestRepo(t) | 667 | repo := initWorktreeTestRepo(t) | ||
| 563 | - | if err := os.Mkdir(filepath.Join(repo, ".gitignore"), 0o755); err != nil { | 668 | + | defaultRoot := filepath.Join(t.TempDir(), "worktrees") |
| 669 | + | repoRoot := filepath.Join(defaultRoot, testWorktreeRepoID(t, repo)) | |||
| 670 | + | if err := os.MkdirAll(repoRoot, 0o755); err != nil { | |||
| 671 | + | t.Fatal(err) | |||
| 672 | + | } | |||
| 673 | + | if err := os.WriteFile(filepath.Join(repoRoot, "blocked"), []byte("not a directory"), 0o644); err != nil { | |||
| 564 | t.Fatal(err) | 674 | t.Fatal(err) | ||
| 565 | } | 675 | } | ||
| 566 | - | tool := newCreateWorktreeTestTool(repo) | 676 | + | tool := newCreateWorktreeTestTool(t, repo, defaultRoot) |
| 567 | 677 | ||||
| 568 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/nonregular","bootstrap_root":".worktrees"}`), nil) | 678 | + | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil) |
| 569 | - | if err == nil || !strings.Contains(err.Error(), ".gitignore must be a regular file") { | 679 | + | if err == nil || !strings.Contains(err.Error(), "not a directory") { |
| 570 | - | t.Fatalf("error = %v, want regular-file error", err) | 680 | + | t.Fatalf("error = %v, want non-directory path error", err) |
| 571 | } | 681 | } | ||
| 572 | - | if got := gitTestOutputAllowExit(t, repo, "config", "--local", "--get", worktreeConfigKey); got.exitCode != 1 { | 682 | + | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new") |
| 573 | - | t.Fatalf("non-regular ignore saved config: %#v", got) | 683 | + | if err := cmd.Run(); err == nil { |
| 684 | + | t.Fatal("invalid path created the branch") | |||
| 574 | } | 685 | } | ||
| 575 | } | 686 | } | ||
| 576 | 687 | ||||
| @@ -664,255 +775,34 @@ func TestCreateWorktreeGitCancelsDescendants(t *testing.T) { | |||||
| 664 | } | 775 | } | ||
| 665 | } | 776 | } | ||
| 666 | 777 | ||||
| 667 | - | func TestCreateWorktreePreviewDoesNotModifyRepository(t *testing.T) { | 778 | + | func newCreateWorktreeTestTool(t *testing.T, cwd, defaultRoot string) *CreateWorktreeTool { |
| 668 | - | if _, err := exec.LookPath("git"); err != nil { | 779 | + | t.Helper() |
| 669 | - | t.Skip("git is required") | 780 | + | return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd), DefaultRoot: defaultRoot} |
| 670 | - | } | |||
| 671 | - | repo := initWorktreeTestRepo(t) | |||
| 672 | - | tool := newCreateWorktreeTestTool(repo) | |||
| 673 | - | args := json.RawMessage(`{"branch":"feature/preview"}`) | |||
| 674 | - | ||||
| 675 | - | preview, err := tool.Preview(context.Background(), args) | |||
| 676 | - | if err != nil { | |||
| 677 | - | t.Fatal(err) | |||
| 678 | - | } | |||
| 679 | - | text := preview.Content[0].(provider.TextBlock).Text | |||
| 680 | - | for _, want := range []string{"Worktree bootstrap is required", "feature/preview", "bootstrap_root"} { | |||
| 681 | - | if !strings.Contains(text, want) { | |||
| 682 | - | t.Fatalf("preview missing %q:\n%s", want, text) | |||
| 683 | - | } | |||
| 684 | - | } | |||
| 685 | - | if _, err := os.Stat(filepath.Join(repo, ".worktrees")); !os.IsNotExist(err) { | |||
| 686 | - | t.Fatalf("preview created .worktrees: %v", err) | |||
| 687 | - | } | |||
| 688 | - | if _, err := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(err) { | |||
| 689 | - | t.Fatalf("preview created .gitignore: %v", err) | |||
| 690 | - | } | |||
| 691 | - | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/preview") | |||
| 692 | - | if err := cmd.Run(); err == nil { | |||
| 693 | - | t.Fatal("preview created the branch") | |||
| 694 | - | } | |||
| 695 | - | } | |||
| 696 | - | ||||
| 697 | - | func TestCreateWorktreeRejectsStateChangedAfterPreview(t *testing.T) { | |||
| 698 | - | if _, err := exec.LookPath("git"); err != nil { | |||
| 699 | - | t.Skip("git is required") | |||
| 700 | - | } | |||
| 701 | - | repo := initWorktreeTestRepo(t) | |||
| 702 | - | if err := os.Mkdir(filepath.Join(repo, ".worktrees"), 0o755); err != nil { | |||
| 703 | - | t.Fatal(err) | |||
| 704 | - | } | |||
| 705 | - | tool := newCreateWorktreeTestTool(repo) | |||
| 706 | - | args := json.RawMessage(`{"branch":"feature/stale-preview"}`) | |||
| 707 | - | if _, err := tool.Preview(context.Background(), args); err != nil { | |||
| 708 | - | t.Fatal(err) | |||
| 709 | - | } | |||
| 710 | - | if err := os.WriteFile(filepath.Join(repo, "after-preview.txt"), []byte("changed\n"), 0o644); err != nil { | |||
| 711 | - | t.Fatal(err) | |||
| 712 | - | } | |||
| 713 | - | gitTestOutput(t, repo, "add", "after-preview.txt") | |||
| 714 | - | gitTestOutput(t, repo, "commit", "-qm", "change after preview") | |||
| 715 | - | ||||
| 716 | - | _, err := tool.Execute(context.Background(), args, nil) | |||
| 717 | - | if err == nil || !strings.Contains(err.Error(), "state changed since preview") { | |||
| 718 | - | t.Fatalf("error = %v, want stale-preview error", err) | |||
| 719 | - | } | |||
| 720 | - | if got := gitTestOutputAllowExit(t, repo, "show-ref", "--verify", "--quiet", "refs/heads/feature/stale-preview"); got.exitCode != 1 { | |||
| 721 | - | t.Fatalf("stale preview created branch: %#v", got) | |||
| 722 | - | } | |||
| 723 | - | } | |||
| 724 | - | ||||
| 725 | - | func TestCreateWorktreeRejectsNonRepository(t *testing.T) { | |||
| 726 | - | dir := t.TempDir() | |||
| 727 | - | tool := newCreateWorktreeTestTool(dir) | |||
| 728 | - | ||||
| 729 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/new"}`), nil) | |||
| 730 | - | if err == nil || !strings.Contains(err.Error(), "not a Git repository") { | |||
| 731 | - | t.Fatalf("error = %v, want repository error", err) | |||
| 732 | - | } | |||
| 733 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 734 | - | if _, statErr := os.Stat(filepath.Join(dir, path)); !os.IsNotExist(statErr) { | |||
| 735 | - | t.Fatalf("non-repository call created %s: %v", path, statErr) | |||
| 736 | - | } | |||
| 737 | - | } | |||
| 738 | - | } | |||
| 739 | - | ||||
| 740 | - | func TestCreateWorktreeRequiresSandbox(t *testing.T) { | |||
| 741 | - | tool := &CreateWorktreeTool{CWD: t.TempDir()} | |||
| 742 | - | ||||
| 743 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-sandbox"}`), nil) | |||
| 744 | - | if err == nil || !strings.Contains(err.Error(), "sandbox is required") { | |||
| 745 | - | t.Fatalf("error = %v, want sandbox error", err) | |||
| 746 | - | } | |||
| 747 | } | 781 | } | ||
| 748 | 782 | ||||
| 749 | - | func TestCreateWorktreeRejectsExistingBranchWithoutChangingGitignore(t *testing.T) { | 783 | + | func testWorktreeRepoID(t *testing.T, repo string) string { |
| 750 | - | if _, err := exec.LookPath("git"); err != nil { | 784 | + | t.Helper() |
| 751 | - | t.Skip("git is required") | 785 | + | real, err := canonical(repo) |
| 752 | - | } | |||
| 753 | - | repo := initWorktreeTestRepo(t) | |||
| 754 | - | tool := newCreateWorktreeTestTool(repo) | |||
| 755 | - | bootstrapArgs := json.RawMessage(`{"branch":"feature/existing","bootstrap_root":".worktrees"}`) | |||
| 756 | - | if _, err := tool.Execute(context.Background(), bootstrapArgs, nil); err != nil { | |||
| 757 | - | t.Fatal(err) | |||
| 758 | - | } | |||
| 759 | - | ignoreBefore, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |||
| 760 | if err != nil { | 786 | if err != nil { | ||
| 761 | t.Fatal(err) | 787 | t.Fatal(err) | ||
| 762 | } | 788 | } | ||
| 763 | - | 789 | + | commonDir, err := canonical(filepath.Join(real, ".git")) | |
| 764 | - | _, err = tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/existing"}`), nil) | |||
| 765 | - | if err == nil || !strings.Contains(err.Error(), "already exists") { | |||
| 766 | - | t.Fatalf("error = %v, want collision error", err) | |||
| 767 | - | } | |||
| 768 | - | ignoreAfter, err := os.ReadFile(filepath.Join(repo, ".gitignore")) | |||
| 769 | if err != nil { | 790 | if err != nil { | ||
| 770 | t.Fatal(err) | 791 | t.Fatal(err) | ||
| 771 | } | 792 | } | ||
| 772 | - | if string(ignoreAfter) != string(ignoreBefore) { | 793 | + | return worktreeRepoID(commonDir) |
| 773 | - | t.Fatalf("collision changed .gitignore:\nbefore: %q\nafter: %q", ignoreBefore, ignoreAfter) | |||
| 774 | - | } | |||
| 775 | - | } | |||
| 776 | - | ||||
| 777 | - | func TestCreateWorktreeRespectsJail(t *testing.T) { | |||
| 778 | - | if _, err := exec.LookPath("git"); err != nil { | |||
| 779 | - | t.Skip("git is required") | |||
| 780 | - | } | |||
| 781 | - | repo := initWorktreeTestRepo(t) | |||
| 782 | - | nested := filepath.Join(repo, "nested") | |||
| 783 | - | if err := os.Mkdir(nested, 0o755); err != nil { | |||
| 784 | - | t.Fatal(err) | |||
| 785 | - | } | |||
| 786 | - | sandbox := NewSandbox(nested) | |||
| 787 | - | sandbox.Lock() | |||
| 788 | - | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |||
| 789 | - | ||||
| 790 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed"}`), nil) | |||
| 791 | - | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |||
| 792 | - | t.Fatalf("error = %v, want jailed error", err) | |||
| 793 | - | } | |||
| 794 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 795 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 796 | - | t.Fatalf("jailed call created %s: %v", path, statErr) | |||
| 797 | - | } | |||
| 798 | - | } | |||
| 799 | - | } | |||
| 800 | - | ||||
| 801 | - | func TestCreateWorktreeRejectsJailedAncestorBeforeGitDiscovery(t *testing.T) { | |||
| 802 | - | if runtime.GOOS == "windows" { | |||
| 803 | - | t.Skip("Git invocation fixture uses a POSIX shell") | |||
| 804 | - | } | |||
| 805 | - | if _, err := exec.LookPath("git"); err != nil { | |||
| 806 | - | t.Skip("git is required") | |||
| 807 | - | } | |||
| 808 | - | repo := initWorktreeTestRepo(t) | |||
| 809 | - | nested := filepath.Join(repo, "nested") | |||
| 810 | - | if err := os.Mkdir(nested, 0o755); err != nil { | |||
| 811 | - | t.Fatal(err) | |||
| 812 | - | } | |||
| 813 | - | sentinel := filepath.Join(repo, "git-was-invoked") | |||
| 814 | - | binDir := t.TempDir() | |||
| 815 | - | fakeGit := filepath.Join(binDir, "git") | |||
| 816 | - | if err := os.WriteFile(fakeGit, []byte("#!/bin/sh\ntouch "+shellQuoteForTest(sentinel)+"\nexit 1\n"), 0o755); err != nil { | |||
| 817 | - | t.Fatal(err) | |||
| 818 | - | } | |||
| 819 | - | t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) | |||
| 820 | - | sandbox := NewSandbox(nested) | |||
| 821 | - | sandbox.Lock() | |||
| 822 | - | tool := &CreateWorktreeTool{CWD: nested, Sandbox: sandbox} | |||
| 823 | - | ||||
| 824 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/jailed-discovery"}`), nil) | |||
| 825 | - | if err == nil || !strings.Contains(err.Error(), "jailed:") { | |||
| 826 | - | t.Fatalf("error = %v, want jailed error", err) | |||
| 827 | - | } | |||
| 828 | - | if _, err := os.Stat(sentinel); !os.IsNotExist(err) { | |||
| 829 | - | t.Fatalf("Git ran before jailed ancestor was rejected: %v", err) | |||
| 830 | - | } | |||
| 831 | - | } | |||
| 832 | - | ||||
| 833 | - | func TestCreateWorktreeRequiresFilesystemPermissions(t *testing.T) { | |||
| 834 | - | if _, err := exec.LookPath("git"); err != nil { | |||
| 835 | - | t.Skip("git is required") | |||
| 836 | - | } | |||
| 837 | - | repo := initWorktreeTestRepo(t) | |||
| 838 | - | sandbox := NewSandbox(repo) | |||
| 839 | - | permissions := &PermissionSet{} | |||
| 840 | - | permissions.Bash.Mode = "allowlist" | |||
| 841 | - | permissions.Bash.Allow = []string{"git"} | |||
| 842 | - | sandbox.SetPermissions(permissions) | |||
| 843 | - | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |||
| 844 | - | ||||
| 845 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/restricted"}`), nil) | |||
| 846 | - | if err == nil || !strings.Contains(err.Error(), "no filesystem read permission") { | |||
| 847 | - | t.Fatalf("error = %v, want filesystem permission error", err) | |||
| 848 | - | } | |||
| 849 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 850 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 851 | - | t.Fatalf("restricted call created %s: %v", path, statErr) | |||
| 852 | - | } | |||
| 853 | - | } | |||
| 854 | - | } | |||
| 855 | - | ||||
| 856 | - | func TestCreateWorktreeRequiresGitBashPermission(t *testing.T) { | |||
| 857 | - | if _, err := exec.LookPath("git"); err != nil { | |||
| 858 | - | t.Skip("git is required") | |||
| 859 | - | } | |||
| 860 | - | repo := initWorktreeTestRepo(t) | |||
| 861 | - | sandbox := NewSandbox(repo) | |||
| 862 | - | permissions := &PermissionSet{} | |||
| 863 | - | permissions.FS.Read = []string{repo} | |||
| 864 | - | permissions.FS.Write = []string{repo} | |||
| 865 | - | sandbox.SetPermissions(permissions) | |||
| 866 | - | tool := &CreateWorktreeTool{CWD: repo, Sandbox: sandbox} | |||
| 867 | - | ||||
| 868 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"feature/no-git"}`), nil) | |||
| 869 | - | if err == nil || !strings.Contains(err.Error(), "no bash permission") { | |||
| 870 | - | t.Fatalf("error = %v, want git permission error", err) | |||
| 871 | - | } | |||
| 872 | - | for _, path := range []string{".worktrees", ".gitignore"} { | |||
| 873 | - | if _, statErr := os.Stat(filepath.Join(repo, path)); !os.IsNotExist(statErr) { | |||
| 874 | - | t.Fatalf("restricted call created %s: %v", path, statErr) | |||
| 875 | - | } | |||
| 876 | - | } | |||
| 877 | - | } | |||
| 878 | - | ||||
| 879 | - | func TestCreateWorktreeRejectsInvalidWorktreePathBeforeGitignoreUpdate(t *testing.T) { | |||
| 880 | - | if _, err := exec.LookPath("git"); err != nil { | |||
| 881 | - | t.Skip("git is required") | |||
| 882 | - | } | |||
| 883 | - | repo := initWorktreeTestRepo(t) | |||
| 884 | - | worktrees := filepath.Join(repo, ".worktrees") | |||
| 885 | - | if err := os.Mkdir(worktrees, 0o755); err != nil { | |||
| 886 | - | t.Fatal(err) | |||
| 887 | - | } | |||
| 888 | - | if err := os.WriteFile(filepath.Join(worktrees, "blocked"), []byte("not a directory"), 0o644); err != nil { | |||
| 889 | - | t.Fatal(err) | |||
| 890 | - | } | |||
| 891 | - | tool := newCreateWorktreeTestTool(repo) | |||
| 892 | - | ||||
| 893 | - | _, err := tool.Execute(context.Background(), json.RawMessage(`{"branch":"blocked/new"}`), nil) | |||
| 894 | - | if err == nil || !strings.Contains(err.Error(), "not a directory") { | |||
| 895 | - | t.Fatalf("error = %v, want non-directory path error", err) | |||
| 896 | - | } | |||
| 897 | - | if _, statErr := os.Stat(filepath.Join(repo, ".gitignore")); !os.IsNotExist(statErr) { | |||
| 898 | - | t.Fatalf("invalid path left .gitignore behind: %v", statErr) | |||
| 899 | - | } | |||
| 900 | - | cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/blocked/new") | |||
| 901 | - | if err := cmd.Run(); err == nil { | |||
| 902 | - | t.Fatal("invalid path created the branch") | |||
| 903 | - | } | |||
| 904 | } | 794 | } | ||
| 905 | 795 | ||||
| 906 | - | func newCreateWorktreeTestTool(cwd string) *CreateWorktreeTool { | 796 | + | func initWorktreeTestRepo(t *testing.T) string { |
| 907 | - | return &CreateWorktreeTool{CWD: cwd, Sandbox: NewSandbox(cwd)} | 797 | + | t.Helper() |
| 798 | + | return initWorktreeTestRepoAt(t, t.TempDir()) | |||
| 908 | } | 799 | } | ||
| 909 | 800 | ||||
| 910 | - | func initWorktreeTestRepo(t *testing.T) string { | 801 | + | func initWorktreeTestRepoAt(t *testing.T, repo string) string { |
| 911 | t.Helper() | 802 | t.Helper() | ||
| 912 | t.Setenv("GIT_CONFIG_NOSYSTEM", "1") | 803 | t.Setenv("GIT_CONFIG_NOSYSTEM", "1") | ||
| 913 | t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) | 804 | t.Setenv("GIT_CONFIG_SYSTEM", os.DevNull) | ||
| 914 | t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) | 805 | t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) | ||
| 915 | - | repo := t.TempDir() | |||
| 916 | gitTestOutput(t, repo, "init", "-q") | 806 | gitTestOutput(t, repo, "init", "-q") | ||
| 917 | gitTestOutput(t, repo, "config", "user.email", "test@example.invalid") | 807 | gitTestOutput(t, repo, "config", "user.email", "test@example.invalid") | ||
| 918 | gitTestOutput(t, repo, "config", "user.name", "Test User") | 808 | gitTestOutput(t, repo, "config", "user.name", "Test User") | ||
A packages/agent/tools/manage_worktrees.go +355 -0
| @@ -0,0 +1,355 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/zut/packages/core" | |
| 14 | + | "github.com/bnema/zut/packages/provider" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // ManageWorktreesTool inventories repository worktrees and removes only clean, | |
| 18 | + | // merged, explicitly selected secondary worktrees. | |
| 19 | + | type ManageWorktreesTool struct { | |
| 20 | + | CWD string | |
| 21 | + | Sandbox *Sandbox | |
| 22 | + | ||
| 23 | + | previewMu sync.Mutex | |
| 24 | + | preview *manageWorktreesPreview | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | type manageWorktreesArgs struct { | |
| 28 | + | Action string `json:"action"` | |
| 29 | + | Paths []string `json:"paths,omitempty"` | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | type managedWorktree struct { | |
| 33 | + | Path string `json:"path"` | |
| 34 | + | Branch string `json:"branch,omitempty"` | |
| 35 | + | Head string `json:"head,omitempty"` | |
| 36 | + | Primary bool `json:"primary"` | |
| 37 | + | Dirty bool `json:"dirty"` | |
| 38 | + | Prunable bool `json:"prunable"` | |
| 39 | + | Merged bool `json:"merged"` | |
| 40 | + | CleanupOK bool `json:"cleanup_eligible"` | |
| 41 | + | BlockedBy string `json:"blocked_by,omitempty"` | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type manageWorktreesPlan struct { | |
| 45 | + | repoRoot string | |
| 46 | + | action string | |
| 47 | + | selected []managedWorktree | |
| 48 | + | all []managedWorktree | |
| 49 | + | text string | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type manageWorktreesPreview struct { | |
| 53 | + | raw string | |
| 54 | + | plan manageWorktreesPlan | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | const manageWorktreesSchema = `{ | |
| 58 | + | "type":"object", | |
| 59 | + | "properties":{ | |
| 60 | + | "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."}, | |
| 61 | + | "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."} | |
| 62 | + | }, | |
| 63 | + | "required":["action"] | |
| 64 | + | }` | |
| 65 | + | ||
| 66 | + | func (t *ManageWorktreesTool) Name() string { return "manage_worktrees" } | |
| 67 | + | func (t *ManageWorktreesTool) Description() string { | |
| 68 | + | return "List repository worktrees and safely remove explicitly selected clean, merged worktrees." | |
| 69 | + | } | |
| 70 | + | func (t *ManageWorktreesTool) Schema() json.RawMessage { return json.RawMessage(manageWorktreesSchema) } | |
| 71 | + | ||
| 72 | + | func (t *ManageWorktreesTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |
| 73 | + | plan, err := t.plan(ctx, raw) | |
| 74 | + | if err != nil { | |
| 75 | + | return core.ToolResult{}, err | |
| 76 | + | } | |
| 77 | + | t.previewMu.Lock() | |
| 78 | + | t.preview = &manageWorktreesPreview{raw: string(raw), plan: plan} | |
| 79 | + | t.previewMu.Unlock() | |
| 80 | + | return manageWorktreesResult(plan, "preview"), nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 84 | + | plan, err := t.plan(ctx, raw) | |
| 85 | + | if err != nil { | |
| 86 | + | return core.ToolResult{}, err | |
| 87 | + | } | |
| 88 | + | t.previewMu.Lock() | |
| 89 | + | cached := t.preview | |
| 90 | + | t.preview = nil | |
| 91 | + | t.previewMu.Unlock() | |
| 92 | + | if cached != nil && cached.raw == string(raw) && !sameManageWorktreesPlan(cached.plan, plan) { | |
| 93 | + | return core.ToolResult{}, errors.New("manage_worktrees: repository state changed since preview; inspect and retry") | |
| 94 | + | } | |
| 95 | + | if plan.action == "list" { | |
| 96 | + | return manageWorktreesResult(plan, "listed"), nil | |
| 97 | + | } | |
| 98 | + | for _, worktree := range plan.selected { | |
| 99 | + | if progress != nil { | |
| 100 | + | progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n") | |
| 101 | + | } | |
| 102 | + | if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil { | |
| 103 | + | return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune") | |
| 107 | + | return manageWorktreesResult(plan, "removed"), nil | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) { | |
| 111 | + | var args manageWorktreesArgs | |
| 112 | + | if err := json.Unmarshal(raw, &args); err != nil { | |
| 113 | + | return manageWorktreesPlan{}, fmt.Errorf("invalid args: %w", err) | |
| 114 | + | } | |
| 115 | + | if args.Action != "list" && args.Action != "cleanup" { | |
| 116 | + | return manageWorktreesPlan{}, errors.New("manage_worktrees: action must be list or cleanup") | |
| 117 | + | } | |
| 118 | + | if t.Sandbox == nil { | |
| 119 | + | return manageWorktreesPlan{}, errors.New("manage_worktrees: sandbox is required") | |
| 120 | + | } | |
| 121 | + | if err := t.Sandbox.CheckBashPermission("git"); err != nil { | |
| 122 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |
| 123 | + | } | |
| 124 | + | cwd := t.CWD | |
| 125 | + | if cwd == "" { | |
| 126 | + | var err error | |
| 127 | + | cwd, err = os.Getwd() | |
| 128 | + | if err != nil { | |
| 129 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: working directory: %w", err) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | if err := t.Sandbox.CheckReadPath(cwd); err != nil { | |
| 133 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |
| 134 | + | } | |
| 135 | + | repoRoot, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel") | |
| 136 | + | if err != nil { | |
| 137 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: verify repository: %w", err) | |
| 138 | + | } | |
| 139 | + | repoRoot, err = filepath.Abs(repoRoot) | |
| 140 | + | if err != nil { | |
| 141 | + | return manageWorktreesPlan{}, err | |
| 142 | + | } | |
| 143 | + | if err := t.Sandbox.CheckReadPath(repoRoot); err != nil { | |
| 144 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |
| 145 | + | } | |
| 146 | + | items, err := inspectManagedWorktrees(ctx, repoRoot) | |
| 147 | + | if err != nil { | |
| 148 | + | return manageWorktreesPlan{}, err | |
| 149 | + | } | |
| 150 | + | plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items} | |
| 151 | + | if args.Action == "cleanup" { | |
| 152 | + | if len(args.Paths) == 0 { | |
| 153 | + | return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path") | |
| 154 | + | } | |
| 155 | + | byPath := make(map[string]managedWorktree, len(items)) | |
| 156 | + | for _, item := range items { | |
| 157 | + | canonicalPath, err := canonicalOrParent(item.Path) | |
| 158 | + | if err != nil { | |
| 159 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve registered path: %w", err) | |
| 160 | + | } | |
| 161 | + | byPath[canonicalPath] = item | |
| 162 | + | } | |
| 163 | + | seen := map[string]bool{} | |
| 164 | + | for _, requested := range args.Paths { | |
| 165 | + | if !filepath.IsAbs(requested) { | |
| 166 | + | requested = filepath.Join(cwd, requested) | |
| 167 | + | } | |
| 168 | + | path, err := canonicalOrParent(requested) | |
| 169 | + | if err != nil { | |
| 170 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve path: %w", err) | |
| 171 | + | } | |
| 172 | + | item, ok := byPath[path] | |
| 173 | + | if !ok { | |
| 174 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q is not a registered worktree", filepath.ToSlash(path)) | |
| 175 | + | } | |
| 176 | + | if !item.CleanupOK { | |
| 177 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q cannot be removed: %s", filepath.ToSlash(path), item.BlockedBy) | |
| 178 | + | } | |
| 179 | + | if err := t.Sandbox.CheckWritePath(item.Path); err != nil { | |
| 180 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |
| 181 | + | } | |
| 182 | + | if !seen[item.Path] { | |
| 183 | + | plan.selected = append(plan.selected, item) | |
| 184 | + | seen[item.Path] = true | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | plan.text = renderManagedWorktrees(plan) | |
| 189 | + | return plan, nil | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // WorktreeInventoryContext returns a model-readable repository inventory. A | |
| 193 | + | // non-Git working directory has no inventory and is not an error. | |
| 194 | + | func WorktreeInventoryContext(ctx context.Context, cwd string) string { | |
| 195 | + | root, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel") | |
| 196 | + | if err != nil { | |
| 197 | + | return "" | |
| 198 | + | } | |
| 199 | + | output, err := manageWorktreesGit(ctx, root, "worktree", "list", "--porcelain") | |
| 200 | + | if err != nil { | |
| 201 | + | return "" | |
| 202 | + | } | |
| 203 | + | const maxInventoryItems = 20 | |
| 204 | + | var b strings.Builder | |
| 205 | + | b.WriteString("[Repository worktrees]\n") | |
| 206 | + | count := 0 | |
| 207 | + | for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") { | |
| 208 | + | if count == maxInventoryItems { | |
| 209 | + | break | |
| 210 | + | } | |
| 211 | + | path, branch := "", "" | |
| 212 | + | for _, line := range strings.Split(block, "\n") { | |
| 213 | + | if strings.HasPrefix(line, "worktree ") { | |
| 214 | + | path = strings.TrimPrefix(line, "worktree ") | |
| 215 | + | } | |
| 216 | + | if strings.HasPrefix(line, "branch refs/heads/") { | |
| 217 | + | branch = strings.TrimPrefix(line, "branch refs/heads/") | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | if path == "" { | |
| 221 | + | continue | |
| 222 | + | } | |
| 223 | + | fmt.Fprintf(&b, "- %s", filepath.ToSlash(path)) | |
| 224 | + | if branch != "" { | |
| 225 | + | fmt.Fprintf(&b, " [%s]", branch) | |
| 226 | + | } | |
| 227 | + | b.WriteByte('\n') | |
| 228 | + | count++ | |
| 229 | + | } | |
| 230 | + | total := len(strings.Split(strings.TrimSpace(output), "\n\n")) | |
| 231 | + | if total <= 1 { | |
| 232 | + | return "" | |
| 233 | + | } | |
| 234 | + | if total > count { | |
| 235 | + | fmt.Fprintf(&b, "- ... %d more worktrees\n", total-count) | |
| 236 | + | } | |
| 237 | + | b.WriteString("Use worktree action=list for current safety status before cleanup.") | |
| 238 | + | return b.String() | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) { | |
| 242 | + | output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain") | |
| 243 | + | if err != nil { | |
| 244 | + | return nil, fmt.Errorf("manage_worktrees: list: %w", err) | |
| 245 | + | } | |
| 246 | + | blocks := strings.Split(strings.TrimSpace(output), "\n\n") | |
| 247 | + | items := make([]managedWorktree, 0, len(blocks)) | |
| 248 | + | for index, block := range blocks { | |
| 249 | + | item := managedWorktree{Primary: index == 0} | |
| 250 | + | for _, line := range strings.Split(block, "\n") { | |
| 251 | + | switch { | |
| 252 | + | case strings.HasPrefix(line, "worktree "): | |
| 253 | + | item.Path = strings.TrimPrefix(line, "worktree ") | |
| 254 | + | case strings.HasPrefix(line, "HEAD "): | |
| 255 | + | item.Head = strings.TrimPrefix(line, "HEAD ") | |
| 256 | + | case strings.HasPrefix(line, "branch refs/heads/"): | |
| 257 | + | item.Branch = strings.TrimPrefix(line, "branch refs/heads/") | |
| 258 | + | case strings.HasPrefix(line, "prunable"): | |
| 259 | + | item.Prunable = true | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | if item.Path == "" { | |
| 263 | + | continue | |
| 264 | + | } | |
| 265 | + | if item.Primary { | |
| 266 | + | item.BlockedBy = "primary worktree" | |
| 267 | + | } else if item.Prunable { | |
| 268 | + | item.BlockedBy = "missing worktree; prune metadata instead" | |
| 269 | + | } else if item.Branch == "" { | |
| 270 | + | item.BlockedBy = "detached HEAD" | |
| 271 | + | } else if _, submoduleErr := os.Stat(filepath.Join(item.Path, ".gitmodules")); submoduleErr == nil { | |
| 272 | + | item.BlockedBy = "worktree contains submodules" | |
| 273 | + | } else if !os.IsNotExist(submoduleErr) { | |
| 274 | + | item.BlockedBy = "cannot inspect submodules: " + firstErrorLine(submoduleErr) | |
| 275 | + | } else { | |
| 276 | + | status, statusErr := manageWorktreesGit(ctx, item.Path, "status", "--porcelain", "--ignored=matching") | |
| 277 | + | if statusErr != nil { | |
| 278 | + | item.BlockedBy = "status unavailable: " + firstErrorLine(statusErr) | |
| 279 | + | } else if status != "" { | |
| 280 | + | item.Dirty = true | |
| 281 | + | item.BlockedBy = "uncommitted changes" | |
| 282 | + | } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil { | |
| 283 | + | item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr) | |
| 284 | + | } else { | |
| 285 | + | item.Merged = true | |
| 286 | + | item.CleanupOK = true | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | items = append(items, item) | |
| 290 | + | } | |
| 291 | + | return items, nil | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func renderManagedWorktrees(plan manageWorktreesPlan) string { | |
| 295 | + | var b strings.Builder | |
| 296 | + | if plan.action == "cleanup" { | |
| 297 | + | b.WriteString("remove worktrees\n") | |
| 298 | + | } else { | |
| 299 | + | b.WriteString("repository worktrees\n") | |
| 300 | + | } | |
| 301 | + | for _, item := range plan.all { | |
| 302 | + | fmt.Fprintf(&b, "- %s", filepath.ToSlash(item.Path)) | |
| 303 | + | if item.Branch != "" { | |
| 304 | + | fmt.Fprintf(&b, " [%s]", item.Branch) | |
| 305 | + | } | |
| 306 | + | state := item.BlockedBy | |
| 307 | + | if item.CleanupOK { | |
| 308 | + | state = "clean and merged; cleanup eligible" | |
| 309 | + | } | |
| 310 | + | if state != "" { | |
| 311 | + | fmt.Fprintf(&b, ": %s", state) | |
| 312 | + | } | |
| 313 | + | b.WriteByte('\n') | |
| 314 | + | } | |
| 315 | + | if plan.action == "cleanup" { | |
| 316 | + | b.WriteString("selected:\n") | |
| 317 | + | for _, item := range plan.selected { | |
| 318 | + | fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path)) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | return b.String() | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResult { | |
| 325 | + | return core.ToolResult{ | |
| 326 | + | Content: []provider.Content{provider.TextBlock{Text: plan.text}}, | |
| 327 | + | Details: map[string]any{"state": state, "worktrees": plan.all}, | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool { | |
| 332 | + | if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) { | |
| 333 | + | return false | |
| 334 | + | } | |
| 335 | + | for index := range a.selected { | |
| 336 | + | if a.selected[index] != b.selected[index] { | |
| 337 | + | return false | |
| 338 | + | } | |
| 339 | + | } | |
| 340 | + | for index := range a.all { | |
| 341 | + | if a.all[index] != b.all[index] { | |
| 342 | + | return false | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | return true | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func firstErrorLine(err error) string { | |
| 349 | + | line, _, _ := strings.Cut(err.Error(), "\n") | |
| 350 | + | return line | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func manageWorktreesGit(ctx context.Context, dir string, args ...string) (string, error) { | |
| 354 | + | return createWorktreeGitOutput(ctx, dir, args...) | |
| 355 | + | } | |
| @@ -0,0 +1,355 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | "sync" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/zut/packages/core" | |||
| 14 | + | "github.com/bnema/zut/packages/provider" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // ManageWorktreesTool inventories repository worktrees and removes only clean, | |||
| 18 | + | // merged, explicitly selected secondary worktrees. | |||
| 19 | + | type ManageWorktreesTool struct { | |||
| 20 | + | CWD string | |||
| 21 | + | Sandbox *Sandbox | |||
| 22 | + | ||||
| 23 | + | previewMu sync.Mutex | |||
| 24 | + | preview *manageWorktreesPreview | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type manageWorktreesArgs struct { | |||
| 28 | + | Action string `json:"action"` | |||
| 29 | + | Paths []string `json:"paths,omitempty"` | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | type managedWorktree struct { | |||
| 33 | + | Path string `json:"path"` | |||
| 34 | + | Branch string `json:"branch,omitempty"` | |||
| 35 | + | Head string `json:"head,omitempty"` | |||
| 36 | + | Primary bool `json:"primary"` | |||
| 37 | + | Dirty bool `json:"dirty"` | |||
| 38 | + | Prunable bool `json:"prunable"` | |||
| 39 | + | Merged bool `json:"merged"` | |||
| 40 | + | CleanupOK bool `json:"cleanup_eligible"` | |||
| 41 | + | BlockedBy string `json:"blocked_by,omitempty"` | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type manageWorktreesPlan struct { | |||
| 45 | + | repoRoot string | |||
| 46 | + | action string | |||
| 47 | + | selected []managedWorktree | |||
| 48 | + | all []managedWorktree | |||
| 49 | + | text string | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | type manageWorktreesPreview struct { | |||
| 53 | + | raw string | |||
| 54 | + | plan manageWorktreesPlan | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | const manageWorktreesSchema = `{ | |||
| 58 | + | "type":"object", | |||
| 59 | + | "properties":{ | |||
| 60 | + | "action":{"type":"string","enum":["list","cleanup"],"description":"List repository worktrees, or remove explicitly selected worktrees that are clean and merged."}, | |||
| 61 | + | "paths":{"type":"array","items":{"type":"string"},"description":"Exact worktree paths to remove. Required for cleanup."} | |||
| 62 | + | }, | |||
| 63 | + | "required":["action"] | |||
| 64 | + | }` | |||
| 65 | + | ||||
| 66 | + | func (t *ManageWorktreesTool) Name() string { return "manage_worktrees" } | |||
| 67 | + | func (t *ManageWorktreesTool) Description() string { | |||
| 68 | + | return "List repository worktrees and safely remove explicitly selected clean, merged worktrees." | |||
| 69 | + | } | |||
| 70 | + | func (t *ManageWorktreesTool) Schema() json.RawMessage { return json.RawMessage(manageWorktreesSchema) } | |||
| 71 | + | ||||
| 72 | + | func (t *ManageWorktreesTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |||
| 73 | + | plan, err := t.plan(ctx, raw) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return core.ToolResult{}, err | |||
| 76 | + | } | |||
| 77 | + | t.previewMu.Lock() | |||
| 78 | + | t.preview = &manageWorktreesPreview{raw: string(raw), plan: plan} | |||
| 79 | + | t.previewMu.Unlock() | |||
| 80 | + | return manageWorktreesResult(plan, "preview"), nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (t *ManageWorktreesTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |||
| 84 | + | plan, err := t.plan(ctx, raw) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return core.ToolResult{}, err | |||
| 87 | + | } | |||
| 88 | + | t.previewMu.Lock() | |||
| 89 | + | cached := t.preview | |||
| 90 | + | t.preview = nil | |||
| 91 | + | t.previewMu.Unlock() | |||
| 92 | + | if cached != nil && cached.raw == string(raw) && !sameManageWorktreesPlan(cached.plan, plan) { | |||
| 93 | + | return core.ToolResult{}, errors.New("manage_worktrees: repository state changed since preview; inspect and retry") | |||
| 94 | + | } | |||
| 95 | + | if plan.action == "list" { | |||
| 96 | + | return manageWorktreesResult(plan, "listed"), nil | |||
| 97 | + | } | |||
| 98 | + | for _, worktree := range plan.selected { | |||
| 99 | + | if progress != nil { | |||
| 100 | + | progress("Removing worktree " + filepath.ToSlash(worktree.Path) + "...\n") | |||
| 101 | + | } | |||
| 102 | + | if _, err := manageWorktreesGit(ctx, plan.repoRoot, "worktree", "remove", worktree.Path); err != nil { | |||
| 103 | + | return core.ToolResult{}, fmt.Errorf("manage_worktrees: remove %q: %w", filepath.ToSlash(worktree.Path), err) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | _, _ = manageWorktreesGit(ctx, plan.repoRoot, "worktree", "prune") | |||
| 107 | + | return manageWorktreesResult(plan, "removed"), nil | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | func (t *ManageWorktreesTool) plan(ctx context.Context, raw json.RawMessage) (manageWorktreesPlan, error) { | |||
| 111 | + | var args manageWorktreesArgs | |||
| 112 | + | if err := json.Unmarshal(raw, &args); err != nil { | |||
| 113 | + | return manageWorktreesPlan{}, fmt.Errorf("invalid args: %w", err) | |||
| 114 | + | } | |||
| 115 | + | if args.Action != "list" && args.Action != "cleanup" { | |||
| 116 | + | return manageWorktreesPlan{}, errors.New("manage_worktrees: action must be list or cleanup") | |||
| 117 | + | } | |||
| 118 | + | if t.Sandbox == nil { | |||
| 119 | + | return manageWorktreesPlan{}, errors.New("manage_worktrees: sandbox is required") | |||
| 120 | + | } | |||
| 121 | + | if err := t.Sandbox.CheckBashPermission("git"); err != nil { | |||
| 122 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |||
| 123 | + | } | |||
| 124 | + | cwd := t.CWD | |||
| 125 | + | if cwd == "" { | |||
| 126 | + | var err error | |||
| 127 | + | cwd, err = os.Getwd() | |||
| 128 | + | if err != nil { | |||
| 129 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: working directory: %w", err) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | if err := t.Sandbox.CheckReadPath(cwd); err != nil { | |||
| 133 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |||
| 134 | + | } | |||
| 135 | + | repoRoot, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel") | |||
| 136 | + | if err != nil { | |||
| 137 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: verify repository: %w", err) | |||
| 138 | + | } | |||
| 139 | + | repoRoot, err = filepath.Abs(repoRoot) | |||
| 140 | + | if err != nil { | |||
| 141 | + | return manageWorktreesPlan{}, err | |||
| 142 | + | } | |||
| 143 | + | if err := t.Sandbox.CheckReadPath(repoRoot); err != nil { | |||
| 144 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |||
| 145 | + | } | |||
| 146 | + | items, err := inspectManagedWorktrees(ctx, repoRoot) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return manageWorktreesPlan{}, err | |||
| 149 | + | } | |||
| 150 | + | plan := manageWorktreesPlan{repoRoot: repoRoot, action: args.Action, all: items} | |||
| 151 | + | if args.Action == "cleanup" { | |||
| 152 | + | if len(args.Paths) == 0 { | |||
| 153 | + | return manageWorktreesPlan{}, errors.New("manage_worktrees: cleanup requires at least one explicit path") | |||
| 154 | + | } | |||
| 155 | + | byPath := make(map[string]managedWorktree, len(items)) | |||
| 156 | + | for _, item := range items { | |||
| 157 | + | canonicalPath, err := canonicalOrParent(item.Path) | |||
| 158 | + | if err != nil { | |||
| 159 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve registered path: %w", err) | |||
| 160 | + | } | |||
| 161 | + | byPath[canonicalPath] = item | |||
| 162 | + | } | |||
| 163 | + | seen := map[string]bool{} | |||
| 164 | + | for _, requested := range args.Paths { | |||
| 165 | + | if !filepath.IsAbs(requested) { | |||
| 166 | + | requested = filepath.Join(cwd, requested) | |||
| 167 | + | } | |||
| 168 | + | path, err := canonicalOrParent(requested) | |||
| 169 | + | if err != nil { | |||
| 170 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: resolve path: %w", err) | |||
| 171 | + | } | |||
| 172 | + | item, ok := byPath[path] | |||
| 173 | + | if !ok { | |||
| 174 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q is not a registered worktree", filepath.ToSlash(path)) | |||
| 175 | + | } | |||
| 176 | + | if !item.CleanupOK { | |||
| 177 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %q cannot be removed: %s", filepath.ToSlash(path), item.BlockedBy) | |||
| 178 | + | } | |||
| 179 | + | if err := t.Sandbox.CheckWritePath(item.Path); err != nil { | |||
| 180 | + | return manageWorktreesPlan{}, fmt.Errorf("manage_worktrees: %w", err) | |||
| 181 | + | } | |||
| 182 | + | if !seen[item.Path] { | |||
| 183 | + | plan.selected = append(plan.selected, item) | |||
| 184 | + | seen[item.Path] = true | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | plan.text = renderManagedWorktrees(plan) | |||
| 189 | + | return plan, nil | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // WorktreeInventoryContext returns a model-readable repository inventory. A | |||
| 193 | + | // non-Git working directory has no inventory and is not an error. | |||
| 194 | + | func WorktreeInventoryContext(ctx context.Context, cwd string) string { | |||
| 195 | + | root, err := manageWorktreesGit(ctx, cwd, "rev-parse", "--show-toplevel") | |||
| 196 | + | if err != nil { | |||
| 197 | + | return "" | |||
| 198 | + | } | |||
| 199 | + | output, err := manageWorktreesGit(ctx, root, "worktree", "list", "--porcelain") | |||
| 200 | + | if err != nil { | |||
| 201 | + | return "" | |||
| 202 | + | } | |||
| 203 | + | const maxInventoryItems = 20 | |||
| 204 | + | var b strings.Builder | |||
| 205 | + | b.WriteString("[Repository worktrees]\n") | |||
| 206 | + | count := 0 | |||
| 207 | + | for _, block := range strings.Split(strings.TrimSpace(output), "\n\n") { | |||
| 208 | + | if count == maxInventoryItems { | |||
| 209 | + | break | |||
| 210 | + | } | |||
| 211 | + | path, branch := "", "" | |||
| 212 | + | for _, line := range strings.Split(block, "\n") { | |||
| 213 | + | if strings.HasPrefix(line, "worktree ") { | |||
| 214 | + | path = strings.TrimPrefix(line, "worktree ") | |||
| 215 | + | } | |||
| 216 | + | if strings.HasPrefix(line, "branch refs/heads/") { | |||
| 217 | + | branch = strings.TrimPrefix(line, "branch refs/heads/") | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | if path == "" { | |||
| 221 | + | continue | |||
| 222 | + | } | |||
| 223 | + | fmt.Fprintf(&b, "- %s", filepath.ToSlash(path)) | |||
| 224 | + | if branch != "" { | |||
| 225 | + | fmt.Fprintf(&b, " [%s]", branch) | |||
| 226 | + | } | |||
| 227 | + | b.WriteByte('\n') | |||
| 228 | + | count++ | |||
| 229 | + | } | |||
| 230 | + | total := len(strings.Split(strings.TrimSpace(output), "\n\n")) | |||
| 231 | + | if total <= 1 { | |||
| 232 | + | return "" | |||
| 233 | + | } | |||
| 234 | + | if total > count { | |||
| 235 | + | fmt.Fprintf(&b, "- ... %d more worktrees\n", total-count) | |||
| 236 | + | } | |||
| 237 | + | b.WriteString("Use worktree action=list for current safety status before cleanup.") | |||
| 238 | + | return b.String() | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func inspectManagedWorktrees(ctx context.Context, repoRoot string) ([]managedWorktree, error) { | |||
| 242 | + | output, err := manageWorktreesGit(ctx, repoRoot, "worktree", "list", "--porcelain") | |||
| 243 | + | if err != nil { | |||
| 244 | + | return nil, fmt.Errorf("manage_worktrees: list: %w", err) | |||
| 245 | + | } | |||
| 246 | + | blocks := strings.Split(strings.TrimSpace(output), "\n\n") | |||
| 247 | + | items := make([]managedWorktree, 0, len(blocks)) | |||
| 248 | + | for index, block := range blocks { | |||
| 249 | + | item := managedWorktree{Primary: index == 0} | |||
| 250 | + | for _, line := range strings.Split(block, "\n") { | |||
| 251 | + | switch { | |||
| 252 | + | case strings.HasPrefix(line, "worktree "): | |||
| 253 | + | item.Path = strings.TrimPrefix(line, "worktree ") | |||
| 254 | + | case strings.HasPrefix(line, "HEAD "): | |||
| 255 | + | item.Head = strings.TrimPrefix(line, "HEAD ") | |||
| 256 | + | case strings.HasPrefix(line, "branch refs/heads/"): | |||
| 257 | + | item.Branch = strings.TrimPrefix(line, "branch refs/heads/") | |||
| 258 | + | case strings.HasPrefix(line, "prunable"): | |||
| 259 | + | item.Prunable = true | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | if item.Path == "" { | |||
| 263 | + | continue | |||
| 264 | + | } | |||
| 265 | + | if item.Primary { | |||
| 266 | + | item.BlockedBy = "primary worktree" | |||
| 267 | + | } else if item.Prunable { | |||
| 268 | + | item.BlockedBy = "missing worktree; prune metadata instead" | |||
| 269 | + | } else if item.Branch == "" { | |||
| 270 | + | item.BlockedBy = "detached HEAD" | |||
| 271 | + | } else if _, submoduleErr := os.Stat(filepath.Join(item.Path, ".gitmodules")); submoduleErr == nil { | |||
| 272 | + | item.BlockedBy = "worktree contains submodules" | |||
| 273 | + | } else if !os.IsNotExist(submoduleErr) { | |||
| 274 | + | item.BlockedBy = "cannot inspect submodules: " + firstErrorLine(submoduleErr) | |||
| 275 | + | } else { | |||
| 276 | + | status, statusErr := manageWorktreesGit(ctx, item.Path, "status", "--porcelain", "--ignored=matching") | |||
| 277 | + | if statusErr != nil { | |||
| 278 | + | item.BlockedBy = "status unavailable: " + firstErrorLine(statusErr) | |||
| 279 | + | } else if status != "" { | |||
| 280 | + | item.Dirty = true | |||
| 281 | + | item.BlockedBy = "uncommitted changes" | |||
| 282 | + | } else if _, mergeErr := manageWorktreesGit(ctx, repoRoot, "merge-base", "--is-ancestor", item.Head, "HEAD"); mergeErr != nil { | |||
| 283 | + | item.BlockedBy = "branch is not merged into the current branch: " + firstErrorLine(mergeErr) | |||
| 284 | + | } else { | |||
| 285 | + | item.Merged = true | |||
| 286 | + | item.CleanupOK = true | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | items = append(items, item) | |||
| 290 | + | } | |||
| 291 | + | return items, nil | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func renderManagedWorktrees(plan manageWorktreesPlan) string { | |||
| 295 | + | var b strings.Builder | |||
| 296 | + | if plan.action == "cleanup" { | |||
| 297 | + | b.WriteString("remove worktrees\n") | |||
| 298 | + | } else { | |||
| 299 | + | b.WriteString("repository worktrees\n") | |||
| 300 | + | } | |||
| 301 | + | for _, item := range plan.all { | |||
| 302 | + | fmt.Fprintf(&b, "- %s", filepath.ToSlash(item.Path)) | |||
| 303 | + | if item.Branch != "" { | |||
| 304 | + | fmt.Fprintf(&b, " [%s]", item.Branch) | |||
| 305 | + | } | |||
| 306 | + | state := item.BlockedBy | |||
| 307 | + | if item.CleanupOK { | |||
| 308 | + | state = "clean and merged; cleanup eligible" | |||
| 309 | + | } | |||
| 310 | + | if state != "" { | |||
| 311 | + | fmt.Fprintf(&b, ": %s", state) | |||
| 312 | + | } | |||
| 313 | + | b.WriteByte('\n') | |||
| 314 | + | } | |||
| 315 | + | if plan.action == "cleanup" { | |||
| 316 | + | b.WriteString("selected:\n") | |||
| 317 | + | for _, item := range plan.selected { | |||
| 318 | + | fmt.Fprintf(&b, "- %s\n", filepath.ToSlash(item.Path)) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | return b.String() | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | func manageWorktreesResult(plan manageWorktreesPlan, state string) core.ToolResult { | |||
| 325 | + | return core.ToolResult{ | |||
| 326 | + | Content: []provider.Content{provider.TextBlock{Text: plan.text}}, | |||
| 327 | + | Details: map[string]any{"state": state, "worktrees": plan.all}, | |||
| 328 | + | } | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | func sameManageWorktreesPlan(a, b manageWorktreesPlan) bool { | |||
| 332 | + | if a.repoRoot != b.repoRoot || a.action != b.action || a.text != b.text || len(a.selected) != len(b.selected) || len(a.all) != len(b.all) { | |||
| 333 | + | return false | |||
| 334 | + | } | |||
| 335 | + | for index := range a.selected { | |||
| 336 | + | if a.selected[index] != b.selected[index] { | |||
| 337 | + | return false | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 340 | + | for index := range a.all { | |||
| 341 | + | if a.all[index] != b.all[index] { | |||
| 342 | + | return false | |||
| 343 | + | } | |||
| 344 | + | } | |||
| 345 | + | return true | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func firstErrorLine(err error) string { | |||
| 349 | + | line, _, _ := strings.Cut(err.Error(), "\n") | |||
| 350 | + | return line | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func manageWorktreesGit(ctx context.Context, dir string, args ...string) (string, error) { | |||
| 354 | + | return createWorktreeGitOutput(ctx, dir, args...) | |||
| 355 | + | } | |||
A packages/agent/tools/manage_worktrees_test.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "os/exec" | |
| 7 | + | "path/filepath" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestManageWorktreesListsCleanupEligibility(t *testing.T) { | |
| 13 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 14 | + | t.Skip("git is required") | |
| 15 | + | } | |
| 16 | + | repo := initWorktreeTestRepo(t) | |
| 17 | + | mergedPath := filepath.Join(t.TempDir(), "merged") | |
| 18 | + | gitTestOutput(t, repo, "branch", "merged") | |
| 19 | + | gitTestOutput(t, repo, "worktree", "add", mergedPath, "merged") | |
| 20 | + | dirtyPath := filepath.Join(t.TempDir(), "dirty") | |
| 21 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", dirtyPath) | |
| 22 | + | if err := os.WriteFile(filepath.Join(dirtyPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | tool := newManageWorktreesTestTool(repo) | |
| 27 | + | result, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil) | |
| 28 | + | if err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | got := resultText(result) | |
| 32 | + | for _, want := range []string{"repository worktrees", "merged", "cleanup eligible", "dirty", "uncommitted changes", "primary worktree"} { | |
| 33 | + | if !strings.Contains(got, want) { | |
| 34 | + | t.Fatalf("result missing %q:\n%s", want, got) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestManageWorktreesCleanupRemovesOnlyExplicitCleanMergedWorktree(t *testing.T) { | |
| 40 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 41 | + | t.Skip("git is required") | |
| 42 | + | } | |
| 43 | + | repo := initWorktreeTestRepo(t) | |
| 44 | + | path := filepath.Join(t.TempDir(), "merged") | |
| 45 | + | gitTestOutput(t, repo, "branch", "merged") | |
| 46 | + | gitTestOutput(t, repo, "worktree", "add", path, "merged") | |
| 47 | + | tool := newManageWorktreesTestTool(repo) | |
| 48 | + | args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}) | |
| 49 | + | ||
| 50 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |
| 51 | + | t.Fatal(err) | |
| 52 | + | } | |
| 53 | + | if _, err := os.Stat(path); err != nil { | |
| 54 | + | t.Fatalf("preview removed worktree: %v", err) | |
| 55 | + | } | |
| 56 | + | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |
| 57 | + | t.Fatal(err) | |
| 58 | + | } | |
| 59 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 60 | + | t.Fatalf("worktree remains: %v", err) | |
| 61 | + | } | |
| 62 | + | if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/merged"); got == "" { | |
| 63 | + | t.Fatal("cleanup unexpectedly removed branch") | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestManageWorktreesRejectsDirtyCleanup(t *testing.T) { | |
| 68 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 69 | + | t.Skip("git is required") | |
| 70 | + | } | |
| 71 | + | repo := initWorktreeTestRepo(t) | |
| 72 | + | path := filepath.Join(t.TempDir(), "dirty") | |
| 73 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", path) | |
| 74 | + | if err := os.WriteFile(filepath.Join(path, "dirty.txt"), []byte("dirty"), 0o644); err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | tool := newManageWorktreesTestTool(repo) | |
| 78 | + | _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})) | |
| 79 | + | if err == nil || !strings.Contains(err.Error(), "uncommitted changes") { | |
| 80 | + | t.Fatalf("error = %v, want dirty-worktree rejection", err) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestWorktreeInventoryContextReportsRepositoryWorktrees(t *testing.T) { | |
| 85 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 86 | + | t.Skip("git is required") | |
| 87 | + | } | |
| 88 | + | repo := initWorktreeTestRepo(t) | |
| 89 | + | path := filepath.Join(t.TempDir(), "merged") | |
| 90 | + | gitTestOutput(t, repo, "branch", "merged") | |
| 91 | + | gitTestOutput(t, repo, "worktree", "add", path, "merged") | |
| 92 | + | ||
| 93 | + | contextText := WorktreeInventoryContext(context.Background(), repo) | |
| 94 | + | wantPath, err := canonicalOrParent(path) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | for _, want := range []string{"[Repository worktrees]", filepath.ToSlash(wantPath), "worktree action=list"} { | |
| 99 | + | if !strings.Contains(contextText, want) { | |
| 100 | + | t.Fatalf("context missing %q:\n%s", want, contextText) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestManageWorktreesRejectsIgnoredFilesCleanup(t *testing.T) { | |
| 106 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 107 | + | t.Skip("git is required") | |
| 108 | + | } | |
| 109 | + | repo := initWorktreeTestRepo(t) | |
| 110 | + | path := filepath.Join(t.TempDir(), "ignored") | |
| 111 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "ignored", path) | |
| 112 | + | if err := os.WriteFile(filepath.Join(path, ".gitignore"), []byte("local.env\n"), 0o644); err != nil { | |
| 113 | + | t.Fatal(err) | |
| 114 | + | } | |
| 115 | + | gitTestOutput(t, path, "add", ".gitignore") | |
| 116 | + | gitTestOutput(t, path, "commit", "-m", "ignore local file") | |
| 117 | + | gitTestOutput(t, repo, "merge", "--ff-only", "ignored") | |
| 118 | + | if err := os.WriteFile(filepath.Join(path, "local.env"), []byte("private"), 0o600); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | tool := newManageWorktreesTestTool(repo) | |
| 122 | + | _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})) | |
| 123 | + | if err == nil || !strings.Contains(err.Error(), "uncommitted changes") { | |
| 124 | + | t.Fatalf("error = %v, want ignored-file rejection", err) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) { | |
| 129 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 130 | + | t.Skip("git is required") | |
| 131 | + | } | |
| 132 | + | repo := initWorktreeTestRepo(t) | |
| 133 | + | tool := newManageWorktreesTestTool(repo) | |
| 134 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "cleanup"}), nil) | |
| 135 | + | if err == nil || !strings.Contains(err.Error(), "explicit path") { | |
| 136 | + | t.Fatalf("error = %v, want explicit-path rejection", err) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool { | |
| 141 | + | return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)} | |
| 142 | + | } | |
| @@ -0,0 +1,142 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "os/exec" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestManageWorktreesListsCleanupEligibility(t *testing.T) { | |||
| 13 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 14 | + | t.Skip("git is required") | |||
| 15 | + | } | |||
| 16 | + | repo := initWorktreeTestRepo(t) | |||
| 17 | + | mergedPath := filepath.Join(t.TempDir(), "merged") | |||
| 18 | + | gitTestOutput(t, repo, "branch", "merged") | |||
| 19 | + | gitTestOutput(t, repo, "worktree", "add", mergedPath, "merged") | |||
| 20 | + | dirtyPath := filepath.Join(t.TempDir(), "dirty") | |||
| 21 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", dirtyPath) | |||
| 22 | + | if err := os.WriteFile(filepath.Join(dirtyPath, "dirty.txt"), []byte("dirty"), 0o644); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | tool := newManageWorktreesTestTool(repo) | |||
| 27 | + | result, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil) | |||
| 28 | + | if err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | got := resultText(result) | |||
| 32 | + | for _, want := range []string{"repository worktrees", "merged", "cleanup eligible", "dirty", "uncommitted changes", "primary worktree"} { | |||
| 33 | + | if !strings.Contains(got, want) { | |||
| 34 | + | t.Fatalf("result missing %q:\n%s", want, got) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestManageWorktreesCleanupRemovesOnlyExplicitCleanMergedWorktree(t *testing.T) { | |||
| 40 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 41 | + | t.Skip("git is required") | |||
| 42 | + | } | |||
| 43 | + | repo := initWorktreeTestRepo(t) | |||
| 44 | + | path := filepath.Join(t.TempDir(), "merged") | |||
| 45 | + | gitTestOutput(t, repo, "branch", "merged") | |||
| 46 | + | gitTestOutput(t, repo, "worktree", "add", path, "merged") | |||
| 47 | + | tool := newManageWorktreesTestTool(repo) | |||
| 48 | + | args := mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}}) | |||
| 49 | + | ||||
| 50 | + | if _, err := tool.Preview(context.Background(), args); err != nil { | |||
| 51 | + | t.Fatal(err) | |||
| 52 | + | } | |||
| 53 | + | if _, err := os.Stat(path); err != nil { | |||
| 54 | + | t.Fatalf("preview removed worktree: %v", err) | |||
| 55 | + | } | |||
| 56 | + | if _, err := tool.Execute(context.Background(), args, nil); err != nil { | |||
| 57 | + | t.Fatal(err) | |||
| 58 | + | } | |||
| 59 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 60 | + | t.Fatalf("worktree remains: %v", err) | |||
| 61 | + | } | |||
| 62 | + | if got := gitTestOutput(t, repo, "show-ref", "--verify", "refs/heads/merged"); got == "" { | |||
| 63 | + | t.Fatal("cleanup unexpectedly removed branch") | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestManageWorktreesRejectsDirtyCleanup(t *testing.T) { | |||
| 68 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 69 | + | t.Skip("git is required") | |||
| 70 | + | } | |||
| 71 | + | repo := initWorktreeTestRepo(t) | |||
| 72 | + | path := filepath.Join(t.TempDir(), "dirty") | |||
| 73 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "dirty", path) | |||
| 74 | + | if err := os.WriteFile(filepath.Join(path, "dirty.txt"), []byte("dirty"), 0o644); err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | tool := newManageWorktreesTestTool(repo) | |||
| 78 | + | _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})) | |||
| 79 | + | if err == nil || !strings.Contains(err.Error(), "uncommitted changes") { | |||
| 80 | + | t.Fatalf("error = %v, want dirty-worktree rejection", err) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestWorktreeInventoryContextReportsRepositoryWorktrees(t *testing.T) { | |||
| 85 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 86 | + | t.Skip("git is required") | |||
| 87 | + | } | |||
| 88 | + | repo := initWorktreeTestRepo(t) | |||
| 89 | + | path := filepath.Join(t.TempDir(), "merged") | |||
| 90 | + | gitTestOutput(t, repo, "branch", "merged") | |||
| 91 | + | gitTestOutput(t, repo, "worktree", "add", path, "merged") | |||
| 92 | + | ||||
| 93 | + | contextText := WorktreeInventoryContext(context.Background(), repo) | |||
| 94 | + | wantPath, err := canonicalOrParent(path) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | for _, want := range []string{"[Repository worktrees]", filepath.ToSlash(wantPath), "worktree action=list"} { | |||
| 99 | + | if !strings.Contains(contextText, want) { | |||
| 100 | + | t.Fatalf("context missing %q:\n%s", want, contextText) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestManageWorktreesRejectsIgnoredFilesCleanup(t *testing.T) { | |||
| 106 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 107 | + | t.Skip("git is required") | |||
| 108 | + | } | |||
| 109 | + | repo := initWorktreeTestRepo(t) | |||
| 110 | + | path := filepath.Join(t.TempDir(), "ignored") | |||
| 111 | + | gitTestOutput(t, repo, "worktree", "add", "-b", "ignored", path) | |||
| 112 | + | if err := os.WriteFile(filepath.Join(path, ".gitignore"), []byte("local.env\n"), 0o644); err != nil { | |||
| 113 | + | t.Fatal(err) | |||
| 114 | + | } | |||
| 115 | + | gitTestOutput(t, path, "add", ".gitignore") | |||
| 116 | + | gitTestOutput(t, path, "commit", "-m", "ignore local file") | |||
| 117 | + | gitTestOutput(t, repo, "merge", "--ff-only", "ignored") | |||
| 118 | + | if err := os.WriteFile(filepath.Join(path, "local.env"), []byte("private"), 0o600); err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | tool := newManageWorktreesTestTool(repo) | |||
| 122 | + | _, err := tool.Preview(context.Background(), mustJSON(t, map[string]any{"action": "cleanup", "paths": []string{path}})) | |||
| 123 | + | if err == nil || !strings.Contains(err.Error(), "uncommitted changes") { | |||
| 124 | + | t.Fatalf("error = %v, want ignored-file rejection", err) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func TestManageWorktreesRejectsCleanupWithoutExplicitPaths(t *testing.T) { | |||
| 129 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 130 | + | t.Skip("git is required") | |||
| 131 | + | } | |||
| 132 | + | repo := initWorktreeTestRepo(t) | |||
| 133 | + | tool := newManageWorktreesTestTool(repo) | |||
| 134 | + | _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "cleanup"}), nil) | |||
| 135 | + | if err == nil || !strings.Contains(err.Error(), "explicit path") { | |||
| 136 | + | t.Fatalf("error = %v, want explicit-path rejection", err) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func newManageWorktreesTestTool(cwd string) *ManageWorktreesTool { | |||
| 141 | + | return &ManageWorktreesTool{CWD: cwd, Sandbox: NewSandbox(cwd)} | |||
| 142 | + | } | |||
M packages/agent/tools/read.go +1 -1
| @@ -1,5 +1,5 @@ | |||
| 1 | 1 | // Package tools implements zut's built-in tools: read, write, edit, bash, | |
| 2 | - | // create_worktree, grep, lsp, web search, and autonomous goal updates. | |
| 2 | + | // worktree management, grep, lsp, web search, and autonomous goal updates. | |
| 3 | 3 | package tools | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | // Package tools implements zut's built-in tools: read, write, edit, bash, | 1 | // Package tools implements zut's built-in tools: read, write, edit, bash, | ||
| 2 | - | // create_worktree, grep, lsp, web search, and autonomous goal updates. | 2 | + | // worktree management, grep, lsp, web search, and autonomous goal updates. |
| 3 | package tools | 3 | package tools | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
A packages/agent/tools/worktree.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // WorktreeTool exposes persistent worktree creation and lifecycle management | |
| 14 | + | // through one public tool while keeping the implementations focused internally. | |
| 15 | + | type WorktreeTool struct { | |
| 16 | + | Create *CreateWorktreeTool | |
| 17 | + | Manage *ManageWorktreesTool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | const worktreeSchema = `{ | |
| 21 | + | "type":"object", | |
| 22 | + | "properties":{ | |
| 23 | + | "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."}, | |
| 24 | + | "branch":{"type":"string","description":"New branch name. Required for create."}, | |
| 25 | + | "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."} | |
| 26 | + | }, | |
| 27 | + | "required":["action"] | |
| 28 | + | }` | |
| 29 | + | ||
| 30 | + | func (t *WorktreeTool) Name() string { return "worktree" } | |
| 31 | + | func (t *WorktreeTool) Description() string { | |
| 32 | + | return "Create, list, and safely clean up Git worktrees." | |
| 33 | + | } | |
| 34 | + | func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) } | |
| 35 | + | ||
| 36 | + | func (t *WorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |
| 37 | + | action, delegated, err := parseWorktreeAction(raw) | |
| 38 | + | if err != nil { | |
| 39 | + | return core.ToolResult{}, err | |
| 40 | + | } | |
| 41 | + | switch action { | |
| 42 | + | case "create": | |
| 43 | + | if t.Create == nil { | |
| 44 | + | return core.ToolResult{}, errors.New("worktree: create action is unavailable") | |
| 45 | + | } | |
| 46 | + | result, err := t.Create.Preview(ctx, delegated) | |
| 47 | + | return result, worktreeError(err) | |
| 48 | + | case "list", "cleanup": | |
| 49 | + | if t.Manage == nil { | |
| 50 | + | return core.ToolResult{}, errors.New("worktree: management actions are unavailable") | |
| 51 | + | } | |
| 52 | + | result, err := t.Manage.Preview(ctx, delegated) | |
| 53 | + | return result, worktreeError(err) | |
| 54 | + | default: | |
| 55 | + | return core.ToolResult{}, errors.New("worktree: unknown action") | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (t *WorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 60 | + | action, delegated, err := parseWorktreeAction(raw) | |
| 61 | + | if err != nil { | |
| 62 | + | return core.ToolResult{}, err | |
| 63 | + | } | |
| 64 | + | switch action { | |
| 65 | + | case "create": | |
| 66 | + | if t.Create == nil { | |
| 67 | + | return core.ToolResult{}, errors.New("worktree: create action is unavailable") | |
| 68 | + | } | |
| 69 | + | result, err := t.Create.Execute(ctx, delegated, progress) | |
| 70 | + | return result, worktreeError(err) | |
| 71 | + | case "list", "cleanup": | |
| 72 | + | if t.Manage == nil { | |
| 73 | + | return core.ToolResult{}, errors.New("worktree: management actions are unavailable") | |
| 74 | + | } | |
| 75 | + | result, err := t.Manage.Execute(ctx, delegated, progress) | |
| 76 | + | return result, worktreeError(err) | |
| 77 | + | default: | |
| 78 | + | return core.ToolResult{}, errors.New("worktree: unknown action") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func parseWorktreeAction(raw json.RawMessage) (string, json.RawMessage, error) { | |
| 83 | + | var values map[string]json.RawMessage | |
| 84 | + | if err := json.Unmarshal(raw, &values); err != nil { | |
| 85 | + | return "", nil, err | |
| 86 | + | } | |
| 87 | + | var action string | |
| 88 | + | if err := json.Unmarshal(values["action"], &action); err != nil || action == "" { | |
| 89 | + | return "", nil, errors.New("worktree: action is required") | |
| 90 | + | } | |
| 91 | + | delete(values, "action") | |
| 92 | + | if action == "list" || action == "cleanup" { | |
| 93 | + | encoded, err := json.Marshal(map[string]any{"action": action, "paths": rawField(values, "paths")}) | |
| 94 | + | if err != nil { | |
| 95 | + | return "", nil, err | |
| 96 | + | } | |
| 97 | + | if action == "list" { | |
| 98 | + | encoded = json.RawMessage(`{"action":"list"}`) | |
| 99 | + | } | |
| 100 | + | return action, encoded, nil | |
| 101 | + | } | |
| 102 | + | encoded, err := json.Marshal(map[string]any{"branch": rawField(values, "branch")}) | |
| 103 | + | return action, encoded, err | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func worktreeError(err error) error { | |
| 107 | + | if err == nil { | |
| 108 | + | return nil | |
| 109 | + | } | |
| 110 | + | message := err.Error() | |
| 111 | + | for _, prefix := range []string{"create_worktree: ", "manage_worktrees: "} { | |
| 112 | + | if strings.HasPrefix(message, prefix) { | |
| 113 | + | message = strings.TrimPrefix(message, prefix) | |
| 114 | + | break | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return fmt.Errorf("worktree: %s", message) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func rawField(values map[string]json.RawMessage, key string) any { | |
| 121 | + | value, ok := values[key] | |
| 122 | + | if !ok { | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | var decoded any | |
| 126 | + | if json.Unmarshal(value, &decoded) != nil { | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | return decoded | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // WorktreeTool exposes persistent worktree creation and lifecycle management | |||
| 14 | + | // through one public tool while keeping the implementations focused internally. | |||
| 15 | + | type WorktreeTool struct { | |||
| 16 | + | Create *CreateWorktreeTool | |||
| 17 | + | Manage *ManageWorktreesTool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | const worktreeSchema = `{ | |||
| 21 | + | "type":"object", | |||
| 22 | + | "properties":{ | |||
| 23 | + | "action":{"type":"string","enum":["create","list","cleanup"],"description":"Create a branch worktree, list repository worktrees, or remove explicitly selected safe worktrees."}, | |||
| 24 | + | "branch":{"type":"string","description":"New branch name. Required for create."}, | |||
| 25 | + | "paths":{"type":"array","items":{"type":"string"},"description":"Exact registered worktree paths. Required for cleanup."} | |||
| 26 | + | }, | |||
| 27 | + | "required":["action"] | |||
| 28 | + | }` | |||
| 29 | + | ||||
| 30 | + | func (t *WorktreeTool) Name() string { return "worktree" } | |||
| 31 | + | func (t *WorktreeTool) Description() string { | |||
| 32 | + | return "Create, list, and safely clean up Git worktrees." | |||
| 33 | + | } | |||
| 34 | + | func (t *WorktreeTool) Schema() json.RawMessage { return json.RawMessage(worktreeSchema) } | |||
| 35 | + | ||||
| 36 | + | func (t *WorktreeTool) Preview(ctx context.Context, raw json.RawMessage) (core.ToolResult, error) { | |||
| 37 | + | action, delegated, err := parseWorktreeAction(raw) | |||
| 38 | + | if err != nil { | |||
| 39 | + | return core.ToolResult{}, err | |||
| 40 | + | } | |||
| 41 | + | switch action { | |||
| 42 | + | case "create": | |||
| 43 | + | if t.Create == nil { | |||
| 44 | + | return core.ToolResult{}, errors.New("worktree: create action is unavailable") | |||
| 45 | + | } | |||
| 46 | + | result, err := t.Create.Preview(ctx, delegated) | |||
| 47 | + | return result, worktreeError(err) | |||
| 48 | + | case "list", "cleanup": | |||
| 49 | + | if t.Manage == nil { | |||
| 50 | + | return core.ToolResult{}, errors.New("worktree: management actions are unavailable") | |||
| 51 | + | } | |||
| 52 | + | result, err := t.Manage.Preview(ctx, delegated) | |||
| 53 | + | return result, worktreeError(err) | |||
| 54 | + | default: | |||
| 55 | + | return core.ToolResult{}, errors.New("worktree: unknown action") | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (t *WorktreeTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |||
| 60 | + | action, delegated, err := parseWorktreeAction(raw) | |||
| 61 | + | if err != nil { | |||
| 62 | + | return core.ToolResult{}, err | |||
| 63 | + | } | |||
| 64 | + | switch action { | |||
| 65 | + | case "create": | |||
| 66 | + | if t.Create == nil { | |||
| 67 | + | return core.ToolResult{}, errors.New("worktree: create action is unavailable") | |||
| 68 | + | } | |||
| 69 | + | result, err := t.Create.Execute(ctx, delegated, progress) | |||
| 70 | + | return result, worktreeError(err) | |||
| 71 | + | case "list", "cleanup": | |||
| 72 | + | if t.Manage == nil { | |||
| 73 | + | return core.ToolResult{}, errors.New("worktree: management actions are unavailable") | |||
| 74 | + | } | |||
| 75 | + | result, err := t.Manage.Execute(ctx, delegated, progress) | |||
| 76 | + | return result, worktreeError(err) | |||
| 77 | + | default: | |||
| 78 | + | return core.ToolResult{}, errors.New("worktree: unknown action") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func parseWorktreeAction(raw json.RawMessage) (string, json.RawMessage, error) { | |||
| 83 | + | var values map[string]json.RawMessage | |||
| 84 | + | if err := json.Unmarshal(raw, &values); err != nil { | |||
| 85 | + | return "", nil, err | |||
| 86 | + | } | |||
| 87 | + | var action string | |||
| 88 | + | if err := json.Unmarshal(values["action"], &action); err != nil || action == "" { | |||
| 89 | + | return "", nil, errors.New("worktree: action is required") | |||
| 90 | + | } | |||
| 91 | + | delete(values, "action") | |||
| 92 | + | if action == "list" || action == "cleanup" { | |||
| 93 | + | encoded, err := json.Marshal(map[string]any{"action": action, "paths": rawField(values, "paths")}) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return "", nil, err | |||
| 96 | + | } | |||
| 97 | + | if action == "list" { | |||
| 98 | + | encoded = json.RawMessage(`{"action":"list"}`) | |||
| 99 | + | } | |||
| 100 | + | return action, encoded, nil | |||
| 101 | + | } | |||
| 102 | + | encoded, err := json.Marshal(map[string]any{"branch": rawField(values, "branch")}) | |||
| 103 | + | return action, encoded, err | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func worktreeError(err error) error { | |||
| 107 | + | if err == nil { | |||
| 108 | + | return nil | |||
| 109 | + | } | |||
| 110 | + | message := err.Error() | |||
| 111 | + | for _, prefix := range []string{"create_worktree: ", "manage_worktrees: "} { | |||
| 112 | + | if strings.HasPrefix(message, prefix) { | |||
| 113 | + | message = strings.TrimPrefix(message, prefix) | |||
| 114 | + | break | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | return fmt.Errorf("worktree: %s", message) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func rawField(values map[string]json.RawMessage, key string) any { | |||
| 121 | + | value, ok := values[key] | |||
| 122 | + | if !ok { | |||
| 123 | + | return nil | |||
| 124 | + | } | |||
| 125 | + | var decoded any | |||
| 126 | + | if json.Unmarshal(value, &decoded) != nil { | |||
| 127 | + | return nil | |||
| 128 | + | } | |||
| 129 | + | return decoded | |||
| 130 | + | } | |||
A packages/agent/tools/worktree_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os/exec" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestWorktreeToolDispatchesListAndCreate(t *testing.T) { | |
| 11 | + | if _, err := exec.LookPath("git"); err != nil { | |
| 12 | + | t.Skip("git is required") | |
| 13 | + | } | |
| 14 | + | repo := initWorktreeTestRepo(t) | |
| 15 | + | root := t.TempDir() | |
| 16 | + | tool := &WorktreeTool{ | |
| 17 | + | Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root}, | |
| 18 | + | Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)}, | |
| 19 | + | } | |
| 20 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil); err != nil { | |
| 21 | + | t.Fatal(err) | |
| 22 | + | } | |
| 23 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feature/unified"}), nil); err != nil { | |
| 24 | + | t.Fatal(err) | |
| 25 | + | } | |
| 26 | + | matches, err := filepath.Glob(filepath.Join(root, "*", "feature", "unified")) | |
| 27 | + | if err != nil || len(matches) != 1 { | |
| 28 | + | t.Fatalf("created paths = %v, error = %v", matches, err) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func TestWorktreeToolRequiresAction(t *testing.T) { | |
| 33 | + | tool := &WorktreeTool{Create: &CreateWorktreeTool{}, Manage: &ManageWorktreesTool{}} | |
| 34 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"branch": "missing-action"}), nil); err == nil { | |
| 35 | + | t.Fatal("missing action was accepted") | |
| 36 | + | } | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os/exec" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestWorktreeToolDispatchesListAndCreate(t *testing.T) { | |||
| 11 | + | if _, err := exec.LookPath("git"); err != nil { | |||
| 12 | + | t.Skip("git is required") | |||
| 13 | + | } | |||
| 14 | + | repo := initWorktreeTestRepo(t) | |||
| 15 | + | root := t.TempDir() | |||
| 16 | + | tool := &WorktreeTool{ | |||
| 17 | + | Create: &CreateWorktreeTool{CWD: repo, Sandbox: NewSandbox(repo), DefaultRoot: root}, | |||
| 18 | + | Manage: &ManageWorktreesTool{CWD: repo, Sandbox: NewSandbox(repo)}, | |||
| 19 | + | } | |||
| 20 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "list"}), nil); err != nil { | |||
| 21 | + | t.Fatal(err) | |||
| 22 | + | } | |||
| 23 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"action": "create", "branch": "feature/unified"}), nil); err != nil { | |||
| 24 | + | t.Fatal(err) | |||
| 25 | + | } | |||
| 26 | + | matches, err := filepath.Glob(filepath.Join(root, "*", "feature", "unified")) | |||
| 27 | + | if err != nil || len(matches) != 1 { | |||
| 28 | + | t.Fatalf("created paths = %v, error = %v", matches, err) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestWorktreeToolRequiresAction(t *testing.T) { | |||
| 33 | + | tool := &WorktreeTool{Create: &CreateWorktreeTool{}, Manage: &ManageWorktreesTool{}} | |||
| 34 | + | if _, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"branch": "missing-action"}), nil); err == nil { | |||
| 35 | + | t.Fatal("missing action was accepted") | |||
| 36 | + | } | |||
| 37 | + | } | |||
M packages/core/agent.go +9 -1
| @@ -101,6 +101,10 @@ type Agent struct { | |||
| 101 | 101 | // collection into one lifecycle round trip. | |
| 102 | 102 | BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string) | |
| 103 | 103 | ||
| 104 | + | // HostContext returns bounded host-owned context for the current turn. It is | |
| 105 | + | // composed with extension context instead of replacing extension hooks. | |
| 106 | + | HostContext func(ctx context.Context) string | |
| 107 | + | ||
| 104 | 108 | // CommitToolResult, if set, commits extension/tool metadata immediately | |
| 105 | 109 | // after each tool call completes. Returning an error converts the result to | |
| 106 | 110 | // an error before it is appended to the transcript or sent to the provider. | |
| @@ -666,7 +670,11 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||
| 666 | 670 | } | |
| 667 | 671 | } | |
| 668 | 672 | ||
| 669 | - | a.appendDynamicContext(turnContext) | |
| 673 | + | hostContext := "" | |
| 674 | + | if a.HostContext != nil { | |
| 675 | + | hostContext = a.HostContext(ctx) | |
| 676 | + | } | |
| 677 | + | a.appendDynamicContext(hostContext, turnContext) | |
| 670 | 678 | ||
| 671 | 679 | var ( | |
| 672 | 680 | stop provider.StopReason | |
| @@ -101,6 +101,10 @@ type Agent struct { | |||||
| 101 | // collection into one lifecycle round trip. | 101 | // collection into one lifecycle round trip. | ||
| 102 | BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string) | 102 | BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string) | ||
| 103 | 103 | ||||
| 104 | + | // HostContext returns bounded host-owned context for the current turn. It is | |||
| 105 | + | // composed with extension context instead of replacing extension hooks. | |||
| 106 | + | HostContext func(ctx context.Context) string | |||
| 107 | + | ||||
| 104 | // CommitToolResult, if set, commits extension/tool metadata immediately | 108 | // CommitToolResult, if set, commits extension/tool metadata immediately | ||
| 105 | // after each tool call completes. Returning an error converts the result to | 109 | // after each tool call completes. Returning an error converts the result to | ||
| 106 | // an error before it is appended to the transcript or sent to the provider. | 110 | // an error before it is appended to the transcript or sent to the provider. | ||
| @@ -666,7 +670,11 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte | |||||
| 666 | } | 670 | } | ||
| 667 | } | 671 | } | ||
| 668 | 672 | ||||
| 669 | - | a.appendDynamicContext(turnContext) | 673 | + | hostContext := "" |
| 674 | + | if a.HostContext != nil { | |||
| 675 | + | hostContext = a.HostContext(ctx) | |||
| 676 | + | } | |||
| 677 | + | a.appendDynamicContext(hostContext, turnContext) | |||
| 670 | 678 | ||||
| 671 | var ( | 679 | var ( | ||
| 672 | stop provider.StopReason | 680 | stop provider.StopReason | ||
M packages/core/dynamic_context.go +5 -2
| @@ -13,11 +13,14 @@ const internalContextMarker = "internal_context" | |||
| 13 | 13 | // only when its canonical value changes. It is deliberately separate from the | |
| 14 | 14 | // stable top-level instructions so changing time or extension state cannot | |
| 15 | 15 | // rewrite the instruction cache prefix. | |
| 16 | - | func (a *Agent) appendDynamicContext(turnContext string) { | |
| 17 | - | parts := make([]string, 0, 2) | |
| 16 | + | func (a *Agent) appendDynamicContext(hostContext, turnContext string) { | |
| 17 | + | parts := make([]string, 0, 3) | |
| 18 | 18 | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | |
| 19 | 19 | parts = append(parts, timeContext) | |
| 20 | 20 | } | |
| 21 | + | if hostContext = boundedTurnContext(hostContext); hostContext != "" { | |
| 22 | + | parts = append(parts, hostContext) | |
| 23 | + | } | |
| 21 | 24 | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | |
| 22 | 25 | parts = append(parts, "[Extension context]\n"+turnContext) | |
| 23 | 26 | } | |
| @@ -13,11 +13,14 @@ const internalContextMarker = "internal_context" | |||||
| 13 | // only when its canonical value changes. It is deliberately separate from the | 13 | // only when its canonical value changes. It is deliberately separate from the | ||
| 14 | // stable top-level instructions so changing time or extension state cannot | 14 | // stable top-level instructions so changing time or extension state cannot | ||
| 15 | // rewrite the instruction cache prefix. | 15 | // rewrite the instruction cache prefix. | ||
| 16 | - | func (a *Agent) appendDynamicContext(turnContext string) { | 16 | + | func (a *Agent) appendDynamicContext(hostContext, turnContext string) { |
| 17 | - | parts := make([]string, 0, 2) | 17 | + | parts := make([]string, 0, 3) |
| 18 | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | 18 | if timeContext := a.providerTimeContext().developerText(); timeContext != "" { | ||
| 19 | parts = append(parts, timeContext) | 19 | parts = append(parts, timeContext) | ||
| 20 | } | 20 | } | ||
| 21 | + | if hostContext = boundedTurnContext(hostContext); hostContext != "" { | |||
| 22 | + | parts = append(parts, hostContext) | |||
| 23 | + | } | |||
| 21 | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | 24 | if turnContext = boundedTurnContext(turnContext); turnContext != "" { | ||
| 22 | parts = append(parts, "[Extension context]\n"+turnContext) | 25 | parts = append(parts, "[Extension context]\n"+turnContext) | ||
| 23 | } | 26 | } | ||
M packages/core/dynamic_context_test.go +25 -0
| @@ -2,11 +2,36 @@ package core | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/zut/packages/provider" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 11 | + | func TestDynamicContextComposesHostAndExtensionContext(t *testing.T) { | |
| 12 | + | agent := NewAgent(&compactLifecycleClient{}, "test-model", "system", Registry{}) | |
| 13 | + | agent.HostContext = func(context.Context) string { return "host inventory" } | |
| 14 | + | agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |
| 15 | + | return true, "", "extension data" | |
| 16 | + | } | |
| 17 | + | if err := agent.Prompt(context.Background(), "user message", nil, nil); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | messages := agent.Messages() | |
| 21 | + | if len(messages) < 2 { | |
| 22 | + | t.Fatalf("messages = %#v", messages) | |
| 23 | + | } | |
| 24 | + | text, ok := messages[0].Content[0].(provider.TextBlock) | |
| 25 | + | if !ok { | |
| 26 | + | t.Fatalf("context content = %T", messages[0].Content[0]) | |
| 27 | + | } | |
| 28 | + | for _, want := range []string{"host inventory", "[Extension context]", "extension data"} { | |
| 29 | + | if !strings.Contains(text.Text, want) { | |
| 30 | + | t.Fatalf("dynamic context missing %q: %q", want, text.Text) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 10 | 35 | func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) { | |
| 11 | 36 | client := &compactLifecycleClient{} | |
| 12 | 37 | agent := NewAgent(client, "test-model", "system", Registry{}) | |
| @@ -2,11 +2,36 @@ package core | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "strings" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/zut/packages/provider" | 8 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 11 | + | func TestDynamicContextComposesHostAndExtensionContext(t *testing.T) { | |||
| 12 | + | agent := NewAgent(&compactLifecycleClient{}, "test-model", "system", Registry{}) | |||
| 13 | + | agent.HostContext = func(context.Context) string { return "host inventory" } | |||
| 14 | + | agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) { | |||
| 15 | + | return true, "", "extension data" | |||
| 16 | + | } | |||
| 17 | + | if err := agent.Prompt(context.Background(), "user message", nil, nil); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | messages := agent.Messages() | |||
| 21 | + | if len(messages) < 2 { | |||
| 22 | + | t.Fatalf("messages = %#v", messages) | |||
| 23 | + | } | |||
| 24 | + | text, ok := messages[0].Content[0].(provider.TextBlock) | |||
| 25 | + | if !ok { | |||
| 26 | + | t.Fatalf("context content = %T", messages[0].Content[0]) | |||
| 27 | + | } | |||
| 28 | + | for _, want := range []string{"host inventory", "[Extension context]", "extension data"} { | |||
| 29 | + | if !strings.Contains(text.Text, want) { | |||
| 30 | + | t.Fatalf("dynamic context missing %q: %q", want, text.Text) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 10 | func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) { | 35 | func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) { | ||
| 11 | client := &compactLifecycleClient{} | 36 | client := &compactLifecycleClient{} | ||
| 12 | agent := NewAgent(client, "test-model", "system", Registry{}) | 37 | agent := NewAgent(client, "test-model", "system", Registry{}) | ||