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