Skip to content

Worktrees

Git worktrees allow multiple working directories to share a single repository. For AI coding agents, worktrees enable session isolation — each agent session can operate in its own checkout without interfering with the user’s working directory. This page traces how the three reference implementations handle (or don’t handle) worktrees, and designs OpenOxide’s approach.

A git worktree is a secondary checkout of a repository. Created with git worktree add, it produces a separate directory with its own branch, index, and working tree, but sharing the same object database and refs as the parent. The .git entry in a worktree is a file (not a directory) containing a gitdir: pointer back to the main repository’s .git/worktrees/<name>/ directory.

Worktrees solve two problems for AI agents:

  1. Session isolation: The agent can make changes on a separate branch without touching the user’s working directory. If the agent’s changes are bad, reset the worktree. The user’s work is untouched.
  2. Parallel execution: Multiple agents can operate simultaneously in separate worktrees without file-level conflicts.

The challenge is that most git tooling assumes a single working directory. Operations like git status, git diff, and git stash all need to be directed at the correct worktree. Config inheritance, trust boundaries, and process isolation all become more complex with worktrees.


Aider has no explicit worktree support. It works passively through GitPython.

Aider creates its git.Repo object in aider/repo.py:126:

git.Repo(path, odbt=git.GitDB)

GitPython’s Repo constructor follows .git file pointers automatically. When called from within a worktree, repo.working_tree_dir returns the worktree’s directory (not the main repo’s), and repo.git_dir returns the worktree-specific git directory (e.g., ../.git/worktrees/<name>/).

This means Aider can run from inside a worktree. Commits, diffs, and file operations target the worktree’s branch correctly because GitPython delegates to the underlying git commands, which respect the worktree context.

  • No worktree creation: Aider cannot create worktrees. The user must set them up manually.
  • No worktree detection: Aider does not know whether it is running in a worktree or the main checkout. It treats both identically.
  • No worktree-specific configuration: No config inheritance or trust delegation.
  • No testing: No test coverage for worktree scenarios in the Aider test suite.

Aider’s implicit support is fragile. If GitPython changes how it resolves .git file pointers, or if Aider adds path-based assumptions (e.g., comparing repo.git_dir against known patterns), worktree support could break silently.


Codex has partial but deliberate worktree support: it detects worktrees for trust inheritance and config resolution, but does not create or manage them.

The primary repo detection function get_git_repo_root() (codex-rs/core/src/git_info.rs:28) walks up the directory tree looking for a .git file or directory. The function’s docstring explicitly acknowledges the limitation:

/// Note that this does **not** detect *work-trees* created with
/// `git worktree add` where the checkout lives outside the main repository
/// directory. If you need Codex to work from such a checkout simply pass the
/// `--allow-no-git-exec` CLI flag that disables the repo requirement.

The function finds .git entries but does not follow gitdir: pointers in .git files. For worktrees inside the main repo’s directory tree, this works fine (the walk will find the parent’s .git directory). For worktrees outside the main repo (e.g., git worktree add /tmp/my-worktree), the function fails to find a repo and requires --allow-no-git-exec as a workaround.

A separate function, resolve_root_git_project_for_trust() (git_info.rs:610-636), handles worktrees correctly:

pub fn resolve_root_git_project_for_trust(cwd: &Path) -> Option<PathBuf> {
let git_dir_out = std::process::Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(base)
.output()
.ok()?;
// ...
let git_dir_path = std::fs::canonicalize(&git_dir_path_raw)
.unwrap_or(git_dir_path_raw);
git_dir_path.parent().map(Path::to_path_buf)
}

git rev-parse --git-common-dir returns the shared .git directory regardless of whether the current directory is in a worktree or the main checkout. This function then takes the parent of that directory to get the project root. Path canonicalization handles macOS /var vs /private/var symlink ambiguity.

This is used in config/mod.rs:465-474 to resolve project-level config, allowing worktrees to inherit trust and configuration from their parent repository.

A unit test (git_info.rs:1124-1151) named resolve_root_git_project_for_trust_detects_worktree_and_returns_main_root confirms this behavior: it creates a worktree via git worktree add, then verifies that paths within the worktree resolve to the main repo root.

Codex blocks --work-tree and --work-tree= as dangerous git flags (codex-rs/shell-command/src/command_safety/is_dangerous_command.rs:39,51). This prevents the agent from overriding the working tree via shell commands, which could be used to escape the sandbox or modify files outside the intended scope.

