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#[derive(Debug)]
14pub struct WorktreeManager {
15    /// Root directory for worktrees (e.g. `/tmp/claude-pool/worktrees`).
16    base_dir: PathBuf,
17    /// Source repository path.
18    repo_dir: PathBuf,
19}
20
21impl WorktreeManager {
22    /// Create a new worktree manager.
23    ///
24    /// - `repo_dir`: The source repository to create worktrees from.
25    /// - `base_dir`: Directory where worktrees will be created. If `None`,
26    ///   uses a temp directory under the system temp dir.
27    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    /// Create a worktree manager after verifying the repo directory is a git repository.
35    ///
36    /// Returns an error if `repo_dir` is not inside a git working tree.
37    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    /// Create a worktree for a slot.
65    ///
66    /// Creates a git worktree at `{base_dir}/{slot_id}` branched from
67    /// the current HEAD.
68    pub async fn create(&self, slot_id: &SlotId) -> Result<PathBuf> {
69        let worktree_path = self.base_dir.join(&slot_id.0);
70
71        // Ensure base directory exists.
72        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        // Remove existing worktree if it exists (stale from previous run).
77        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    /// Remove a slot's worktree and its branch.
111    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                // Fall back to manual removal.
135                let _ = tokio::fs::remove_dir_all(&worktree_path).await;
136            }
137        }
138
139        // Clean up the branch.
140        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    /// Remove all worktrees managed by this pool.
156    pub async fn cleanup_all(&self, slot_ids: &[SlotId]) -> Result<()> {
157        for id in slot_ids {
158            self.remove(id).await?;
159        }
160
161        // Prune stale worktree references.
162        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    /// Get the worktree path for a slot (may not exist yet).
172    pub fn worktree_path(&self, slot_id: &SlotId) -> PathBuf {
173        self.base_dir.join(&slot_id.0)
174    }
175
176    /// Get the base directory for all worktrees.
177    pub fn base_dir(&self) -> &Path {
178        &self.base_dir
179    }
180
181    /// Get the source repository directory.
182    pub fn repo_dir(&self) -> &Path {
183        &self.repo_dir
184    }
185
186    /// Create a worktree for a chain execution.
187    ///
188    /// Creates a git worktree at `{base_dir}/chains/{task_id}` branched from
189    /// the current HEAD.
190    pub async fn create_for_chain(&self, task_id: &TaskId) -> Result<PathBuf> {
191        let worktree_path = self.chain_worktree_path(task_id);
192
193        // Ensure chains directory exists.
194        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        // Remove existing worktree if it exists (stale from previous run).
200        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    /// Remove a chain's worktree and its branch.
236    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        // Clean up the branch.
264        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    /// Get the worktree path for a chain (may not exist yet).
280    pub fn chain_worktree_path(&self, task_id: &TaskId) -> PathBuf {
281        self.base_dir.join("chains").join(&task_id.0)
282    }
283
284    /// Create a full clone for a chain execution using `git clone --local --shared`.
285    ///
286    /// Creates a clone at `{base_dir}/clones/{task_id}` with no shared .git directory.
287    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        // Preserve the GitHub remote URL from the source repo so that
319        // tools like `gh pr create` work inside the clone (#152).
320        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            // Only override if the source has a non-local remote (i.e. GitHub URL).
332            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    /// Remove a chain's clone directory.
361    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    /// Get the clone path for a chain (may not exist yet).
378    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        // Set up a source repo with a non-local origin.
428        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        // Need at least one commit for clone to work.
440        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        // Verify the clone's origin points to GitHub, not the local path.
452        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}