Skip to content

feat: run agents in isolated git worktrees#2938

Open
dgageot wants to merge 4 commits into
docker:mainfrom
dgageot:board/6b40c47245b611f4
Open

feat: run agents in isolated git worktrees#2938
dgageot wants to merge 4 commits into
docker:mainfrom
dgageot:board/6b40c47245b611f4

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented May 30, 2026

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:

  1. --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 a worktree-<name> branch. Validated against path traversal; mutually exclusive with --remote and --sandbox.

  2. worktree_create hook — A new lifecycle event fired just after the worktree is created and before the session starts. Hooks run inside the new worktree, receiving worktree_path, worktree_branch, and worktree_source_dir, so they can prepare the checkout (copy .env, install deps, warm caches). A blocking verdict aborts the run.

  3. Cleanup on exit — When the interactive session ends:

    • Clean (no uncommitted changes, untracked files, or new commits) → worktree and branch removed automatically.
    • Has work → prompt to keep or remove (defaults to keep on any non-yes answer, so work is never lost silently).
    • --exec → never cleaned up; left for inspection.
    • TUI error → left in place.
    • Pre-existing worktrees are never touched.
  4. --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 (via gh pr checkout), so commits push back to the PR. Handles fork remotes and upstream tracking. Requires the GitHub CLI.

Notes

  • Worktree name generation vendors Moby's namesgenerator (Apache-2.0) verbatim to avoid pulling in the full engine module.
  • --worktree-pr requires gh to be installed and authenticated; missing/malformed inputs produce clear errors.

Testing

  • Unit tests for worktree create/status/remove, name validation, PR-ref parsing, and the cleanup decision paths (auto-remove, keep-on-decline, confirm-remove).
  • worktree_create hook covered end-to-end; fixed hookWorkingDir to default to the executor's working dir (surfaced by the CLI-dispatched event).
  • Docs (CLI reference + hooks) and an example config included.
  • task build / tests / golangci-lint / custom lint cops all pass.

dgageot added 4 commits May 30, 2026 11:42
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>.
@dgageot dgageot requested a review from a team as a code owner May 30, 2026 12:28
@aheritier aheritier added area/agent For work that has to do with the general agent loop/agentic features of the app kind/feat PR adds a new feature (maps to feat: commit prefix) labels May 30, 2026
@docker-agent
Copy link
Copy Markdown

PR Review Failed — The review agent encountered an error and could not complete the review. View logs.

Comment thread pkg/worktree/worktree.go
// 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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/root/run.go
if loadResult != nil {
if err := f.dispatchWorktreeCreate(ctx, out, loadResult.Team, createdWorktree); err != nil {
stopToolSets(loadResult.Team)
return err
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The user gets an error message with no hint for manual cleanup.
  2. The next invocation with the same name hits the os.Stat guard in Create() and fails immediately with ErrInvalidName: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/agent For work that has to do with the general agent loop/agentic features of the app kind/feat PR adds a new feature (maps to feat: commit prefix)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants