1use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use crate::types::{SlotId, TaskId};
11
12#[derive(Debug)]
14pub struct WorktreeManager {
15 base_dir: PathBuf,
17 repo_dir: PathBuf,
19}
20
21impl WorktreeManager {
22 pub fn new(repo_dir: impl Into<PathBuf>, base_dir: Option<PathBuf>) -> Self {
28 let repo_dir = repo_dir.into();
29 let base_dir =
30 base_dir.unwrap_or_else(|| std::env::temp_dir().join("claude-pool").join("worktrees"));
31 Self { base_dir, repo_dir }
32 }
33
34 pub async fn new_validated(
38 repo_dir: impl Into<PathBuf>,
39 base_dir: Option<PathBuf>,
40 ) -> Result<Self> {
41 let repo_dir = repo_dir.into();
42 let output = tokio::process::Command::new("git")
43 .args(["rev-parse", "--is-inside-work-tree"])
44 .current_dir(&repo_dir)
45 .output()
46 .await
47 .map_err(|e| {
48 Error::Store(format!(
49 "failed to check git repo at {}: {e}",
50 repo_dir.display()
51 ))
52 })?;
53
54 if !output.status.success() {
55 return Err(Error::Store(format!(
56 "worktree isolation requires a git repository, but {} is not inside a git work tree",
57 repo_dir.display()
58 )));
59 }
60
61 Ok(Self::new(repo_dir, base_dir))
62 }
63
64 pub async fn create(&self, slot_id: &SlotId) -> Result<PathBuf> {
69 let worktree_path = self.base_dir.join(&slot_id.0);
70
71 tokio::fs::create_dir_all(&self.base_dir)
73 .await
74 .map_err(|e| Error::Store(format!("failed to create worktree base dir: {e}")))?;
75
76 if worktree_path.exists() {
78 self.remove(slot_id).await?;
79 }
80
81 let branch_name = format!("claude-pool/{}", slot_id.0);
82 let output = tokio::process::Command::new("git")
83 .args([
84 "worktree",
85 "add",
86 "-b",
87 &branch_name,
88 worktree_path.to_str().unwrap_or_default(),
89 "HEAD",
90 ])
91 .current_dir(&self.repo_dir)
92 .output()
93 .await
94 .map_err(|e| Error::Store(format!("failed to create git worktree: {e}")))?;
95
96 if !output.status.success() {
97 let stderr = String::from_utf8_lossy(&output.stderr);
98 return Err(Error::Store(format!("git worktree add failed: {stderr}")));
99 }
100
101 tracing::info!(
102 slot_id = %slot_id.0,
103 path = %worktree_path.display(),
104 "created git worktree"
105 );
106
107 Ok(worktree_path)
108 }
109
110 pub async fn remove(&self, slot_id: &SlotId) -> Result<()> {
112 let worktree_path = self.base_dir.join(&slot_id.0);
113
114 if worktree_path.exists() {
115 let output = tokio::process::Command::new("git")
116 .args([
117 "worktree",
118 "remove",
119 "--force",
120 worktree_path.to_str().unwrap_or_default(),
121 ])
122 .current_dir(&self.repo_dir)
123 .output()
124 .await
125 .map_err(|e| Error::Store(format!("failed to remove git worktree: {e}")))?;
126
127 if !output.status.success() {
128 let stderr = String::from_utf8_lossy(&output.stderr);
129 tracing::warn!(
130 slot_id = %slot_id.0,
131 error = %stderr,
132 "failed to remove worktree, cleaning up manually"
133 );
134 let _ = tokio::fs::remove_dir_all(&worktree_path).await;
136 }
137 }
138
139 let branch_name = format!("claude-pool/{}", slot_id.0);
141 let _ = tokio::process::Command::new("git")
142 .args(["branch", "-D", &branch_name])
143 .current_dir(&self.repo_dir)
144 .output()
145 .await;
146
147 tracing::debug!(
148 slot_id = %slot_id.0,
149 "removed git worktree"
150 );
151
152 Ok(())
153 }
154
155 pub async fn cleanup_all(&self, slot_ids: &[SlotId]) -> Result<()> {
157 for id in slot_ids {
158 self.remove(id).await?;
159 }
160
161 let _ = tokio::process::Command::new("git")
163 .args(["worktree", "prune"])
164 .current_dir(&self.repo_dir)
165 .output()
166 .await;
167
168 Ok(())
169 }
170
171 pub fn worktree_path(&self, slot_id: &SlotId) -> PathBuf {
173 self.base_dir.join(&slot_id.0)
174 }
175
176 pub fn base_dir(&self) -> &Path {
178 &self.base_dir
179 }
180
181 pub fn repo_dir(&self) -> &Path {
183 &self.repo_dir
184 }
185
186 pub async fn create_for_chain(&self, task_id: &TaskId) -> Result<PathBuf> {
191 let worktree_path = self.chain_worktree_path(task_id);
192
193 let chains_dir = self.base_dir.join("chains");
195 tokio::fs::create_dir_all(&chains_dir)
196 .await
197 .map_err(|e| Error::Store(format!("failed to create chains dir: {e}")))?;
198
199 if worktree_path.exists() {
201 self.remove_chain(task_id).await?;
202 }
203
204 let branch_name = format!("claude-pool/chain/{}", task_id.0);
205 let output = tokio::process::Command::new("git")
206 .args([
207 "worktree",
208 "add",
209 "-b",
210 &branch_name,
211 worktree_path.to_str().unwrap_or_default(),
212 "HEAD",
213 ])
214 .current_dir(&self.repo_dir)
215 .output()
216 .await
217 .map_err(|e| Error::Store(format!("failed to create chain worktree: {e}")))?;
218
219 if !output.status.success() {
220 let stderr = String::from_utf8_lossy(&output.stderr);
221 return Err(Error::Store(format!(
222 "git worktree add failed for chain: {stderr}"
223 )));
224 }
225
226 tracing::info!(
227 task_id = %task_id.0,
228 path = %worktree_path.display(),
229 "created chain worktree"
230 );
231
232 Ok(worktree_path)
233 }
234
235 pub async fn remove_chain(&self, task_id: &TaskId) -> Result<()> {
237 let worktree_path = self.chain_worktree_path(task_id);
238
239 if worktree_path.exists() {
240 let output = tokio::process::Command::new("git")
241 .args([
242 "worktree",
243 "remove",
244 "--force",
245 worktree_path.to_str().unwrap_or_default(),
246 ])
247 .current_dir(&self.repo_dir)
248 .output()
249 .await
250 .map_err(|e| Error::Store(format!("failed to remove chain worktree: {e}")))?;
251
252 if !output.status.success() {
253 let stderr = String::from_utf8_lossy(&output.stderr);
254 tracing::warn!(
255 task_id = %task_id.0,
256 error = %stderr,
257 "failed to remove chain worktree, cleaning up manually"
258 );
259 let _ = tokio::fs::remove_dir_all(&worktree_path).await;
260 }
261 }
262
263 let branch_name = format!("claude-pool/chain/{}", task_id.0);
265 let _ = tokio::process::Command::new("git")
266 .args(["branch", "-D", &branch_name])
267 .current_dir(&self.repo_dir)
268 .output()
269 .await;
270
271 tracing::debug!(
272 task_id = %task_id.0,
273 "removed chain worktree"
274 );
275
276 Ok(())
277 }
278
279 pub fn chain_worktree_path(&self, task_id: &TaskId) -> PathBuf {
281 self.base_dir.join("chains").join(&task_id.0)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[tokio::test]
290 async fn new_validated_rejects_non_repo() {
291 let tmpdir = tempfile::tempdir().unwrap();
292 let result = WorktreeManager::new_validated(tmpdir.path(), None).await;
293 assert!(result.is_err());
294 let err = result.unwrap_err().to_string();
295 assert!(
296 err.contains("not inside a git work tree"),
297 "expected git work tree error, got: {err}"
298 );
299 }
300
301 #[tokio::test]
302 async fn new_validated_accepts_git_repo() {
303 let tmpdir = tempfile::tempdir().unwrap();
304 std::process::Command::new("git")
305 .args(["init"])
306 .current_dir(tmpdir.path())
307 .output()
308 .unwrap();
309 let mgr = WorktreeManager::new_validated(tmpdir.path(), None).await;
310 assert!(mgr.is_ok());
311 }
312
313 #[test]
314 fn worktree_path_construction() {
315 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
316 let id = SlotId("slot-0".into());
317 assert_eq!(mgr.worktree_path(&id), PathBuf::from("/tmp/wt/slot-0"));
318 }
319
320 #[test]
321 fn default_base_dir() {
322 let mgr = WorktreeManager::new("/repo", None);
323 let expected = std::env::temp_dir().join("claude-pool").join("worktrees");
324 assert_eq!(mgr.base_dir(), expected);
325 }
326
327 #[test]
328 fn chain_worktree_path_construction() {
329 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
330 let task_id = TaskId("chain-abc123".into());
331 assert_eq!(
332 mgr.chain_worktree_path(&task_id),
333 PathBuf::from("/tmp/wt/chains/chain-abc123")
334 );
335 }
336}