Skip to main content

batuta/agent/
worktree.rs

1//! Git worktree isolation for subagents (Claude-Code parity).
2//!
3//! PMAT-CODE-WORKTREE-001: Claude Code's Agent tool accepts an
4//! `isolation: "worktree"` flag. When set, the runtime creates a
5//! fresh `git worktree` checked out to a new branch, the agent runs
6//! against that working tree, and at completion:
7//!
8//! - if the worktree is clean (no changes), it is removed and the
9//!   branch is deleted — the caller sees no artifact.
10//! - if the worktree is dirty, both the worktree path and the
11//!   branch are returned so the caller can inspect or merge.
12//!
13//! This module ships the primitives (`WorktreeSession::create`,
14//! `.is_dirty()`, `.auto_close_if_clean()`, `.keep()`) so
15//! spawn-tool call sites can opt in.
16//!
17//! # Example
18//!
19//! ```rust,ignore
20//! use aprender_orchestrate::agent::worktree::WorktreeSession;
21//!
22//! let repo = std::path::Path::new(".");
23//! let session = WorktreeSession::create(repo, "agent/xyz")?;
24//! // ...agent runs, edits files under session.path()...
25//! if session.is_dirty()? {
26//!     let kept = session.keep()?;  // returns (path, branch)
27//!     println!("worktree kept at {} on branch {}", kept.0.display(), kept.1);
28//! } else {
29//!     session.auto_close()?;  // removes worktree + deletes branch
30//! }
31//! ```
32
33use std::path::{Path, PathBuf};
34use std::process::Command;
35
36/// Errors arising from worktree lifecycle operations.
37#[derive(Debug)]
38pub enum WorktreeError {
39    /// `git worktree add` failed.
40    CreateFailed(String),
41    /// `git worktree remove` failed.
42    RemoveFailed(String),
43    /// `git branch -D` failed.
44    BranchDeleteFailed(String),
45    /// `git status --porcelain` failed.
46    StatusFailed(String),
47    /// Spawn of the underlying git process failed (e.g. git not on PATH).
48    SpawnFailed(String),
49    /// Supplied branch name is empty.
50    EmptyBranchName,
51}
52
53impl std::fmt::Display for WorktreeError {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::CreateFailed(msg) => write!(f, "git worktree add failed: {msg}"),
57            Self::RemoveFailed(msg) => write!(f, "git worktree remove failed: {msg}"),
58            Self::BranchDeleteFailed(msg) => write!(f, "git branch -D failed: {msg}"),
59            Self::StatusFailed(msg) => write!(f, "git status --porcelain failed: {msg}"),
60            Self::SpawnFailed(msg) => write!(f, "failed to spawn git: {msg}"),
61            Self::EmptyBranchName => write!(f, "branch name must be non-empty"),
62        }
63    }
64}
65
66impl std::error::Error for WorktreeError {}
67
68/// Active git worktree session scoped to a subagent.
69///
70/// On drop, nothing happens by design — the caller must choose
71/// `auto_close`, `auto_close_if_clean`, or `keep`. This forces an
72/// explicit disposition (Poka-Yoke) rather than silent cleanup
73/// that might discard agent output.
74#[derive(Debug)]
75pub struct WorktreeSession {
76    /// Path to the worktree directory.
77    path: PathBuf,
78    /// Branch checked out in the worktree.
79    branch: String,
80    /// Repository the worktree was forked from.
81    repo_root: PathBuf,
82}
83
84impl WorktreeSession {
85    /// Create a new worktree at a path derived from the branch name.
86    ///
87    /// Worktree is placed at `<repo>/.git/apr-worktrees/<sanitized-branch>`.
88    /// The branch is created fresh from HEAD.
89    pub fn create(repo_root: &Path, branch: &str) -> Result<Self, WorktreeError> {
90        if branch.trim().is_empty() {
91            return Err(WorktreeError::EmptyBranchName);
92        }
93        let worktree_path = worktree_path_for(repo_root, branch);
94        let output = Command::new("git")
95            .arg("-C")
96            .arg(repo_root)
97            .arg("worktree")
98            .arg("add")
99            .arg("-b")
100            .arg(branch)
101            .arg(&worktree_path)
102            .output()
103            .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
104
105        if !output.status.success() {
106            return Err(WorktreeError::CreateFailed(
107                String::from_utf8_lossy(&output.stderr).into(),
108            ));
109        }
110
111        Ok(Self {
112            path: worktree_path,
113            branch: branch.to_string(),
114            repo_root: repo_root.to_path_buf(),
115        })
116    }
117
118    /// Path to the worktree directory (pass as cwd to the subagent).
119    pub fn path(&self) -> &Path {
120        &self.path
121    }
122
123    /// Branch name checked out in the worktree.
124    pub fn branch(&self) -> &str {
125        &self.branch
126    }
127
128    /// Repository root the worktree was forked from.
129    pub fn repo_root(&self) -> &Path {
130        &self.repo_root
131    }
132
133    /// Check whether the worktree has any uncommitted changes.
134    ///
135    /// Uses `git status --porcelain` — any non-empty output is dirty.
136    pub fn is_dirty(&self) -> Result<bool, WorktreeError> {
137        let output = Command::new("git")
138            .arg("-C")
139            .arg(&self.path)
140            .arg("status")
141            .arg("--porcelain")
142            .output()
143            .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
144
145        if !output.status.success() {
146            return Err(WorktreeError::StatusFailed(
147                String::from_utf8_lossy(&output.stderr).into(),
148            ));
149        }
150
151        Ok(!output.stdout.is_empty())
152    }
153
154    /// Remove the worktree and delete its branch.
155    ///
156    /// Safe to call whether clean or dirty. Caller must have
157    /// decided they don't want to keep the work.
158    pub fn auto_close(self) -> Result<(), WorktreeError> {
159        remove_worktree(&self.repo_root, &self.path)?;
160        delete_branch(&self.repo_root, &self.branch)?;
161        Ok(())
162    }
163
164    /// Close the worktree only if clean; otherwise keep it.
165    ///
166    /// Returns `Ok(None)` if cleanup ran (clean case), or
167    /// `Ok(Some((path, branch)))` if the caller should keep the
168    /// worktree (dirty case).
169    pub fn auto_close_if_clean(self) -> Result<Option<(PathBuf, String)>, WorktreeError> {
170        if self.is_dirty()? {
171            Ok(Some((self.path, self.branch)))
172        } else {
173            let path = self.path.clone();
174            let branch = self.branch.clone();
175            remove_worktree(&self.repo_root, &path)?;
176            delete_branch(&self.repo_root, &branch)?;
177            Ok(None)
178        }
179    }
180
181    /// Explicitly keep the worktree, returning `(path, branch)`.
182    ///
183    /// The caller is now responsible for future cleanup.
184    pub fn keep(self) -> (PathBuf, String) {
185        (self.path, self.branch)
186    }
187}
188
189fn remove_worktree(repo_root: &Path, path: &Path) -> Result<(), WorktreeError> {
190    let output = Command::new("git")
191        .arg("-C")
192        .arg(repo_root)
193        .arg("worktree")
194        .arg("remove")
195        .arg("--force")
196        .arg(path)
197        .output()
198        .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
199
200    if !output.status.success() {
201        return Err(WorktreeError::RemoveFailed(String::from_utf8_lossy(&output.stderr).into()));
202    }
203    Ok(())
204}
205
206fn delete_branch(repo_root: &Path, branch: &str) -> Result<(), WorktreeError> {
207    let output = Command::new("git")
208        .arg("-C")
209        .arg(repo_root)
210        .arg("branch")
211        .arg("-D")
212        .arg(branch)
213        .output()
214        .map_err(|e| WorktreeError::SpawnFailed(e.to_string()))?;
215
216    if !output.status.success() {
217        return Err(WorktreeError::BranchDeleteFailed(
218            String::from_utf8_lossy(&output.stderr).into(),
219        ));
220    }
221    Ok(())
222}
223
224fn worktree_path_for(repo_root: &Path, branch: &str) -> PathBuf {
225    let sanitized: String = branch
226        .chars()
227        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
228        .collect();
229    repo_root.join(".git").join("apr-worktrees").join(sanitized)
230}
231
232#[cfg(test)]
233mod tests;