1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use std::process::Command;
9use uuid::Uuid;
10
11#[derive(Debug, Clone)]
13pub struct WorktreeConfig {
14 pub base_dir: PathBuf,
16 pub cleanup_on_success: bool,
18 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#[derive(Debug, Clone)]
34pub struct WorktreeInfo {
35 pub id: Uuid,
37 pub task_id: Uuid,
39 pub flow_id: Uuid,
41 pub path: PathBuf,
43 pub branch: String,
45 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#[derive(Debug)]
61pub enum WorktreeError {
62 GitError(String),
64 IoError(std::io::Error),
66 NotFound(Uuid),
68 AlreadyExists(Uuid),
70 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
94pub type Result<T> = std::result::Result<T, WorktreeError>;
96
97pub struct WorktreeManager {
99 repo_path: PathBuf,
101 config: WorktreeConfig,
103}
104
105impl WorktreeManager {
106 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 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 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 if let Some(parent) = worktree_path.parent() {
161 std::fs::create_dir_all(parent)?;
162 }
163
164 let base = base_ref.unwrap_or("HEAD");
166 let base_commit = self.get_commit_hash(base)?;
167
168 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 pub fn remove(&self, worktree_path: &Path) -> Result<()> {
201 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 #[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 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 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 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 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 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 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 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 pub fn commit(&self, worktree_path: &Path, message: &str) -> Result<String> {
362 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 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 pub fn repo_path(&self) -> &Path {
389 &self.repo_path
390 }
391
392 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 }