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
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}