feat: run agents in isolated git worktrees#2938
Conversation
Add a --worktree/-w flag to `docker agent run` that, when the working directory is inside a git repository, creates a fresh git worktree on a dedicated branch and points the session at it. This isolates the agent's changes from the user's checkout. - Generated names use Docker's namesgenerator (e.g. "focused_turing"); an explicit name can be given with --worktree=<name>. - The branch is named worktree-<name> and the worktree is stored under <data-dir>/worktrees/<name>. - Explicit names are validated to prevent path traversal, and duplicate names are rejected with a clear error. - --worktree is mutually exclusive with --remote and --sandbox. - Documents the flag in the CLI reference.
Introduce the worktree_create lifecycle event, fired once just after `docker agent run --worktree` creates a git worktree and before the session starts. Each hook runs inside the new worktree so setup commands (copy .env, install dependencies, warm caches) operate on the fresh checkout. A blocking verdict aborts the run. The input carries worktree_path, worktree_branch, and worktree_source_dir (the repo root branched from) so hooks can copy untracked files git won't carry over. Unlike most events it is dispatched from the CLI rather than the run loop, because the working directory must be settled before the runtime and session exist. The dispatch nil-guards loadResult to match the surrounding cleanup convention. Also fix hookWorkingDir to default to the executor's working directory when a hook has no working_dir override, instead of inheriting the process cwd. This was masked for runtime hooks (the process has already chdir'd) but surfaced via the CLI-dispatched worktree_create event. Includes schema, example, docs, and tests.
When `docker agent run --worktree` created the worktree, clean it up once the interactive session ends: - Clean (no uncommitted changes, untracked files, or new commits): the worktree and its branch are removed automatically. - Has work: prompt to keep or remove, defaulting to keep on any non-yes answer or read error so work is never discarded silently. - Non-interactive (--exec): never cleaned up; left in place for inspection. - Pre-existing worktrees are never touched (Create only makes new ones). - A TUI error leaves the worktree in place rather than risk discarding work after an abnormal exit. Adds Worktree.Status (uncommitted/untracked/new-commit detection via a recorded BaseCommit) and Worktree.Remove, plus tests for the auto-remove, keep-on-decline, and confirm-remove paths. The session is shut down before removal so tools release file handles, and cleanup runs on a non-cancelable context so a Ctrl-C TUI exit doesn't abort the prompt.
Add `docker agent run --worktree-pr <number|url>`, which creates a git worktree checked out on an existing GitHub pull request so the agent can continue it. The PR's head branch is checked out tracking its remote, so commits made during the run push back to the pull request. PR resolution is delegated to the GitHub CLI (gh pr checkout), which handles head-branch lookup, fork remotes, and upstream tracking. The flag accepts a PR number, docker#123, or a pull request URL. - Mutually exclusive with --worktree, --remote, and --sandbox. - Returns clear errors when gh is missing or the ref is malformed. - Reuses the existing worktree_create hook and end-of-session cleanup. The worktree is stored under <data-dir>/worktrees/pr-<number>.
|
❌ PR Review Failed — The review agent encountered an error and could not complete the review. View logs. |
| // The branch can only be deleted once the worktree no longer occupies it; | ||
| // -D discards unmerged commits, which is the intended "remove and forget". | ||
| if err := git(ctx, wt.SourceDir, "branch", "-D", wt.Branch); err != nil { | ||
| return fmt.Errorf("deleting worktree branch: %w", err) |
There was a problem hiding this comment.
Remove() unconditionally deletes wt.Branch with git branch -D. For --worktree runs that branch is worktree-<name>, owned by this tool — fine to delete. For --worktree-pr runs, wt.Branch is the PR's actual head branch (set by gh pr checkout, e.g. fix/auth). If the user made new commits during the session and did not push them, confirming removal at the cleanup prompt silently destroys those commits: -D bypasses merge checks and the remote never received the work.
Suggested fix: add an OwnsBranch bool field to Worktree (true when Create minted the branch, false when CreatePR checked out an existing one) and skip the git branch -D step when OwnsBranch is false. worktree remove --force already removes the directory and the remote-tracking ref; only the local branch pointer needs to survive.
| if loadResult != nil { | ||
| if err := f.dispatchWorktreeCreate(ctx, out, loadResult.Team, createdWorktree); err != nil { | ||
| stopToolSets(loadResult.Team) | ||
| return err |
There was a problem hiding this comment.
When dispatchWorktreeCreate returns an error (hook blocked the run), the function returns early but the worktree is already on disk at <data-dir>/worktrees/<name>. It is never cleaned up.
Two consequences:
- The user gets an error message with no hint for manual cleanup.
- The next invocation with the same name hits the
os.Statguard inCreate()and fails immediately withErrInvalidName: worktree "X" already exists— the retry is broken.
Fix: call _ = createdWorktree.Remove(ctx) before returning in this error path. No agent work has been done yet, so there is nothing to preserve.
What
Adds first-class support for running an agent inside a git worktree, so its changes stay isolated from your checkout on a dedicated branch. Built up over four focused commits:
--worktree[=name]/-w— Run in a fresh worktree of the current repo. An explicit name is optional; otherwise a friendly name is generated (Docker-style, e.g.focused_turing). Stored at<data-dir>/worktrees/<name>on aworktree-<name>branch. Validated against path traversal; mutually exclusive with--remoteand--sandbox.worktree_createhook — A new lifecycle event fired just after the worktree is created and before the session starts. Hooks run inside the new worktree, receivingworktree_path,worktree_branch, andworktree_source_dir, so they can prepare the checkout (copy.env, install deps, warm caches). A blocking verdict aborts the run.Cleanup on exit — When the interactive session ends:
--exec→ never cleaned up; left for inspection.--worktree-pr <number|url>— Check out an existing GitHub pull request to continue it. The PR's head branch is checked out tracking its remote (viagh pr checkout), so commits push back to the PR. Handles fork remotes and upstream tracking. Requires the GitHub CLI.Notes
namesgenerator(Apache-2.0) verbatim to avoid pulling in the full engine module.--worktree-prrequiresghto be installed and authenticated; missing/malformed inputs produce clear errors.Testing
worktree_createhook covered end-to-end; fixedhookWorkingDirto default to the executor's working dir (surfaced by the CLI-dispatched event).task build/ tests /golangci-lint/ custom lint cops all pass.