The TurnDiffTracker (codex-rs/core/src/turn_diff_tracker.rs:41,141-143) caches git worktree roots to avoid repeated filesystem walks during a turn. find_git_root_cached() stores resolved roots and reuses them for subsequent file lookups.

The Windows sandbox implementation (codex-rs/windows-sandbox-rs/src/elevated_impl.rs:52-81) has explicit .git file parsing:

fn find_git_root(start: &Path) -> Option<PathBuf> {
// ...
if metadata.is_file() {
// .git is a file → this is a worktree
let content = std::fs::read_to_string(&git_path).ok()?;
if let Some(gitdir) = content.strip_prefix("gitdir: ") {
let resolved = if Path::new(gitdir_str).is_absolute() {
PathBuf::from(gitdir_str)
} else {
cur.join(gitdir_str)
};
return resolved.parent().and_then(|p| p.parent()).map(|p| p.to_path_buf());
}
}
}

When .git is a file (worktree indicator), the code reads the gitdir: pointer, resolves it (handling both absolute and relative paths), and navigates up to find the main repo root. This is used to inject safe.directory config for the sandbox user.

  • No worktree creation or management — you cannot codex worktree add or similar.
  • No multi-worktree agent orchestration — no built-in support for running multiple agents in separate worktrees.
  • No worktree lifecycle management — no reset, remove, or cleanup operations.

OpenCode has a complete worktree lifecycle management system behind the experimental flag. This is the most comprehensive worktree implementation of the three.

The implementation lives in packages/opencode/src/worktree/index.ts (643 lines) and is exposed via four HTTP endpoints in packages/opencode/src/server/routes/experimental.ts:89-186.

export const Info = z.object({
name: z.string(), // Human-readable name (e.g., "brave-cabin")
branch: z.string(), // Git branch (e.g., "opencode/brave-cabin")
directory: z.string(), // Absolute path to worktree directory
});
export const CreateInput = z.object({
name: z.string().optional(),
startCommand: z.string().optional(),
});

OpenCode generates worktree names using an adjective-noun pattern (worktree/index.ts:124-205). Two arrays of 29 adjectives and 30 nouns (e.g., “brave-cabin”, “stellar-falcon”, “nimble-wizard”) produce random names. If a user provides a name, it is slugified (lowercased, non-alphanumeric replaced with hyphens).

The candidate() function (worktree/index.ts:268-284) tries up to 26 variations before giving up. For each candidate, it checks:

  1. Does the directory already exist on the filesystem?
  2. Does the branch refs/heads/opencode/<name> already exist via git show-ref --verify?

Both must be clear for the name to be accepted.

Worktree.create() (worktree/index.ts:334-417):

  1. Validates the project uses git (Instance.project.vcs !== "git" check).
  2. Creates the worktree storage root at ~/.opencode/data/worktree/{projectId}/.
  3. Generates or slugifies the name, finds a unique candidate.
  4. Executes git worktree add --no-checkout -b {branch} {directory} from the main repo root.
  5. Registers the worktree in the database via Project.addSandbox().
  6. Kicks off async initialization in a setTimeout(0) callback: a. git reset --hard to populate the worktree with files. b. Instance.provide() to bootstrap a new OpenCode instance (loads project config, starts LSP servers, etc.). c. Emits worktree.ready or worktree.failed via GlobalBus. d. Runs the project’s start command + any worktree-specific start command.

The --no-checkout flag on git worktree add creates the worktree without populating files, allowing the separate git reset --hard step to handle checkout. This provides a clean error boundary — creation is separate from checkout.

Worktree.remove() (worktree/index.ts:419-506):

  1. Canonicalizes the target directory path (handles symlinks, trailing slashes, platform-specific case).
  2. Parses git worktree list --porcelain output to find the matching entry by canonical path comparison.
  3. If the worktree is not in the git list but the directory exists, falls back to direct fs.rm().
  4. Executes git worktree remove --force {path}.
  5. If git removal fails, re-checks the worktree list. If the entry is gone (git removed it but returned non-zero), proceeds with cleanup. If still present, throws.
  6. Direct filesystem cleanup via fs.rm() with retries (maxRetries: 5, retryDelay: 100ms) to handle permission errors and locked files.
  7. Deletes the associated branch with git branch -D {branch}.

The retry logic on filesystem removal is important: on Windows (and sometimes macOS), file locks from IDEs or LSP servers can prevent immediate deletion. The 5-retry with 100ms delay handles most transient lock scenarios.

