Git Worktrees

Per-task git worktree isolation gives every active task its own checkout on its own branch, so parallel agents never trample each other's working tree. Claiming a task materializes a worktree; finishing it auto-commits, merges back to trunk, and prunes the directory.

Typical flow

  1. Set MULTITERMINAL_WORKTREE_MODE=on (a startup env var — see below) and launch MultiTerminal.
  2. Claim / activate a task → you get an isolated checkout on branch task/<id> under .claude/worktrees/.
  3. Do the work there — your edits never touch the main checkout or any other task's tree.
  4. Mark the task done → MultiTerminal auto-commits, merges the branch back to trunk, and prunes the worktree directory.

Why worktrees

A git worktree is a second working directory backed by the same repository — a separate checkout on a separate branch that shares the one .git store. MultiTerminal uses one worktree per active task so that:

  • Two agents working two tasks in the same repo never see each other's uncommitted edits.
  • Each task's commits land on a dedicated task/<id> branch, keeping trunk clean until the task is done.
  • A helper joining a task gets its own branch and worktree, so helper work integrates deliberately rather than racing the assignee's tree.

Worktrees live inside the project at .claude/worktrees/ (gitignored). Keeping them under the main checkout matters for Claude Code's permission scope, harness cwd pinning, and the EnterWorktree enter-existing form (see below).

Turning it on (WORKTREE_MODE)

Worktree isolation is opt-in, gated by an environment variable. With it unset, MultiTerminal behaves exactly as before — everyone works directly in the main checkout.

# Enable per-task worktree isolation
MULTITERMINAL_WORKTREE_MODE=on

MULTITERMINAL_WORKTREE_MODE is a process environment variable read once at startup (WorktreeConfig resolves it on first access and caches the result). Set it before launching MultiTerminal — e.g. as a Windows user/system environment variable, or in the shell that starts the app — and restart the app for a change to take effect. Only the literal value on (case-insensitive) enables it; unset, blank, 1, true, and yes all leave it off.

When the gate is on, the worktree lifecycle hooks fire automatically at task-activate and task-done time. When it is off, no worktrees are created and the teardown steps are skipped.

Auto-create on claim

Setting a task active (claiming it, or activating an already-claimed task) materializes a worktree for the acting agent. The operation is idempotent: if that agent already has an active worktree on disk for the task, the existing one is returned unchanged — no second checkout, no git work.

  • The assignee gets the canonical worktree on branch task/<id>, forked from trunk.
  • Each helper gets their own worktree on task/<id>--<slug>, forked from the canonical branch's tip (the canonical branch is created first if it does not yet exist).

Fresh worktrees contain only tracked files. A few build-required artifacts in this repo are vendored and gitignored (notably tools/rg.exe), so MultiTerminal seeds the known vendored directories from the main checkout into each new worktree — otherwise a post-build copy step would fail.

Trunk safety

The canonical branch is always rooted explicitly at trunk, never at "whatever HEAD points to." If the main checkout is on a detached HEAD or sitting on another task/ branch, worktree creation fails loudly rather than silently forking the new branch from the wrong commit.

Branch & path naming

The first 8 characters of the task id (<idShort>) drive both the directory name and the branch name.

RoleBranchWorktree pathForked from
Assignee (canonical) task/<idShort> .claude/worktrees/<idShort>/ trunk
Helper task/<idShort>--<slug> .claude/worktrees/<idShort>--<slug>/ canonical branch tip

Records are persisted in the task_worktrees table, keyed by the composite (task_id, agent_name). A record is active while the directory exists on disk and pruned after removal; pruned rows are retained for audit/history.

Two agents on one task

Say task c0ce8037 is assigned to Alice and Bob joins as a helper with slug tests:

  • Alice (assignee) works in .claude/worktrees/c0ce8037/ on branch task/c0ce8037 — the canonical branch, forked from trunk.
  • Bob (helper) works in .claude/worktrees/c0ce8037--tests/ on branch task/c0ce8037--tests — forked from the canonical branch tip.

Each has its own checkout, so neither sees the other's uncommitted edits. On done, Bob's branch is merged into Alice's canonical branch first, then the canonical branch lands on trunk.

Teardown on done (commit → merge → prune)

