Skip to main content

claude_pool/
worktree.rs

1//! Git worktree isolation for parallel slots.
2//!
3//! When multiple slots operate on the same repository, they need
4//! isolated working directories to avoid stepping on each other's
5//! git state. This module manages git worktree creation and cleanup.
6
7use std::path::{Path, PathBuf};
8
9use crate::error::{Error, Result};
10use crate::types::{SlotId, TaskId};
11
12/// Manages git worktrees for pool slots.
13///
14/// When dropped, the manager attempts best-effort cleanup of all worktrees
15/// under its base directory. This ensures stale worktrees are removed even
16/// if the pool panics or exits without calling [`cleanup_all`][Self::cleanup_all].
17#[derive(Debug)]
18pub struct WorktreeManager {
19    /// Root directory for worktrees (e.g. `/tmp/claude-pool/worktrees`).
20    base_dir: PathBuf,
21    /// Source repository path.
22    repo_dir: PathBuf,
23    /// Slot IDs currently tracked (for cleanup on drop).
24    tracked_slots: std::sync::Mutex<Vec<SlotId>>,
25    /// Chain task IDs currently tracked (for cleanup on drop).
26    tracked_chains: std::sync::Mutex<Vec<TaskId>>,
27}
28
29impl WorktreeManager {
30    /// Create a new worktree manager.
31    ///
32    /// - `repo_dir`: The source repository to create worktrees from.
33    /// - `base_dir`: Directory where worktrees will be created. If `None`,
34    ///   uses a temp directory under the system temp dir.
35    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    /// Create a worktree manager after verifying the repo directory is a git repository.
48    ///
49    /// Returns an error if `repo_dir` is not inside a git working tree.
50    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    /// Track a slot ID for cleanup on drop.
78    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    /// Untrack a slot ID (after explicit removal).
87    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    /// Track a chain task ID for cleanup on drop.
94    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    /// Untrack a chain task ID (after explicit removal).
103    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    /// Create a worktree for a slot.
110    ///
111    /// Creates a git worktree at `{base_dir}/{slot_id}` branched from
112    /// the current HEAD.
113    pub async fn create(&self, slot_id: &SlotId) -> Result<PathBuf> {
114        let worktree_path = self.base_dir.join(&slot_id.0);
115
116        // Ensure base directory exists.
117        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        // Remove existing worktree if it exists (stale from previous run).
122        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    /// Remove a slot's worktree and its branch.
158    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                // Fall back to manual removal.
182                let _ = tokio::fs::remove_dir_all(&worktree_path).await;
183            }
184        }
185
186        // Clean up the branch.
187        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    /// Remove all worktrees managed by this pool.
205    pub async fn cleanup_all(&self, slot_ids: &[SlotId]) -> Result<()> {
206        for id in slot_ids {
207            self.remove(id).await?;
208        }
209
210        // Prune stale worktree references.
211        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    /// Get the worktree path for a slot (may not exist yet).
221    pub fn worktree_path(&self, slot_id: &SlotId) -> PathBuf {
222        self.base_dir.join(&slot_id.0)
223    }
224
225    /// Get the base directory for all worktrees.
226    pub fn base_dir(&self) -> &Path {
227        &self.base_dir
228    }
229
230    /// Get the source repository directory.
231    pub fn repo_dir(&self) -> &Path {
232        &self.repo_dir
233    }
234
235    /// Create a worktree for a chain execution.
236    ///
237    /// Creates a git worktree at `{base_dir}/chains/{task_id}` branched from
238    /// the current HEAD.
239    pub async fn create_for_chain(&self, task_id: &TaskId) -> Result<PathBuf> {
240        let worktree_path = self.chain_worktree_path(task_id);
241
242        // Ensure chains directory exists.
243        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        // Remove existing worktree if it exists (stale from previous run).
249        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    /// Remove a chain's worktree and its branch.
287    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        // Clean up the branch.
315        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    /// Get the worktree path for a chain (may not exist yet).
333    pub fn chain_worktree_path(&self, task_id: &TaskId) -> PathBuf {
334        self.base_dir.join("chains").join(&task_id.0)
335    }
336
337    /// Create a full clone for a chain execution using `git clone --local --shared`.
338    ///
339    /// Creates a clone at `{base_dir}/clones/{task_id}` with no shared .git directory.
340    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        // Preserve the GitHub remote URL from the source repo so that
372        // tools like `gh pr create` work inside the clone (#152).
373        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            // Only override if the source has a non-local remote (i.e. GitHub URL).
385            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    /// Remove a chain's clone directory.
414    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    /// Get the clone path for a chain (may not exist yet).
431    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        // Best-effort synchronous cleanup using blocking git commands.
460        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                // Fall back to manual removal if git worktree remove fails.
474                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        // Prune stale worktree references.
510        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    /// Initialize a git repo with a dummy user config and an initial commit.
522    /// This works in CI where no global git identity is configured.
523    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        // Only needs git init, no commit required for validation.
562        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        // Set up a source repo with a non-local origin.
588        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        // Verify the clone's origin points to GitHub, not the local path.
602        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            // mgr is dropped here, triggering cleanup
637        }
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            // mgr is dropped here
662        }
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        // After explicit remove, tracked_slots should be empty.
683        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}