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