Worktree.reset() (worktree/index.ts:508-642) resets a worktree to the default branch. This is the most complex operation:

  1. Safety check: Refuses to reset the primary workspace (compares canonical paths).
  2. Default branch detection (4-level fallback): a. origin/HEAD symbolic ref → parse remote branch name. b. If no origin, check for upstream remote. c. Local main branch exists? Use it. d. Local master branch exists? Use it. e. No default found → throw error.
  3. Fetch: If using a remote branch, fetch it first (git fetch {remote} {branch}).
  4. Hard reset: git reset --hard {target} in the worktree directory.
  5. Aggressive clean: git clean -ffdx with a two-pass sweep() function. The first pass may fail on locked files; failed() parses git’s “warning: failed to remove” output to extract problematic paths. prune() attempts direct fs.rm() on those paths (with path traversal protection — rejects paths outside the worktree root). A second git clean -ffdx handles any remaining files.
  6. Submodule reset: Three commands in sequence:
    • git submodule update --init --recursive --force
    • git submodule foreach --recursive git reset --hard
    • git submodule foreach --recursive git clean -fdx
  7. Validation: git status --porcelain=v1 must return empty. If dirty files remain, throws.
  8. Restart: Queues the project’s start script asynchronously.

Four experimental endpoints (experimental.ts:89-186):

MethodPathInputOutput
POST/experimental/worktreeCreateInput (name?, startCommand?)Worktree.Info
GET/experimental/worktreestring[] (directories)
DELETE/experimental/worktreeRemoveInput (directory)boolean
POST/experimental/worktree/resetResetInput (directory)boolean

Two bus events for async status tracking:

  • worktree.ready — emitted after successful bootstrap (properties: name, branch).
  • worktree.failed — emitted on any failure during async initialization (properties: message).

These events flow through GlobalBus and are visible to all connected clients via SSE.

The web app (packages/app/src/utils/worktree.ts, 73 lines) implements a lightweight state machine for the worktree lifecycle:

  • States: pending, ready, failed (terminal states prevent regression).
  • Normalizes directory paths (trailing slashes).
  • Shared waiter pattern for concurrent creation requests.

The sidebar (packages/app/src/pages/layout/sidebar-project.tsx) displays workspaces as either “local” (primary) or “sandbox” (worktree), with a badge showing the count. Context menus expose workspace operations.

Six specific error types (worktree/index.ts:82-122):

  • WorktreeNotGitError — non-git projects.
  • WorktreeNameGenerationFailedError — 26 naming attempts exhausted.
  • WorktreeCreateFailedErrorgit worktree add failed.
  • WorktreeStartCommandFailedError — startup script failed.
  • WorktreeRemoveFailedError — removal or branch deletion failed.
  • WorktreeResetFailedError — any reset step failed.

Worktree directories are persisted in the project’s sandbox list via Project.addSandbox() and Project.removeSandbox(). This survives restarts — OpenCode remembers which worktrees exist even if the process is killed.


The fundamental worktree detection problem: .git is a directory in normal repos and a file in worktrees. Any code that does if dir.join(".git").is_dir() will miss worktrees entirely. Codex’s get_git_repo_root() uses .exists() which catches both, but doesn’t follow the gitdir: pointer in the file. The Windows sandbox code is the only part that actually reads the pointer.

git rev-parse --git-dir returns the worktree-specific git directory (e.g., .git/worktrees/my-worktree/). git rev-parse --git-common-dir returns the shared repository root. Using the wrong one produces incorrect paths for config, hooks, and refs. Codex uses --git-common-dir for trust resolution — the correct choice.

When a worktree lives outside the main repo’s directory tree (e.g., /tmp/my-worktree), walking up the directory hierarchy will never find the main .git directory. The only reliable detection is reading the .git file or using git rev-parse. Codex documents this as a known limitation with a CLI flag workaround.

IDE language servers, file watchers, and operating system indexing services can lock files in a worktree. OpenCode handles this with retry logic (5 attempts, 100ms delay) and a two-pass clean strategy that parses git’s warning output. Without this, worktree removal fails intermittently on Windows and macOS.

git worktree remove deletes the checkout but does not delete the branch. If the branch is not explicitly deleted, it accumulates over time. OpenCode’s remove() explicitly runs git branch -D after removing the worktree. Without this, repeated create/remove cycles pollute the branch namespace.

Git submodules maintain their own state per worktree. A git reset --hard on the worktree root does not touch submodule contents. OpenCode’s reset runs three separate submodule commands (update, reset, clean) after the main reset. Missing any of these can leave stale submodule state.

