1use anyhow::{anyhow, Result};
7use std::path::PathBuf;
8use std::process::Command;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum MergeResult {
13 Success,
15 Conflict { files: Vec<String> },
17 NothingToCommit,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct WorktreeInfo {
24 pub main_path: PathBuf,
26 pub worktree_path: PathBuf,
28 pub branch: String,
30}
31
32#[derive(Debug)]
34struct WorktreeEntry {
35 path: PathBuf,
36 branch: Option<String>,
37}
38
39fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
52 let mut entries = Vec::new();
53 let mut current_path: Option<PathBuf> = None;
54 let mut current_branch: Option<String> = None;
55
56 for line in output.lines() {
57 if let Some(path) = line.strip_prefix("worktree ") {
58 if let Some(path) = current_path.take() {
60 entries.push(WorktreeEntry {
61 path,
62 branch: current_branch.take(),
63 });
64 }
65 current_path = Some(PathBuf::from(path));
66 current_branch = None;
67 } else if let Some(branch_ref) = line.strip_prefix("branch ") {
68 current_branch = Some(
70 branch_ref
71 .strip_prefix("refs/heads/")
72 .unwrap_or(branch_ref)
73 .to_string(),
74 );
75 }
76 }
78
79 if let Some(path) = current_path {
81 entries.push(WorktreeEntry {
82 path,
83 branch: current_branch,
84 });
85 }
86
87 entries
88}
89
90pub fn detect_worktree(cwd: &std::path::Path) -> Result<Option<WorktreeInfo>> {
101 let output = Command::new("git")
103 .args(["worktree", "list", "--porcelain"])
104 .current_dir(cwd)
105 .output();
106
107 let output = match output {
108 Ok(o) => o,
109 Err(_) => return Ok(None), };
111
112 if !output.status.success() {
113 return Ok(None);
115 }
116
117 let stdout = String::from_utf8_lossy(&output.stdout);
118 let entries = parse_worktree_list(&stdout);
119
120 if entries.is_empty() {
121 return Ok(None);
122 }
123
124 let main_entry = &entries[0];
126 let main_path = &main_entry.path;
127
128 let mut current_entry: Option<&WorktreeEntry> = None;
131 for entry in &entries {
132 if cwd.starts_with(&entry.path) {
133 match current_entry {
134 None => current_entry = Some(entry),
135 Some(prev) if entry.path.as_os_str().len() > prev.path.as_os_str().len() => {
136 current_entry = Some(entry)
137 }
138 _ => {}
139 }
140 }
141 }
142
143 let current_entry = match current_entry {
144 Some(e) => e,
145 None => return Ok(None), };
147
148 if current_entry.path == *main_path {
150 return Ok(None);
151 }
152
153 Ok(Some(WorktreeInfo {
155 main_path: main_path.clone(),
156 worktree_path: current_entry.path.clone(),
157 branch: current_entry.branch.clone().unwrap_or_default(),
158 }))
159}
160
161pub fn commit_worktree_paths(
169 cwd: &std::path::Path,
170 message: &str,
171 paths: &[String],
172) -> Result<bool> {
173 commit_worktree_paths_preserve_index(cwd, message, paths)
174}
175
176pub fn commit_worktree_paths_preserve_index(
178 cwd: &std::path::Path,
179 message: &str,
180 paths: &[String],
181) -> Result<bool> {
182 if paths.is_empty() {
183 return Ok(false);
184 }
185
186 let repo_root = git_stdout(cwd, &["rev-parse", "--show-toplevel"])?;
187 let index_path = std::env::temp_dir().join(format!(
188 "mana-targeted-index-{}-{}",
189 std::process::id(),
190 unique_suffix()
191 ));
192
193 let result = commit_with_temp_index(cwd, PathBuf::from(repo_root), &index_path, message, paths);
194 cleanup_temp_index(&index_path);
195 result
196}
197
198fn commit_with_temp_index(
199 cwd: &std::path::Path,
200 repo_root: PathBuf,
201 index_path: &std::path::Path,
202 message: &str,
203 paths: &[String],
204) -> Result<bool> {
205 let index = index_path.to_string_lossy().to_string();
206 git_status(
207 cwd,
208 &["read-tree", "HEAD"],
209 Some((&index, repo_root.as_path())),
210 "git read-tree failed",
211 )?;
212
213 let add_output = git_command_with_env(cwd, Some((&index, repo_root.as_path())))
214 .arg("add")
215 .arg("-A")
216 .arg("--")
217 .args(paths)
218 .output()?;
219 if !add_output.status.success() {
220 return Err(anyhow!(
221 "git add failed: {}",
222 String::from_utf8_lossy(&add_output.stderr)
223 ));
224 }
225
226 let tree = git_stdout_with_env(
227 cwd,
228 &["write-tree"],
229 Some((&index, repo_root.as_path())),
230 "git write-tree failed",
231 )?;
232 let head_tree = git_stdout(cwd, &["rev-parse", "HEAD^{tree}"])?;
233 if tree == head_tree {
234 return Ok(false);
235 }
236
237 let commit_output = Command::new("git")
238 .arg("commit-tree")
239 .arg(&tree)
240 .arg("-p")
241 .arg("HEAD")
242 .arg("-m")
243 .arg(message)
244 .current_dir(cwd)
245 .output()?;
246 if !commit_output.status.success() {
247 return Err(anyhow!(
248 "git commit-tree failed: {}",
249 String::from_utf8_lossy(&commit_output.stderr)
250 ));
251 }
252 let new_head = String::from_utf8_lossy(&commit_output.stdout)
253 .trim()
254 .to_string();
255 if new_head.is_empty() {
256 return Err(anyhow!("git commit-tree produced an empty commit id"));
257 }
258
259 git_status(
260 cwd,
261 &[
262 "update-ref",
263 "-m",
264 &format!("commit: {message}"),
265 "HEAD",
266 &new_head,
267 ],
268 None,
269 "git update-ref failed",
270 )?;
271
272 Ok(true)
273}
274
275fn git_command_with_env(
276 cwd: &std::path::Path,
277 temp_index: Option<(&str, &std::path::Path)>,
278) -> Command {
279 let mut command = Command::new("git");
280 command.current_dir(cwd);
281 if let Some((index, work_tree)) = temp_index {
282 command
283 .env("GIT_INDEX_FILE", index)
284 .env("GIT_WORK_TREE", work_tree);
285 }
286 command
287}
288
289fn git_status(
290 cwd: &std::path::Path,
291 args: &[&str],
292 temp_index: Option<(&str, &std::path::Path)>,
293 context: &str,
294) -> Result<()> {
295 let output = git_command_with_env(cwd, temp_index).args(args).output()?;
296 if output.status.success() {
297 return Ok(());
298 }
299 Err(anyhow!(
300 "{}: {}",
301 context,
302 String::from_utf8_lossy(&output.stderr)
303 ))
304}
305
306fn git_stdout(cwd: &std::path::Path, args: &[&str]) -> Result<String> {
307 git_stdout_with_env(cwd, args, None, "git command failed")
308}
309
310fn git_stdout_with_env(
311 cwd: &std::path::Path,
312 args: &[&str],
313 temp_index: Option<(&str, &std::path::Path)>,
314 context: &str,
315) -> Result<String> {
316 let output = git_command_with_env(cwd, temp_index).args(args).output()?;
317 if !output.status.success() {
318 return Err(anyhow!(
319 "{}: {}",
320 context,
321 String::from_utf8_lossy(&output.stderr)
322 ));
323 }
324 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
325}
326
327fn cleanup_temp_index(path: &std::path::Path) {
328 let _ = std::fs::remove_file(path);
329 let _ = std::fs::remove_file(path.with_extension("lock"));
330}
331
332fn unique_suffix() -> u128 {
333 std::time::SystemTime::now()
334 .duration_since(std::time::UNIX_EPOCH)
335 .map(|duration| duration.as_nanos())
336 .unwrap_or(0)
337}
338
339pub fn commit_worktree_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
349 let add_output = Command::new("git")
351 .args(["add", "-A"])
352 .current_dir(cwd)
353 .output()?;
354
355 if !add_output.status.success() {
356 return Err(anyhow!(
357 "git add failed: {}",
358 String::from_utf8_lossy(&add_output.stderr)
359 ));
360 }
361
362 commit_staged_changes(cwd, message)
363}
364
365fn commit_staged_changes(cwd: &std::path::Path, message: &str) -> Result<bool> {
366 let commit_output = Command::new("git")
368 .args(["commit", "-m", message])
369 .current_dir(cwd)
370 .output()?;
371
372 if commit_output.status.success() {
373 return Ok(true);
374 }
375
376 let stderr = String::from_utf8_lossy(&commit_output.stderr);
378 let stdout = String::from_utf8_lossy(&commit_output.stdout);
379 if stderr.contains("nothing to commit")
380 || stdout.contains("nothing to commit")
381 || stderr.contains("no changes added")
382 || stdout.contains("no changes added")
383 {
384 return Ok(false);
385 }
386
387 Err(anyhow!("git commit failed: {}", stderr))
388}
389
390pub fn merge_to_main(info: &WorktreeInfo, unit_id: &str) -> Result<MergeResult> {
405 let main_path = &info.main_path;
406 let branch = &info.branch;
407
408 if branch.is_empty() {
409 return Err(anyhow!("Worktree has no branch (detached HEAD?)"));
410 }
411
412 let merge_message = format!("Merge branch '{}' (unit {})", branch, unit_id);
414 let merge_output = Command::new("git")
415 .args(["-C", main_path.to_str().unwrap_or(".")])
416 .args(["merge", branch, "--no-ff", "-m", &merge_message])
417 .output()?;
418
419 if merge_output.status.success() {
420 return Ok(MergeResult::Success);
421 }
422
423 let stderr = String::from_utf8_lossy(&merge_output.stderr);
424 let stdout = String::from_utf8_lossy(&merge_output.stdout);
425
426 if stdout.contains("Already up to date") || stderr.contains("Already up to date") {
428 return Ok(MergeResult::NothingToCommit);
429 }
430
431 if stdout.contains("CONFLICT") || stderr.contains("CONFLICT") {
433 let conflicts = parse_conflict_files(&stdout, &stderr);
435
436 let _ = Command::new("git")
438 .args(["-C", main_path.to_str().unwrap_or(".")])
439 .args(["merge", "--abort"])
440 .output();
441
442 return Ok(MergeResult::Conflict { files: conflicts });
443 }
444
445 Err(anyhow!("git merge failed: {}", stderr))
446}
447
448fn parse_conflict_files(stdout: &str, stderr: &str) -> Vec<String> {
450 let combined = format!("{}\n{}", stdout, stderr);
451 let mut files = Vec::new();
452
453 for line in combined.lines() {
454 if let Some(idx) = line.find("Merge conflict in ") {
456 let file = line[idx + "Merge conflict in ".len()..].trim();
457 files.push(file.to_string());
458 }
459 else if line.starts_with("CONFLICT") {
462 if let Some(colon_idx) = line.find("):") {
464 let rest = &line[colon_idx + 2..].trim();
465 if let Some(word) = rest.split_whitespace().next() {
467 if !word.is_empty() && word != "Merge" && !files.contains(&word.to_string()) {
468 files.push(word.to_string());
469 }
470 }
471 }
472 }
473 }
474
475 files
476}
477
478pub fn cleanup_worktree(info: &WorktreeInfo) -> Result<()> {
485 let main_path = &info.main_path;
486 let worktree_path = &info.worktree_path;
487 let branch = &info.branch;
488
489 let remove_output = Command::new("git")
491 .args(["-C", main_path.to_str().unwrap_or(".")])
492 .args(["worktree", "remove", worktree_path.to_str().unwrap_or(".")])
493 .output()?;
494
495 if !remove_output.status.success() {
496 let force_output = Command::new("git")
498 .args(["-C", main_path.to_str().unwrap_or(".")])
499 .args([
500 "worktree",
501 "remove",
502 "--force",
503 worktree_path.to_str().unwrap_or("."),
504 ])
505 .output()?;
506
507 if !force_output.status.success() {
508 return Err(anyhow!(
509 "Failed to remove worktree: {}",
510 String::from_utf8_lossy(&force_output.stderr)
511 ));
512 }
513 }
514
515 if !branch.is_empty() {
517 let delete_output = Command::new("git")
518 .args(["-C", main_path.to_str().unwrap_or(".")])
519 .args(["branch", "-d", branch])
520 .output()?;
521
522 if !delete_output.status.success() {
523 let force_delete = Command::new("git")
525 .args(["-C", main_path.to_str().unwrap_or(".")])
526 .args(["branch", "-D", branch])
527 .output()?;
528
529 if !force_delete.status.success() {
530 return Err(anyhow!(
531 "Failed to delete branch '{}': {}",
532 branch,
533 String::from_utf8_lossy(&force_delete.stderr)
534 ));
535 }
536 }
537 }
538
539 Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn test_parse_worktree_list_single() {
548 let output = "worktree /home/user/project\nHEAD abc123\nbranch refs/heads/main\n";
549 let entries = parse_worktree_list(output);
550
551 assert_eq!(entries.len(), 1);
552 assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
553 assert_eq!(entries[0].branch, Some("main".to_string()));
554 }
555
556 #[test]
557 fn test_parse_worktree_list_multiple() {
558 let output = r#"worktree /home/user/project
559HEAD abc123
560branch refs/heads/main
561
562worktree /home/user/project-feature
563HEAD def456
564branch refs/heads/feature-x
565"#;
566 let entries = parse_worktree_list(output);
567
568 assert_eq!(entries.len(), 2);
569 assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
570 assert_eq!(entries[0].branch, Some("main".to_string()));
571 assert_eq!(entries[1].path, PathBuf::from("/home/user/project-feature"));
572 assert_eq!(entries[1].branch, Some("feature-x".to_string()));
573 }
574
575 #[test]
576 fn test_parse_worktree_list_detached_head() {
577 let output = "worktree /home/user/project\nHEAD abc123\ndetached\n";
578 let entries = parse_worktree_list(output);
579
580 assert_eq!(entries.len(), 1);
581 assert_eq!(entries[0].path, PathBuf::from("/home/user/project"));
582 assert_eq!(entries[0].branch, None);
583 }
584
585 #[test]
586 fn detect_worktree_runs_without_panic() {
587 let cwd = std::env::current_dir().unwrap();
590 let result = detect_worktree(&cwd);
591 assert!(result.is_ok());
592 }
593
594 mod merge {
596 use super::*;
597
598 #[test]
599 fn test_merge_result_variants() {
600 let success = MergeResult::Success;
602 let conflict = MergeResult::Conflict {
603 files: vec!["file1.txt".to_string(), "file2.txt".to_string()],
604 };
605 let nothing = MergeResult::NothingToCommit;
606
607 assert_eq!(success, MergeResult::Success);
608 assert_eq!(nothing, MergeResult::NothingToCommit);
609
610 if let MergeResult::Conflict { files } = conflict {
611 assert_eq!(files.len(), 2);
612 assert!(files.contains(&"file1.txt".to_string()));
613 } else {
614 unreachable!("Expected Conflict variant");
615 }
616 }
617
618 #[test]
619 fn test_parse_conflict_files_content_conflict() {
620 let stdout =
621 "Auto-merging src/lib.rs\nCONFLICT (content): Merge conflict in src/lib.rs\n";
622 let stderr = "";
623 let files = parse_conflict_files(stdout, stderr);
624 assert_eq!(files, vec!["src/lib.rs"]);
625 }
626
627 #[test]
628 fn test_parse_conflict_files_multiple() {
629 let stdout = r#"Auto-merging file1.txt
630CONFLICT (content): Merge conflict in file1.txt
631Auto-merging file2.txt
632CONFLICT (content): Merge conflict in file2.txt
633"#;
634 let files = parse_conflict_files(stdout, "");
635 assert_eq!(files.len(), 2);
636 assert!(files.contains(&"file1.txt".to_string()));
637 assert!(files.contains(&"file2.txt".to_string()));
638 }
639
640 #[test]
641 fn test_parse_conflict_files_empty() {
642 let files = parse_conflict_files("", "");
643 assert!(files.is_empty());
644 }
645
646 #[test]
647 fn test_parse_conflict_files_no_conflicts() {
648 let stdout = "Already up to date.\n";
649 let files = parse_conflict_files(stdout, "");
650 assert!(files.is_empty());
651 }
652
653 #[test]
654 fn test_worktree_info_for_merge() {
655 let info = WorktreeInfo {
657 main_path: PathBuf::from("/home/user/project"),
658 worktree_path: PathBuf::from("/home/user/project-feature"),
659 branch: "feature-branch".to_string(),
660 };
661
662 assert_eq!(info.branch, "feature-branch");
663 assert_eq!(info.main_path, PathBuf::from("/home/user/project"));
664 assert_eq!(
665 info.worktree_path,
666 PathBuf::from("/home/user/project-feature")
667 );
668 }
669
670 #[test]
671 fn test_merge_to_main_requires_branch() {
672 let info = WorktreeInfo {
674 main_path: PathBuf::from("/tmp/nonexistent"),
675 worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
676 branch: String::new(), };
678
679 let result = merge_to_main(&info, "test-unit");
680 assert!(result.is_err());
681 let err = result.unwrap_err();
682 assert!(err.to_string().contains("no branch"));
683 }
684
685 #[test]
686 fn test_commit_worktree_changes_type_signature() {
687 let cwd = std::env::current_dir().unwrap();
691 let result = commit_worktree_changes(&cwd, "test message");
692 let _ = result;
694 }
695
696 #[test]
697 fn test_cleanup_worktree_type_signature() {
698 let info = WorktreeInfo {
700 main_path: PathBuf::from("/tmp/nonexistent-main"),
701 worktree_path: PathBuf::from("/tmp/nonexistent-wt"),
702 branch: "test-branch".to_string(),
703 };
704
705 let result = cleanup_worktree(&info);
707 assert!(result.is_err()); }
709 }
710}