1use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use crate::types::{SlotId, TaskId};
11
12#[derive(Debug)]
18pub struct WorktreeManager {
19 base_dir: PathBuf,
21 repo_dir: PathBuf,
23 tracked_slots: std::sync::Mutex<Vec<SlotId>>,
25 tracked_chains: std::sync::Mutex<Vec<TaskId>>,
27}
28
29impl WorktreeManager {
30 pub fn new(repo_dir: impl Into<PathBuf>, base_dir: Option<PathBuf>) -> Self {
36 let repo_dir = repo_dir.into();
37 let base_dir =
38 base_dir.unwrap_or_else(|| std::env::temp_dir().join("claude-pool").join("worktrees"));
39 Self {
40 base_dir,
41 repo_dir,
42 tracked_slots: std::sync::Mutex::new(Vec::new()),
43 tracked_chains: std::sync::Mutex::new(Vec::new()),
44 }
45 }
46
47 pub async fn new_validated(
51 repo_dir: impl Into<PathBuf>,
52 base_dir: Option<PathBuf>,
53 ) -> Result<Self> {
54 let repo_dir = repo_dir.into();
55 let output = tokio::process::Command::new("git")
56 .args(["rev-parse", "--is-inside-work-tree"])
57 .current_dir(&repo_dir)
58 .output()
59 .await
60 .map_err(|e| {
61 Error::Store(format!(
62 "failed to check git repo at {}: {e}",
63 repo_dir.display()
64 ))
65 })?;
66
67 if !output.status.success() {
68 return Err(Error::Store(format!(
69 "worktree isolation requires a git repository, but {} is not inside a git work tree",
70 repo_dir.display()
71 )));
72 }
73
74 Ok(Self::new(repo_dir, base_dir))
75 }
76
77 fn track_slot(&self, slot_id: &SlotId) {
79 if let Ok(mut slots) = self.tracked_slots.lock()
80 && !slots.iter().any(|s| s.0 == slot_id.0)
81 {
82 slots.push(slot_id.clone());
83 }
84 }
85
86 fn untrack_slot(&self, slot_id: &SlotId) {
88 if let Ok(mut slots) = self.tracked_slots.lock() {
89 slots.retain(|s| s.0 != slot_id.0);
90 }
91 }
92
93 fn track_chain(&self, task_id: &TaskId) {
95 if let Ok(mut chains) = self.tracked_chains.lock()
96 && !chains.iter().any(|t| t.0 == task_id.0)
97 {
98 chains.push(task_id.clone());
99 }
100 }
101
102 fn untrack_chain(&self, task_id: &TaskId) {
104 if let Ok(mut chains) = self.tracked_chains.lock() {
105 chains.retain(|t| t.0 != task_id.0);
106 }
107 }
108
109 pub async fn create(&self, slot_id: &SlotId) -> Result<PathBuf> {
114 let worktree_path = self.base_dir.join(&slot_id.0);
115
116 tokio::fs::create_dir_all(&self.base_dir)
118 .await
119 .map_err(|e| Error::Store(format!("failed to create worktree base dir: {e}")))?;
120
121 if worktree_path.exists() {
123 self.remove(slot_id).await?;
124 }
125
126 let branch_name = format!("claude-pool/{}", slot_id.0);
127 let output = tokio::process::Command::new("git")
128 .args([
129 "worktree",
130 "add",
131 "-b",
132 &branch_name,
133 worktree_path.to_str().unwrap_or_default(),
134 "HEAD",
135 ])
136 .current_dir(&self.repo_dir)
137 .output()
138 .await
139 .map_err(|e| Error::Store(format!("failed to create git worktree: {e}")))?;
140
141 if !output.status.success() {
142 let stderr = String::from_utf8_lossy(&output.stderr);
143 return Err(Error::Store(format!("git worktree add failed: {stderr}")));
144 }
145
146 self.track_slot(slot_id);
147
148 tracing::info!(
149 slot_id = %slot_id.0,
150 path = %worktree_path.display(),
151 "created git worktree"
152 );
153
154 Ok(worktree_path)
155 }
156
157 pub async fn remove(&self, slot_id: &SlotId) -> Result<()> {
159 let worktree_path = self.base_dir.join(&slot_id.0);
160
161 if worktree_path.exists() {
162 let output = tokio::process::Command::new("git")
163 .args([
164 "worktree",
165 "remove",
166 "--force",
167 worktree_path.to_str().unwrap_or_default(),
168 ])
169 .current_dir(&self.repo_dir)
170 .output()
171 .await
172 .map_err(|e| Error::Store(format!("failed to remove git worktree: {e}")))?;
173
174 if !output.status.success() {
175 let stderr = String::from_utf8_lossy(&output.stderr);
176 tracing::warn!(
177 slot_id = %slot_id.0,
178 error = %stderr,
179 "failed to remove worktree, cleaning up manually"
180 );
181 let _ = tokio::fs::remove_dir_all(&worktree_path).await;
183 }
184 }
185
186 let branch_name = format!("claude-pool/{}", slot_id.0);
188 let _ = tokio::process::Command::new("git")
189 .args(["branch", "-D", &branch_name])
190 .current_dir(&self.repo_dir)
191 .output()
192 .await;
193
194 self.untrack_slot(slot_id);
195
196 tracing::debug!(
197 slot_id = %slot_id.0,
198 "removed git worktree"
199 );
200
201 Ok(())
202 }
203
204 pub async fn cleanup_all(&self, slot_ids: &[SlotId]) -> Result<()> {
206 for id in slot_ids {
207 self.remove(id).await?;
208 }
209
210 let _ = tokio::process::Command::new("git")
212 .args(["worktree", "prune"])
213 .current_dir(&self.repo_dir)
214 .output()
215 .await;
216
217 Ok(())
218 }
219
220 pub fn worktree_path(&self, slot_id: &SlotId) -> PathBuf {
222 self.base_dir.join(&slot_id.0)
223 }
224
225 pub fn base_dir(&self) -> &Path {
227 &self.base_dir
228 }
229
230 pub fn repo_dir(&self) -> &Path {
232 &self.repo_dir
233 }
234
235 pub async fn create_for_chain(&self, task_id: &TaskId) -> Result<PathBuf> {
240 let worktree_path = self.chain_worktree_path(task_id);
241
242 let chains_dir = self.base_dir.join("chains");
244 tokio::fs::create_dir_all(&chains_dir)
245 .await
246 .map_err(|e| Error::Store(format!("failed to create chains dir: {e}")))?;
247
248 if worktree_path.exists() {
250 self.remove_chain(task_id).await?;
251 }
252
253 let branch_name = format!("claude-pool/chain/{}", task_id.0);
254 let output = tokio::process::Command::new("git")
255 .args([
256 "worktree",
257 "add",
258 "-b",
259 &branch_name,
260 worktree_path.to_str().unwrap_or_default(),
261 "HEAD",
262 ])
263 .current_dir(&self.repo_dir)
264 .output()
265 .await
266 .map_err(|e| Error::Store(format!("failed to create chain worktree: {e}")))?;
267
268 if !output.status.success() {
269 let stderr = String::from_utf8_lossy(&output.stderr);
270 return Err(Error::Store(format!(
271 "git worktree add failed for chain: {stderr}"
272 )));
273 }
274
275 self.track_chain(task_id);
276
277 tracing::info!(
278 task_id = %task_id.0,
279 path = %worktree_path.display(),
280 "created chain worktree"
281 );
282
283 Ok(worktree_path)
284 }
285
286 pub async fn remove_chain(&self, task_id: &TaskId) -> Result<()> {
288 let worktree_path = self.chain_worktree_path(task_id);
289
290 if worktree_path.exists() {
291 let output = tokio::process::Command::new("git")
292 .args([
293 "worktree",
294 "remove",
295 "--force",
296 worktree_path.to_str().unwrap_or_default(),
297 ])
298 .current_dir(&self.repo_dir)
299 .output()
300 .await
301 .map_err(|e| Error::Store(format!("failed to remove chain worktree: {e}")))?;
302
303 if !output.status.success() {
304 let stderr = String::from_utf8_lossy(&output.stderr);
305 tracing::warn!(
306 task_id = %task_id.0,
307 error = %stderr,
308 "failed to remove chain worktree, cleaning up manually"
309 );
310 let _ = tokio::fs::remove_dir_all(&worktree_path).await;
311 }
312 }
313
314 let branch_name = format!("claude-pool/chain/{}", task_id.0);
316 let _ = tokio::process::Command::new("git")
317 .args(["branch", "-D", &branch_name])
318 .current_dir(&self.repo_dir)
319 .output()
320 .await;
321
322 self.untrack_chain(task_id);
323
324 tracing::debug!(
325 task_id = %task_id.0,
326 "removed chain worktree"
327 );
328
329 Ok(())
330 }
331
332 pub fn chain_worktree_path(&self, task_id: &TaskId) -> PathBuf {
334 self.base_dir.join("chains").join(&task_id.0)
335 }
336
337 pub async fn create_clone_for_chain(&self, task_id: &TaskId) -> Result<PathBuf> {
341 let clone_path = self.clone_path(task_id);
342
343 let clones_dir = self.base_dir.join("clones");
344 tokio::fs::create_dir_all(&clones_dir)
345 .await
346 .map_err(|e| Error::Store(format!("failed to create clones dir: {e}")))?;
347
348 if clone_path.exists() {
349 self.remove_clone(task_id).await?;
350 }
351
352 let output = tokio::process::Command::new("git")
353 .args([
354 "clone",
355 "--local",
356 "--shared",
357 self.repo_dir.to_str().unwrap_or_default(),
358 clone_path.to_str().unwrap_or_default(),
359 ])
360 .output()
361 .await
362 .map_err(|e| Error::Store(format!("failed to create chain clone: {e}")))?;
363
364 if !output.status.success() {
365 let stderr = String::from_utf8_lossy(&output.stderr);
366 return Err(Error::Store(format!(
367 "git clone failed for chain: {stderr}"
368 )));
369 }
370
371 let remote_output = tokio::process::Command::new("git")
374 .args(["remote", "get-url", "origin"])
375 .current_dir(&self.repo_dir)
376 .output()
377 .await
378 .map_err(|e| Error::Store(format!("failed to get origin URL: {e}")))?;
379
380 if remote_output.status.success() {
381 let url = String::from_utf8_lossy(&remote_output.stdout)
382 .trim()
383 .to_string();
384 if !url.is_empty() && !url.starts_with('/') {
386 let set_output = tokio::process::Command::new("git")
387 .args(["remote", "set-url", "origin", &url])
388 .current_dir(&clone_path)
389 .output()
390 .await
391 .map_err(|e| Error::Store(format!("failed to set origin URL in clone: {e}")))?;
392
393 if !set_output.status.success() {
394 let stderr = String::from_utf8_lossy(&set_output.stderr);
395 tracing::warn!(
396 task_id = %task_id.0,
397 error = %stderr,
398 "failed to set origin URL in clone"
399 );
400 }
401 }
402 }
403
404 tracing::info!(
405 task_id = %task_id.0,
406 path = %clone_path.display(),
407 "created chain clone"
408 );
409
410 Ok(clone_path)
411 }
412
413 pub async fn remove_clone(&self, task_id: &TaskId) -> Result<()> {
415 let clone_path = self.clone_path(task_id);
416
417 if clone_path.exists() {
418 tokio::fs::remove_dir_all(&clone_path).await.map_err(|e| {
419 Error::Store(format!(
420 "failed to remove chain clone at {}: {e}",
421 clone_path.display()
422 ))
423 })?;
424 }
425
426 tracing::debug!(task_id = %task_id.0, "removed chain clone");
427 Ok(())
428 }
429
430 pub fn clone_path(&self, task_id: &TaskId) -> PathBuf {
432 self.base_dir.join("clones").join(&task_id.0)
433 }
434}
435
436impl Drop for WorktreeManager {
437 fn drop(&mut self) {
438 let slots: Vec<SlotId> = self
439 .tracked_slots
440 .lock()
441 .map(|s| s.clone())
442 .unwrap_or_default();
443 let chains: Vec<TaskId> = self
444 .tracked_chains
445 .lock()
446 .map(|c| c.clone())
447 .unwrap_or_default();
448
449 if slots.is_empty() && chains.is_empty() {
450 return;
451 }
452
453 tracing::info!(
454 slots = slots.len(),
455 chains = chains.len(),
456 "cleaning up worktrees on drop"
457 );
458
459 for slot_id in &slots {
461 let worktree_path = self.base_dir.join(&slot_id.0);
462 if worktree_path.exists() {
463 let _ = std::process::Command::new("git")
464 .args([
465 "worktree",
466 "remove",
467 "--force",
468 worktree_path.to_str().unwrap_or_default(),
469 ])
470 .current_dir(&self.repo_dir)
471 .output();
472
473 if worktree_path.exists() {
475 let _ = std::fs::remove_dir_all(&worktree_path);
476 }
477 }
478 let branch_name = format!("claude-pool/{}", slot_id.0);
479 let _ = std::process::Command::new("git")
480 .args(["branch", "-D", &branch_name])
481 .current_dir(&self.repo_dir)
482 .output();
483 }
484
485 for task_id in &chains {
486 let worktree_path = self.base_dir.join("chains").join(&task_id.0);
487 if worktree_path.exists() {
488 let _ = std::process::Command::new("git")
489 .args([
490 "worktree",
491 "remove",
492 "--force",
493 worktree_path.to_str().unwrap_or_default(),
494 ])
495 .current_dir(&self.repo_dir)
496 .output();
497
498 if worktree_path.exists() {
499 let _ = std::fs::remove_dir_all(&worktree_path);
500 }
501 }
502 let branch_name = format!("claude-pool/chain/{}", task_id.0);
503 let _ = std::process::Command::new("git")
504 .args(["branch", "-D", &branch_name])
505 .current_dir(&self.repo_dir)
506 .output();
507 }
508
509 let _ = std::process::Command::new("git")
511 .args(["worktree", "prune"])
512 .current_dir(&self.repo_dir)
513 .output();
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn init_test_repo(path: &std::path::Path) {
524 std::process::Command::new("git")
525 .args(["init"])
526 .current_dir(path)
527 .output()
528 .unwrap();
529 std::process::Command::new("git")
530 .args(["config", "user.email", "test@test.com"])
531 .current_dir(path)
532 .output()
533 .unwrap();
534 std::process::Command::new("git")
535 .args(["config", "user.name", "Test"])
536 .current_dir(path)
537 .output()
538 .unwrap();
539 std::process::Command::new("git")
540 .args(["commit", "--allow-empty", "-m", "init"])
541 .current_dir(path)
542 .output()
543 .unwrap();
544 }
545
546 #[tokio::test]
547 async fn new_validated_rejects_non_repo() {
548 let tmpdir = tempfile::tempdir().unwrap();
549 let result = WorktreeManager::new_validated(tmpdir.path(), None).await;
550 assert!(result.is_err());
551 let err = result.unwrap_err().to_string();
552 assert!(
553 err.contains("not inside a git work tree"),
554 "expected git work tree error, got: {err}"
555 );
556 }
557
558 #[tokio::test]
559 async fn new_validated_accepts_git_repo() {
560 let tmpdir = tempfile::tempdir().unwrap();
561 std::process::Command::new("git")
563 .args(["init"])
564 .current_dir(tmpdir.path())
565 .output()
566 .unwrap();
567 let mgr = WorktreeManager::new_validated(tmpdir.path(), None).await;
568 assert!(mgr.is_ok());
569 }
570
571 #[test]
572 fn worktree_path_construction() {
573 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
574 let id = SlotId("slot-0".into());
575 assert_eq!(mgr.worktree_path(&id), PathBuf::from("/tmp/wt/slot-0"));
576 }
577
578 #[test]
579 fn default_base_dir() {
580 let mgr = WorktreeManager::new("/repo", None);
581 let expected = std::env::temp_dir().join("claude-pool").join("worktrees");
582 assert_eq!(mgr.base_dir(), expected);
583 }
584
585 #[tokio::test]
586 async fn clone_preserves_non_local_remote() {
587 let src = tempfile::tempdir().unwrap();
589 init_test_repo(src.path());
590 std::process::Command::new("git")
591 .args(["remote", "add", "origin", "git@github.com:user/repo.git"])
592 .current_dir(src.path())
593 .output()
594 .unwrap();
595
596 let base = tempfile::tempdir().unwrap();
597 let mgr = WorktreeManager::new(src.path(), Some(base.path().to_path_buf()));
598 let task_id = TaskId("chain-test-remote".into());
599 let clone_path = mgr.create_clone_for_chain(&task_id).await.unwrap();
600
601 let output = std::process::Command::new("git")
603 .args(["remote", "get-url", "origin"])
604 .current_dir(&clone_path)
605 .output()
606 .unwrap();
607 let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
608 assert_eq!(url, "git@github.com:user/repo.git");
609
610 mgr.remove_clone(&task_id).await.unwrap();
611 }
612
613 #[test]
614 fn chain_worktree_path_construction() {
615 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
616 let task_id = TaskId("chain-abc123".into());
617 assert_eq!(
618 mgr.chain_worktree_path(&task_id),
619 PathBuf::from("/tmp/wt/chains/chain-abc123")
620 );
621 }
622
623 #[tokio::test]
624 async fn drop_cleans_up_slot_worktrees() {
625 let src = tempfile::tempdir().unwrap();
626 init_test_repo(src.path());
627
628 let base = tempfile::tempdir().unwrap();
629 let slot_id = SlotId("drop-test-slot".into());
630 let worktree_path;
631
632 {
633 let mgr = WorktreeManager::new(src.path(), Some(base.path().to_path_buf()));
634 worktree_path = mgr.create(&slot_id).await.unwrap();
635 assert!(worktree_path.exists(), "worktree should exist after create");
636 }
638
639 assert!(
640 !worktree_path.exists(),
641 "worktree should be cleaned up after drop"
642 );
643 }
644
645 #[tokio::test]
646 async fn drop_cleans_up_chain_worktrees() {
647 let src = tempfile::tempdir().unwrap();
648 init_test_repo(src.path());
649
650 let base = tempfile::tempdir().unwrap();
651 let task_id = TaskId("drop-test-chain".into());
652 let worktree_path;
653
654 {
655 let mgr = WorktreeManager::new(src.path(), Some(base.path().to_path_buf()));
656 worktree_path = mgr.create_for_chain(&task_id).await.unwrap();
657 assert!(
658 worktree_path.exists(),
659 "chain worktree should exist after create"
660 );
661 }
663
664 assert!(
665 !worktree_path.exists(),
666 "chain worktree should be cleaned up after drop"
667 );
668 }
669
670 #[tokio::test]
671 async fn explicit_remove_prevents_double_cleanup() {
672 let src = tempfile::tempdir().unwrap();
673 init_test_repo(src.path());
674
675 let base = tempfile::tempdir().unwrap();
676 let slot_id = SlotId("explicit-remove-test".into());
677
678 let mgr = WorktreeManager::new(src.path(), Some(base.path().to_path_buf()));
679 let _path = mgr.create(&slot_id).await.unwrap();
680 mgr.remove(&slot_id).await.unwrap();
681
682 let tracked = mgr.tracked_slots.lock().unwrap();
684 assert!(
685 tracked.is_empty(),
686 "slot should be untracked after explicit remove"
687 );
688 }
689
690 #[test]
691 fn tracking_is_idempotent() {
692 let mgr = WorktreeManager::new("/repo", Some(PathBuf::from("/tmp/wt")));
693 let id = SlotId("dup-test".into());
694 mgr.track_slot(&id);
695 mgr.track_slot(&id);
696 let tracked = mgr.tracked_slots.lock().unwrap();
697 assert_eq!(tracked.len(), 1, "duplicate tracking should be prevented");
698 }
699}