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 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#[derive(Debug, Clone)]
43pub struct WorktreeInfo {
44 pub id: Uuid,
46 pub task_id: Uuid,
48 pub flow_id: Uuid,
50 pub path: PathBuf,
52 pub branch: String,
54 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#[derive(Debug)]
70pub enum WorktreeError {
71 GitError(String),
73 IoError(std::io::Error),
75 NotFound(Uuid),
77 AlreadyExists(Uuid),
79 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
103pub type Result<T> = std::result::Result<T, WorktreeError>;
105
106pub struct WorktreeManager {
108 repo_path: PathBuf,
110 config: WorktreeConfig,
112}
113
114impl WorktreeManager {
115 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 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 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 if let Some(parent) = worktree_path.parent() {
170 std::fs::create_dir_all(parent)?;
171 }
172
173 let base = base_ref.unwrap_or("HEAD");
175 let base_commit = self.get_commit_hash(base)?;
176
177 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 pub fn remove(&self, worktree_path: &Path) -> Result<()> {
210 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 #[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 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 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 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 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 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 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 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 pub fn commit(&self, worktree_path: &Path, message: &str) -> Result<String> {
371 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 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 pub fn repo_path(&self) -> &Path {
398 &self.repo_path
399 }
400
401 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 }