The double -f flag in git clean -ffdx is required to remove untracked nested git repositories. Even with this, git clean can fail on locked files and report success (exit code 0) while leaving files behind. OpenCode validates with a final git status --porcelain=v1 check and throws if anything remains.

Worktree directory paths can differ between what git worktree list reports and what the user provides (symlinks, trailing slashes, case differences on case-insensitive filesystems). OpenCode canonicalizes all paths through fs.realpath() + path.normalize() + platform-specific lowercasing before comparison.

OpenCode stores worktrees in ~/.opencode/data/worktree/{projectId}/{name}/, not alongside the project. This avoids polluting the project directory with worktree checkouts, but means the worktrees are invisible to users browsing their project in a file manager. The tradeoff is intentional — worktrees are an internal implementation detail, not user-facing directories.

No Concurrent Agent Worktree Isolation Yet

Section titled “No Concurrent Agent Worktree Isolation Yet”

None of the three tools use worktrees for automatic per-agent isolation. OpenCode’s worktrees are user-managed through the API, not automatically created per session. The next step for AI agent tooling is automatic worktree creation per subagent or per task, with cleanup on completion.


Architecture: Optional Worktree Isolation Layer

Section titled “Architecture: Optional Worktree Isolation Layer”

OpenOxide should treat worktrees as an opt-in isolation layer with three modes:

  1. Direct mode (default): Agent operates in the user’s working directory, same as Aider/Codex today.
  2. Worktree mode (manual): User or config creates worktrees via CLI; agent sessions are assigned to them.
  3. Auto-worktree mode (future): Subagents automatically get fresh worktrees; changes are reviewed before merging back.
pub struct WorktreeManager {
main_repo: PathBuf, // The .git root of the main repository
storage_root: PathBuf, // Where worktrees are stored (~/.openoxide/worktrees/)
worktrees: HashMap<String, WorktreeInfo>,
}
pub struct WorktreeInfo {
pub name: String,
pub branch: String,
pub directory: PathBuf,
pub status: WorktreeStatus,
}
pub enum WorktreeStatus {
Creating,
Ready,
Failed(String),
Removing,
}

Use git2 crate’s Repository::open() which handles .git files natively — no manual pointer parsing needed. For trust resolution, shell out to git rev-parse --git-common-dir (matching Codex’s approach) since git2 does not expose the common dir API directly.

  1. Generate name (adjective-noun pattern, adopt OpenCode’s approach).
  2. git2::Repository::worktree() to create the worktree with --no-checkout.
  3. git2::Repository::open() to open the new worktree.
  4. git2::Repository::reset() with ResetType::Hard to populate.
  5. Emit WorktreeEvent::Ready via the event bus.
  6. Optionally run project start command via openoxide-exec.
  1. Resolve default branch (remote HEAD → local main → local master).
  2. git2::Repository::reset() with ResetType::Hard to the target.
  3. Shell out to git clean -ffdx (git2 does not have an equivalent).
  4. Handle submodule reset via shell commands.
  5. Validate clean state via git2::Repository::statuses().
  1. Delete working directory via std::fs::remove_dir_all() with retry wrapper.
  2. git worktree prune to clean stale entries.
  3. Delete the branch via git2::Branch::delete().
openoxide worktree create [--name NAME] [--start-command CMD]
openoxide worktree list
openoxide worktree remove <directory>
openoxide worktree reset <directory>

When spawning a subagent in auto-worktree mode:

  1. Create a fresh worktree branching from the current HEAD.
  2. Assign the subagent session to the worktree directory.
  3. On completion, generate a diff of the worktree against the parent.
  4. Present the diff for review (or auto-merge if configured).
  5. Remove the worktree.

This provides true session isolation without the agent needing to know it is in a worktree.

  • openoxide-worktree: WorktreeManager, creation/reset/removal operations, name generation, event emission.
  • openoxide-git: Shared git utilities (repo detection, trust resolution, default branch detection). Used by worktree manager and other git features.
  • openoxide-exec: Start command execution (reused from command execution feature).
  1. Use git2 for worktree creation — avoids shelling out for the core operation, gets proper error types.
  2. Shell out for git clean — git2 has no clean equivalent. Accept the subprocess cost.
  3. Store worktrees outside the project — follow OpenCode’s ~/.openoxide/worktrees/ pattern. Keep the project directory clean.
  4. Branch naming convention: openoxide/<name> prefix, matching OpenCode’s opencode/<name>.
  5. Retry on removal — file locks are real. Implement a 5-attempt retry with exponential backoff.
  6. Path canonicalization everywhere — use std::fs::canonicalize() before any path comparison. Handle platform-specific quirks.