Moving a task to done runs a multi-phase teardown so nothing the agents wrote is lost and trunk ends up carrying every agent's work:

  1. Auto-commit — each worktree's changes are committed on its own branch. MultiTerminal stages the exact file list from git status --porcelain (never a blanket git add -A), honors pre-commit hooks (never --no-verify), and writes a single commit with the task title, the implementation summary, and a Co-Authored-By trailer for the agent. A clean worktree is a no-op.
  2. Integrate helper branches — each task/<id>--<slug> branch is merged into the canonical task/<id> branch (inside the still-checked-out canonical worktree). A conflict aborts the merge cleanly and halts teardown so nothing is dropped.
  3. Prune — every worktree directory for the task is removed and its record marked pruned. Prune must run before the trunk merge: git refuses to merge a branch while it is checked out anywhere.
  4. Merge to trunk — the canonical task/<id> branch is merged into the main checkout's trunk with git merge --no-edit (fast-forward or merge commit), then the branch is deleted.

Merge guards (fail closed)

The trunk merge refuses rather than guessing when it cannot be sure it is landing in the right place:

  • The expected trunk is resolved from the project's configured git_default_branch, then the remote's origin/HEAD, then the sole non-task local branch only if it bears a conventional name (main, master, develop, trunk). If none resolve, the merge refuses.
  • If the main checkout's branch is not the expected trunk, the merge refuses with a "check out trunk and re-mark done" message.
  • If the main checkout has uncommitted tracked changes (beyond MT's own .claude/project.json bookkeeping), the merge refuses — genuine user work is never auto-committed.
  • A merge conflict triggers git merge --abort; the main checkout stays clean and the task branch is left alive for manual resolution.

Where your shell ends up

On Claude Code 2.1.157 and later, MultiTerminal uses the EnterWorktree / ExitWorktree harness operations to move a session's working directory into and out of a task worktree cleanly (rather than a raw cd, which the cwd-pinned harness does not honor). The session-start auto-cd flow enters the active task's worktree so a resumed shell lands in the right checkout.

This is also why worktrees must live under .claude/worktrees/: the EnterWorktree(path=...) enter-existing form requires the target be a worktree under that directory for cwd-pinned-at-launch agents (MT terminals).

HUD git panel rebind

The HUD git panel always shows the repository state of the active task's worktree, and rebinds itself when the active task changes. Three events drive this:

  • TaskActiveChanged — fires after the active task swaps. The HUD git renderer resolves the new task's worktree path agent-aware (a helper rebinds to its own task/<id>--<slug> worktree, not the assignee's canonical one).
  • WorktreePruning — fires synchronously just before the directory is removed at task-done. Subscribers drop live git handles, and a control message tells any agent whose cwd is inside the worktree to cd out before the rmdir.
  • WorktreeReady — fires after both prune and the post-prune trunk merge complete. The HUD git renderer releases its cached service for the pruned path and rebinds to the post-merge repo root.

The contract between the last two: pruning says "drop your handles, the directory is about to disappear"; ready says "the post-merge state is durable, refresh your view."

Querying the active worktree

A long-running shell can switch tasks, so the launch-time worktree path goes stale. Agents query the live path instead via the get_active_worktree MCP tool, which is backed by:

GET http://localhost:5050/api/worktrees/active/{agentName}

The endpoint always returns HTTP 200. A null worktreePath means "no active worktree" (no active task, worktree mode off, or project unregistered) and is used as the no-op signal in the auto-cd protocol. A representative response:

{
  "agentName": "Alice",
  "taskId": "c0ce8037",
  "taskTitle": "Add dark mode",
  "worktreePath": "H:\\DevLaptop\\...\\.claude\\worktrees\\c0ce8037",
  "repoRoot": "H:\\DevLaptop\\...\\MultiTerminal",
  "branchName": "task/c0ce8037"
}

This is a read-or-create endpoint: if the agent's active task is eligible but its worktree was never materialized, the call backfills it (idempotent, bounded to that agent's one legitimate worktree).

Failure handling

  • Partial prune (Windows): when an agent's terminal has its cwd inside a worktree, git worktree remove wipes contents and unregisters the worktree but cannot rmdir the directory (the OS holds an open handle). MultiTerminal detects this by re-querying git worktree list; if the path is gone from git's view it treats the removal as a success and leaves only an empty shell, which the janitor sweeps later. A strand signal is surfaced to the activity feed so it is observable in real time.
  • Stranded directories: GET /api/worktrees/stranded reports de-registered-but-on-disk worktree shells. Its payload carries an explicit status (ok / partial / unavailable) so a caller can never mistake "couldn't tell" for "none."
  • Dirty main checkout / conflicts: see the merge guards above — these refuse and leave state recoverable rather than forcing a merge.