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 pub async fn create_clone_for_chain(&self, task_id: &TaskId) -> Result<PathBuf> {
288 let clone_path = self.clone_path(task_id);
289
290 let clones_dir = self.base_dir.join("clones");
291 tokio::fs::create_dir_all(&clones_dir)
292 .await
293 .map_err(|e| Error::Store(format!("failed to create clones dir: {e}")))?;
294
295 if clone_path.exists() {
296 self.remove_clone(task_id).await?;
297 }
298
299 let output = tokio::process::Command::new("git")
300 .args([
301 "clone",
302 "--local",
303 "--shared",
304 self.repo_dir.to_str().unwrap_or_default(),
305 clone_path.to_str().unwrap_or_default(),
306 ])
307 .output()
308 .await
309 .map_err(|e| Error::Store(format!("failed to create chain clone: {e}")))?;
310
311 if !output.status.success() {
312 let stderr = String::from_utf8_lossy(&output.stderr);
313 return Err(Error::Store(format!(
314 "git clone failed for chain: {stderr}"
315 )));
316 }
317
318 let remote_output = tokio::process::Command::new("git")
321 .args(["remote", "get-url", "origin"])
322 .current_dir(&self.repo_dir)
323 .output()
324 .await
325 .map_err(|e| Error::Store(format!("failed to get origin URL: {e}")))?;
326
327 if remote_output.status.success() {
328 let url = String::from_utf8_lossy(&remote_output.stdout)
329 .trim()
330 .to_string();
331 if !url.is_empty() && !url.starts_with('/') {
333 let set_output = tokio::process::Command::new("git")
334 .args(["remote", "set-url", "origin", &url])
335 .current_dir(&clone_path)
336 .output()
337 .await
338 .map_err(|e| Error::Store(format!("failed to set origin URL in clone: {e}")))?;
339
340 if !set_output.status.success() {
341 let stderr = String::from_utf8_lossy(&set_output.stderr);
342 tracing::warn!(
343 task_id = %task_id.0,
344 error = %stderr,
345 "failed to set origin URL in clone"
346 );
347 }
348 }
349 }
350
351 tracing::info!(
352 task_id = %task_id.0,
353 path = %clone_path.display(),
354 "created chain clone"
355 );
356
357 Ok(clone_path)
358 }
359
360 pub async fn remove_clone(&self, task_id: &TaskId) -> Result<()> {
362 let clone_path = self.clone_path(task_id);
363
364 if clone_path.exists() {
365 tokio::fs::remove_dir_all(&clone_path).await.map_err(|e| {
366 Error::Store(format!(
367 "failed to remove chain clone at {}: {e}",
368 clone_path.display()
369 ))
370 })?;
371 }
372
373 tracing::debug!(task_id = %task_id.0, "removed chain clone");
374 Ok(())
375 }
376
377 pub fn clone_path(&self, task_id: &TaskId) -> PathBuf {
379 self.base_dir.join("clones").join(&task_id.0)
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[tokio::test]
388 async fn new_validated_rejects_non_repo() {
389 let tmpdir = tempfile::tempdir().unwrap();
390 let result = WorktreeManager::new_validated(tmpdir.path(), None).await;
391 assert!(result.is_err());
392 let err = result.unwrap_err().to_string();
393 assert!(
394 err.contains("not inside a git work tree"),
395 "expected git work tree error, got: {err}"
396 );
397 }
398
399 #[tokio::test]
400 async fn new_validated_accepts_git_repo() {
401 let tmpdir = tempfile::tempdir().unwrap();
402 std::process::Command::new("git")
403 .args(["init"])
404 .current_dir(tmpdir.path())
405 .output()
406 .unwrap();
407 let mgr = WorktreeManager::new_validated(tmpdir.path(), None).await;
408 assert!(mgr.is_ok());
409 }
410
411 #[test]
412 fn worktree_path_construction() {
413 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
414 let id = SlotId("slot-0".into());
415 assert_eq!(mgr.worktree_path(&id), PathBuf::from("/tmp/wt/slot-0"));
416 }
417
418 #[test]
419 fn default_base_dir() {
420 let mgr = WorktreeManager::new("/repo", None);
421 let expected = std::env::temp_dir().join("claude-pool").join("worktrees");
422 assert_eq!(mgr.base_dir(), expected);
423 }
424
425 #[tokio::test]
426 async fn clone_preserves_non_local_remote() {
427 let src = tempfile::tempdir().unwrap();
429 std::process::Command::new("git")
430 .args(["init"])
431 .current_dir(src.path())
432 .output()
433 .unwrap();
434 std::process::Command::new("git")
435 .args(["remote", "add", "origin", "git@github.com:user/repo.git"])
436 .current_dir(src.path())
437 .output()
438 .unwrap();
439 std::process::Command::new("git")
441 .args(["commit", "--allow-empty", "-m", "init"])
442 .current_dir(src.path())
443 .output()
444 .unwrap();
445
446 let base = tempfile::tempdir().unwrap();
447 let mgr = WorktreeManager::new(src.path(), Some(base.path().to_path_buf()));
448 let task_id = TaskId("chain-test-remote".into());
449 let clone_path = mgr.create_clone_for_chain(&task_id).await.unwrap();
450
451 let output = std::process::Command::new("git")
453 .args(["remote", "get-url", "origin"])
454 .current_dir(&clone_path)
455 .output()
456 .unwrap();
457 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
458 assert_eq!(url, "git@github.com:user/repo.git");
459
460 mgr.remove_clone(&task_id).await.unwrap();
461 }
462
463 #[test]
464 fn chain_worktree_path_construction() {
465 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
466 let task_id = TaskId("chain-abc123".into());
467 assert_eq!(
468 mgr.chain_worktree_path(&task_id),
469 PathBuf::from("/tmp/wt/chains/chain-abc123")
470 );
471 }
472}