Skip to main content

hivemind/core/
worktree.rs

1//! Worktree management for isolated task execution.
2//!
3//! Worktrees provide isolated git working directories for parallel
4//! task execution without branch conflicts.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use uuid::Uuid;
10
11/// Worktree configuration.
12#[derive(Debug, Clone)]
13pub struct WorktreeConfig {
14    /// Base directory for worktrees.
15    pub base_dir: PathBuf,
16    /// Whether to clean up worktrees on success.
17    pub cleanup_on_success: bool,
18    /// Whether to preserve worktrees on failure.
19    pub preserve_on_failure: bool,
20}
21
22impl Default for WorktreeConfig {
23    fn default() -> Self {
24        let base_dir = std::env::var("HIVEMIND_WORKTREE_DIR").map_or_else(
25            |_| {
26                dirs::home_dir().map_or_else(
27                    || PathBuf::from("hivemind/worktrees"),
28                    |home| home.join("hivemind").join("worktrees"),
29                )
30            },
31            PathBuf::from,
32        );
33        Self {
34            base_dir,
35            cleanup_on_success: true,
36            preserve_on_failure: true,
37        }
38    }
39}
40
41/// Information about a worktree.
42#[derive(Debug, Clone)]
43pub struct WorktreeInfo {
44    /// Worktree ID.
45    pub id: Uuid,
46    /// Task this worktree is for.
47    pub task_id: Uuid,
48    /// Flow this worktree belongs to.
49    pub flow_id: Uuid,
50    /// Path to the worktree.
51    pub path: PathBuf,
52    /// Branch name in the worktree.
53    pub branch: String,
54    /// Base commit the worktree was created from.
55    pub base_commit: String,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct WorktreeStatus {
60    pub flow_id: Uuid,
61    pub task_id: Uuid,
62    pub path: PathBuf,
63    pub is_worktree: bool,
64    pub head_commit: Option<String>,
65    pub branch: Option<String>,
66}
67
68/// Errors that can occur during worktree operations.
69#[derive(Debug)]
70pub enum WorktreeError {
71    /// Git command failed.
72    GitError(String),
73    /// IO error.
74    IoError(std::io::Error),
75    /// Worktree not found.
76    NotFound(Uuid),
77    /// Worktree already exists.
78    AlreadyExists(Uuid),
79    /// Invalid repository path.
80    InvalidRepo(PathBuf),
81}
82
83impl std::fmt::Display for WorktreeError {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        match self {
86            Self::GitError(msg) => write!(f, "Git error: {msg}"),
87            Self::IoError(e) => write!(f, "IO error: {e}"),
88            Self::NotFound(id) => write!(f, "Worktree not found: {id}"),
89            Self::AlreadyExists(id) => write!(f, "Worktree already exists: {id}"),
90            Self::InvalidRepo(path) => write!(f, "Invalid repository: {}", path.display()),
91        }
92    }
93}
94
95impl std::error::Error for WorktreeError {}
96
97impl From<std::io::Error> for WorktreeError {
98    fn from(e: std::io::Error) -> Self {
99        Self::IoError(e)
100    }
101}
102
103/// Result type for worktree operations.
104pub type Result<T> = std::result::Result<T, WorktreeError>;
105
106/// Manager for git worktrees.
107pub struct WorktreeManager {
108    /// Repository root path.
109    repo_path: PathBuf,
110    /// Configuration.
111    config: WorktreeConfig,
112}
113
114impl WorktreeManager {
115    /// Creates a new worktree manager.
116    pub fn new(repo_path: PathBuf, config: WorktreeConfig) -> Result<Self> {
117        let WorktreeConfig {
118            base_dir,
119            cleanup_on_success,
120            preserve_on_failure,
121        } = config;
122
123        // Verify it's a git repository
124        let is_git_repo = Command::new("git")
125            .current_dir(&repo_path)
126            .args(["rev-parse", "--git-dir"])
127            .output()
128            .map(|o| o.status.success())
129            .unwrap_or(false);
130        if !is_git_repo {
131            return Err(WorktreeError::InvalidRepo(repo_path));
132        }
133
134        let base_dir = if base_dir.is_absolute() {
135            base_dir
136        } else {
137            repo_path.join(base_dir)
138        };
139
140        let config = WorktreeConfig {
141            base_dir,
142            cleanup_on_success,
143            preserve_on_failure,
144        };
145
146        Ok(Self { repo_path, config })
147    }
148
149    /// Creates a worktree for a task.
150    pub fn create(
151        &self,
152        flow_id: Uuid,
153        task_id: Uuid,
154        base_ref: Option<&str>,
155    ) -> Result<WorktreeInfo> {
156        let worktree_id = Uuid::new_v4();
157        let branch_name = format!("exec/{flow_id}/{task_id}");
158        let worktree_path = self
159            .config
160            .base_dir
161            .join(flow_id.to_string())
162            .join(task_id.to_string());
163
164        if worktree_path.exists() {
165            return Err(WorktreeError::AlreadyExists(task_id));
166        }
167
168        // Create parent directories
169        if let Some(parent) = worktree_path.parent() {
170            std::fs::create_dir_all(parent)?;
171        }
172
173        // Get base commit
174        let base = base_ref.unwrap_or("HEAD");
175        let base_commit = self.get_commit_hash(base)?;
176
177        // Create worktree with new branch
178        let worktree_path_str = worktree_path.to_str().ok_or_else(|| {
179            WorktreeError::GitError("Worktree path is not valid UTF-8".to_string())
180        })?;
181        let output = Command::new("git")
182            .current_dir(&self.repo_path)
183            .args([
184                "worktree",
185                "add",
186                "-B",
187                &branch_name,
188                worktree_path_str,
189                base,
190            ])
191            .output()?;
192
193        if !output.status.success() {
194            let stderr = String::from_utf8_lossy(&output.stderr);
195            return Err(WorktreeError::GitError(stderr.to_string()));
196        }
197
198        Ok(WorktreeInfo {
199            id: worktree_id,
200            task_id,
201            flow_id,
202            path: worktree_path,
203            branch: branch_name,
204            base_commit,
205        })
206    }
207
208    /// Removes a worktree.
209    pub fn remove(&self, worktree_path: &Path) -> Result<()> {
210        // Remove worktree
211        let output = Command::new("git")
212            .current_dir(&self.repo_path)
213            .args([
214                "worktree",
215                "remove",
216                "--force",
217                worktree_path.to_str().unwrap_or(""),
218            ])
219            .output()?;
220
221        if !output.status.success() {
222            let stderr = String::from_utf8_lossy(&output.stderr);
223            return Err(WorktreeError::GitError(stderr.to_string()));
224        }
225
226        Ok(())
227    }
228
229    /// Returns the default worktree path for a flow/task pair.
230    #[must_use]
231    pub fn path_for(&self, flow_id: Uuid, task_id: Uuid) -> PathBuf {
232        self.config
233            .base_dir
234            .join(flow_id.to_string())
235            .join(task_id.to_string())
236    }
237
238    /// Inspects an existing worktree for a flow/task.
239    pub fn inspect(&self, flow_id: Uuid, task_id: Uuid) -> Result<WorktreeStatus> {
240        let path = self.path_for(flow_id, task_id);
241        if !path.exists() {
242            return Ok(WorktreeStatus {
243                flow_id,
244                task_id,
245                path,
246                is_worktree: false,
247                head_commit: None,
248                branch: None,
249            });
250        }
251
252        let is_worktree = self.is_worktree(&path);
253        let head_commit = if is_worktree {
254            self.worktree_head(&path).ok()
255        } else {
256            None
257        };
258
259        let branch = if is_worktree {
260            let output = Command::new("git")
261                .current_dir(&path)
262                .args(["rev-parse", "--abbrev-ref", "HEAD"])
263                .output();
264            match output {
265                Ok(o) if o.status.success() => {
266                    Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
267                }
268                _ => None,
269            }
270        } else {
271            None
272        };
273
274        Ok(WorktreeStatus {
275            flow_id,
276            task_id,
277            path,
278            is_worktree,
279            head_commit,
280            branch,
281        })
282    }
283
284    /// Lists all worktrees for a flow.
285    pub fn list_for_flow(&self, flow_id: Uuid) -> Result<Vec<PathBuf>> {
286        let flow_dir = self.config.base_dir.join(flow_id.to_string());
287
288        if !flow_dir.exists() {
289            return Ok(Vec::new());
290        }
291
292        let mut worktrees = Vec::new();
293        for entry in std::fs::read_dir(&flow_dir)? {
294            let entry = entry?;
295            if entry.file_type()?.is_dir() {
296                worktrees.push(entry.path());
297            }
298        }
299
300        Ok(worktrees)
301    }
302
303    /// Cleans up all worktrees for a flow.
304    pub fn cleanup_flow(&self, flow_id: Uuid) -> Result<()> {
305        let worktrees = self.list_for_flow(flow_id)?;
306
307        for path in worktrees {
308            self.remove(&path)?;
309        }
310
311        // Remove flow directory
312        let flow_dir = self.config.base_dir.join(flow_id.to_string());
313        if flow_dir.exists() {
314            std::fs::remove_dir_all(&flow_dir)?;
315        }
316
317        Ok(())
318    }
319
320    /// Gets the current commit hash for a ref.
321    fn get_commit_hash(&self, reference: &str) -> Result<String> {
322        let output = Command::new("git")
323            .current_dir(&self.repo_path)
324            .args(["rev-parse", reference])
325            .output()?;
326
327        if !output.status.success() {
328            let stderr = String::from_utf8_lossy(&output.stderr);
329            return Err(WorktreeError::GitError(stderr.to_string()));
330        }
331
332        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
333    }
334
335    /// Checks if a path is a valid worktree.
336    pub fn is_worktree(&self, path: &Path) -> bool {
337        if !path.join(".git").exists() {
338            return false;
339        }
340
341        let output = Command::new("git")
342            .current_dir(path)
343            .args(["rev-parse", "--is-inside-work-tree"])
344            .output();
345
346        match output {
347            Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
348                .trim()
349                .eq_ignore_ascii_case("true"),
350            _ => false,
351        }
352    }
353
354    /// Gets the HEAD commit of a worktree.
355    pub fn worktree_head(&self, worktree_path: &Path) -> Result<String> {
356        let output = Command::new("git")
357            .current_dir(worktree_path)
358            .args(["rev-parse", "HEAD"])
359            .output()?;
360
361        if !output.status.success() {
362            let stderr = String::from_utf8_lossy(&output.stderr);
363            return Err(WorktreeError::GitError(stderr.to_string()));
364        }
365
366        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
367    }
368
369    /// Creates a commit in a worktree.
370    pub fn commit(&self, worktree_path: &Path, message: &str) -> Result<String> {
371        // Stage all changes
372        let output = Command::new("git")
373            .current_dir(worktree_path)
374            .args(["add", "-A"])
375            .output()?;
376
377        if !output.status.success() {
378            let stderr = String::from_utf8_lossy(&output.stderr);
379            return Err(WorktreeError::GitError(stderr.to_string()));
380        }
381
382        // Commit
383        let output = Command::new("git")
384            .current_dir(worktree_path)
385            .args(["commit", "-m", message, "--allow-empty"])
386            .output()?;
387
388        if !output.status.success() {
389            let stderr = String::from_utf8_lossy(&output.stderr);
390            return Err(WorktreeError::GitError(stderr.to_string()));
391        }
392
393        self.worktree_head(worktree_path)
394    }
395
396    /// Returns the repository path.
397    pub fn repo_path(&self) -> &Path {
398        &self.repo_path
399    }
400
401    /// Returns the config.
402    pub fn config(&self) -> &WorktreeConfig {
403        &self.config
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use std::process::Command;
411    use tempfile::tempdir;
412
413    fn init_git_repo(repo_dir: &Path) {
414        std::fs::create_dir_all(repo_dir).expect("create repo dir");
415
416        let out = Command::new("git")
417            .args(["init"])
418            .current_dir(repo_dir)
419            .output()
420            .expect("git init");
421        assert!(out.status.success(), "git init failed");
422
423        let out = Command::new("git")
424            .args(["config", "user.name", "Hivemind"])
425            .current_dir(repo_dir)
426            .output()
427            .expect("git config user.name");
428        assert!(out.status.success(), "git config user.name failed");
429
430        let out = Command::new("git")
431            .args(["config", "user.email", "hivemind@example.com"])
432            .current_dir(repo_dir)
433            .output()
434            .expect("git config user.email");
435        assert!(out.status.success(), "git config user.email failed");
436
437        std::fs::write(repo_dir.join("README.md"), "test\n").expect("write file");
438
439        let out = Command::new("git")
440            .args(["add", "."])
441            .current_dir(repo_dir)
442            .output()
443            .expect("git add");
444        assert!(out.status.success(), "git add failed");
445
446        let out = Command::new("git")
447            .args(["commit", "-m", "init"])
448            .current_dir(repo_dir)
449            .output()
450            .expect("git commit");
451        assert!(out.status.success(), "git commit failed");
452    }
453
454    #[test]
455    fn worktree_config_default() {
456        let config = WorktreeConfig::default();
457        assert!(config.cleanup_on_success);
458        assert!(config.preserve_on_failure);
459    }
460
461    #[test]
462    fn worktree_info_creation() {
463        let info = WorktreeInfo {
464            id: Uuid::new_v4(),
465            task_id: Uuid::new_v4(),
466            flow_id: Uuid::new_v4(),
467            path: PathBuf::from("/tmp/test"),
468            branch: "test-branch".to_string(),
469            base_commit: "abc123".to_string(),
470        };
471
472        assert!(!info.branch.is_empty());
473    }
474
475    #[test]
476    fn invalid_repo_detection() {
477        let result = WorktreeManager::new(
478            PathBuf::from("/nonexistent/path"),
479            WorktreeConfig::default(),
480        );
481
482        assert!(result.is_err());
483    }
484
485    #[test]
486    fn create_inspect_list_commit_and_cleanup() {
487        let tmp = tempdir().expect("tempdir");
488        let repo_dir = tmp.path().join("repo");
489        init_git_repo(&repo_dir);
490
491        let manager = WorktreeManager::new(
492            repo_dir,
493            WorktreeConfig {
494                base_dir: tmp.path().join("worktrees"),
495                cleanup_on_success: true,
496                preserve_on_failure: true,
497            },
498        )
499        .expect("worktree manager");
500
501        let flow_id = Uuid::new_v4();
502        let task_id = Uuid::new_v4();
503        let info = manager
504            .create(flow_id, task_id, None)
505            .expect("create worktree");
506        assert!(info.path.exists());
507        assert!(manager.is_worktree(&info.path));
508
509        let status = manager.inspect(flow_id, task_id).expect("inspect");
510        assert!(status.is_worktree);
511        assert_eq!(status.flow_id, flow_id);
512        assert_eq!(status.task_id, task_id);
513        assert!(status.head_commit.is_some());
514
515        let listed = manager.list_for_flow(flow_id).expect("list");
516        assert_eq!(listed.len(), 1);
517        assert_eq!(listed[0], info.path);
518
519        std::fs::write(info.path.join("file.txt"), "hello\n").expect("write file");
520        let head = manager.commit(&info.path, "commit").expect("commit");
521        assert!(!head.trim().is_empty());
522
523        manager.cleanup_flow(flow_id).expect("cleanup");
524        assert!(!info.path.exists());
525    }
526
527    // Note: Full worktree tests require a real git repository
528    // and are better suited for integration tests
529}