1use anyhow::{Context, Result};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15pub fn worktree_path_for_spec(spec_id: &str) -> PathBuf {
19 PathBuf::from(format!("/tmp/chant-{}", spec_id))
20}
21
22pub fn get_active_worktree(spec_id: &str) -> Option<PathBuf> {
26 let path = worktree_path_for_spec(spec_id);
27 if path.exists() && path.is_dir() {
28 Some(path)
29 } else {
30 None
31 }
32}
33
34pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
45 let output = Command::new("git")
47 .args(["add", "-A"])
48 .current_dir(worktree_path)
49 .output()
50 .context("Failed to stage changes in worktree")?;
51
52 if !output.status.success() {
53 let stderr = String::from_utf8_lossy(&output.stderr);
54 anyhow::bail!("Failed to stage changes: {}", stderr);
55 }
56
57 let output = Command::new("git")
59 .args(["status", "--porcelain"])
60 .current_dir(worktree_path)
61 .output()
62 .context("Failed to check git status in worktree")?;
63
64 let status_output = String::from_utf8_lossy(&output.stdout);
65 if status_output.trim().is_empty() {
66 let output = Command::new("git")
68 .args(["rev-parse", "HEAD"])
69 .current_dir(worktree_path)
70 .output()
71 .context("Failed to get HEAD commit")?;
72
73 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
74 return Ok(hash);
75 }
76
77 let output = Command::new("git")
79 .args(["commit", "-m", message])
80 .current_dir(worktree_path)
81 .output()
82 .context("Failed to commit changes in worktree")?;
83
84 if !output.status.success() {
85 let stderr = String::from_utf8_lossy(&output.stderr);
86 anyhow::bail!("Failed to commit: {}", stderr);
87 }
88
89 let output = Command::new("git")
91 .args(["rev-parse", "HEAD"])
92 .current_dir(worktree_path)
93 .output()
94 .context("Failed to get commit hash")?;
95
96 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
97 Ok(hash)
98}
99
100pub fn create_worktree(spec_id: &str, branch: &str) -> Result<PathBuf> {
118 let worktree_path = PathBuf::from(format!("/tmp/chant-{}", spec_id));
119
120 let output = Command::new("git")
122 .args(["rev-parse", "--verify", branch])
123 .output()
124 .context("Failed to check if branch exists")?;
125
126 if output.status.success() {
127 anyhow::bail!("Branch '{}' already exists", branch);
128 }
129
130 let output = Command::new("git")
132 .args([
133 "worktree",
134 "add",
135 "-b",
136 branch,
137 &worktree_path.to_string_lossy(),
138 ])
139 .output()
140 .context("Failed to create git worktree")?;
141
142 if !output.status.success() {
143 let stderr = String::from_utf8_lossy(&output.stderr);
144 anyhow::bail!("Failed to create worktree: {}", stderr);
145 }
146
147 Ok(worktree_path)
148}
149
150pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
171 let git_root = std::env::current_dir().context("Failed to get current directory")?;
173 let main_spec_path = git_root
174 .join(".chant/specs")
175 .join(format!("{}.md", spec_id));
176 let worktree_specs_dir = worktree_path.join(".chant/specs");
177 let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
178
179 std::fs::create_dir_all(&worktree_specs_dir).context(format!(
181 "Failed to create specs directory in worktree: {:?}",
182 worktree_specs_dir
183 ))?;
184
185 std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
187 "Failed to copy spec file to worktree: {:?}",
188 worktree_spec_path
189 ))?;
190
191 commit_in_worktree(
193 worktree_path,
194 &format!("chant({}): update spec status to in_progress", spec_id),
195 )?;
196
197 Ok(())
198}
199
200pub fn remove_worktree(path: &Path) -> Result<()> {
212 let _output = Command::new("git")
214 .args(["worktree", "remove", &path.to_string_lossy()])
215 .output()
216 .context("Failed to run git worktree remove")?;
217
218 if path.exists() {
220 std::fs::remove_dir_all(path)
221 .context(format!("Failed to remove worktree directory at {:?}", path))?;
222 }
223
224 Ok(())
225}
226
227#[derive(Debug, Clone)]
229pub struct MergeCleanupResult {
230 pub success: bool,
231 pub has_conflict: bool,
232 pub error: Option<String>,
233}
234
235fn branch_is_behind_main(branch: &str, work_dir: Option<&Path>) -> Result<bool> {
246 let mut cmd = Command::new("git");
247 cmd.args(["rev-list", "--count", &format!("{}..main", branch)]);
248 if let Some(dir) = work_dir {
249 cmd.current_dir(dir);
250 }
251 let output = cmd
252 .output()
253 .context("Failed to check if branch is behind main")?;
254
255 if !output.status.success() {
256 let stderr = String::from_utf8_lossy(&output.stderr);
257 anyhow::bail!("Failed to check branch status: {}", stderr);
258 }
259
260 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
261 let count: i32 = count_str
262 .parse()
263 .context(format!("Failed to parse commit count: {}", count_str))?;
264 Ok(count > 0)
265}
266
267fn rebase_branch_onto_main(branch: &str, work_dir: Option<&Path>) -> Result<()> {
278 let mut cmd = Command::new("git");
280 cmd.args(["checkout", branch]);
281 if let Some(dir) = work_dir {
282 cmd.current_dir(dir);
283 }
284 let output = cmd
285 .output()
286 .context("Failed to checkout branch for rebase")?;
287
288 if !output.status.success() {
289 let stderr = String::from_utf8_lossy(&output.stderr);
290 anyhow::bail!("Failed to checkout branch: {}", stderr);
291 }
292
293 let mut cmd = Command::new("git");
295 cmd.args(["rebase", "main"]);
296 if let Some(dir) = work_dir {
297 cmd.current_dir(dir);
298 }
299 let output = cmd.output().context("Failed to rebase onto main")?;
300
301 if !output.status.success() {
302 anyhow::bail!("Rebase had conflicts");
303 }
304
305 let mut cmd = Command::new("git");
307 cmd.args(["checkout", "main"]);
308 if let Some(dir) = work_dir {
309 cmd.current_dir(dir);
310 }
311 let output = cmd
312 .output()
313 .context("Failed to checkout main after rebase")?;
314
315 if !output.status.success() {
316 let stderr = String::from_utf8_lossy(&output.stderr);
317 anyhow::bail!("Failed to checkout main: {}", stderr);
318 }
319
320 Ok(())
321}
322
323fn abort_rebase(work_dir: Option<&Path>) {
331 let mut cmd = Command::new("git");
333 cmd.args(["rebase", "--abort"]);
334 if let Some(dir) = work_dir {
335 cmd.current_dir(dir);
336 }
337 let _ = cmd.output();
338
339 let mut cmd = Command::new("git");
341 cmd.args(["checkout", "main"]);
342 if let Some(dir) = work_dir {
343 cmd.current_dir(dir);
344 }
345 let _ = cmd.output();
346}
347
348pub fn merge_and_cleanup(branch: &str, no_rebase: bool) -> MergeCleanupResult {
364 merge_and_cleanup_in_dir(branch, None, no_rebase)
365}
366
367fn merge_and_cleanup_in_dir(
369 branch: &str,
370 work_dir: Option<&Path>,
371 no_rebase: bool,
372) -> MergeCleanupResult {
373 let mut cmd = Command::new("git");
375 cmd.args(["checkout", "main"]);
376 if let Some(dir) = work_dir {
377 cmd.current_dir(dir);
378 }
379 let output = match cmd.output() {
380 Ok(o) => o,
381 Err(e) => {
382 return MergeCleanupResult {
383 success: false,
384 has_conflict: false,
385 error: Some(format!("Failed to checkout main: {}", e)),
386 };
387 }
388 };
389
390 if !output.status.success() {
391 let stderr = String::from_utf8_lossy(&output.stderr);
392 let _ = crate::git::ensure_on_main_branch("main");
394 return MergeCleanupResult {
395 success: false,
396 has_conflict: false,
397 error: Some(format!("Failed to checkout main: {}", stderr)),
398 };
399 }
400
401 if !no_rebase {
403 match branch_is_behind_main(branch, work_dir) {
404 Ok(true) => {
405 println!(
407 "Branch '{}' is behind main, attempting automatic rebase...",
408 branch
409 );
410 match rebase_branch_onto_main(branch, work_dir) {
411 Ok(()) => {
412 println!("Rebase succeeded, proceeding with merge...");
413 }
414 Err(e) => {
415 abort_rebase(work_dir);
417 return MergeCleanupResult {
418 success: false,
419 has_conflict: true,
420 error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
421 };
422 }
423 }
424 }
425 Ok(false) => {
426 }
428 Err(e) => {
429 eprintln!("Warning: Failed to check if branch is behind main: {}", e);
431 }
432 }
433 }
434
435 let mut cmd = Command::new("git");
437 cmd.args(["merge", "--ff-only", branch]);
438 if let Some(dir) = work_dir {
439 cmd.current_dir(dir);
440 }
441 let output = match cmd.output() {
442 Ok(o) => o,
443 Err(e) => {
444 return MergeCleanupResult {
445 success: false,
446 has_conflict: false,
447 error: Some(format!("Failed to perform merge: {}", e)),
448 };
449 }
450 };
451
452 if !output.status.success() {
453 let stderr = String::from_utf8_lossy(&output.stderr);
454 let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
456
457 if has_conflict {
459 let mut cmd = Command::new("git");
460 cmd.args(["merge", "--abort"]);
461 if let Some(dir) = work_dir {
462 cmd.current_dir(dir);
463 }
464 let _ = cmd.output();
465 }
466
467 let spec_id = branch.trim_start_matches("chant/");
469 let error_msg = if has_conflict {
470 crate::merge_errors::merge_conflict(spec_id, branch, "main")
471 } else {
472 crate::merge_errors::fast_forward_conflict(spec_id, branch, "main", &stderr)
473 };
474 let _ = crate::git::ensure_on_main_branch("main");
476 return MergeCleanupResult {
477 success: false,
478 has_conflict,
479 error: Some(error_msg),
480 };
481 }
482
483 let mut cmd = Command::new("git");
485 cmd.args(["branch", "-d", branch]);
486 if let Some(dir) = work_dir {
487 cmd.current_dir(dir);
488 }
489 let output = match cmd.output() {
490 Ok(o) => o,
491 Err(e) => {
492 return MergeCleanupResult {
493 success: false,
494 has_conflict: false,
495 error: Some(format!("Failed to delete branch: {}", e)),
496 };
497 }
498 };
499
500 if !output.status.success() {
501 let stderr = String::from_utf8_lossy(&output.stderr);
502 return MergeCleanupResult {
503 success: false,
504 has_conflict: false,
505 error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
506 };
507 }
508
509 let mut cmd = Command::new("git");
511 cmd.args(["push", "origin", "--delete", branch]);
512 if let Some(dir) = work_dir {
513 cmd.current_dir(dir);
514 }
515 let _ = cmd.output();
517
518 MergeCleanupResult {
519 success: true,
520 has_conflict: false,
521 error: None,
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use std::fs;
529 use std::process::Command as StdCommand;
530
531 fn setup_test_repo(repo_dir: &Path) -> Result<()> {
533 fs::create_dir_all(repo_dir)?;
534
535 let output = StdCommand::new("git")
536 .args(["init", "-b", "main"])
537 .current_dir(repo_dir)
538 .output()
539 .context("Failed to run git init")?;
540 anyhow::ensure!(
541 output.status.success(),
542 "git init failed: {}",
543 String::from_utf8_lossy(&output.stderr)
544 );
545
546 let output = StdCommand::new("git")
547 .args(["config", "user.email", "test@example.com"])
548 .current_dir(repo_dir)
549 .output()
550 .context("Failed to run git config")?;
551 anyhow::ensure!(
552 output.status.success(),
553 "git config email failed: {}",
554 String::from_utf8_lossy(&output.stderr)
555 );
556
557 let output = StdCommand::new("git")
558 .args(["config", "user.name", "Test User"])
559 .current_dir(repo_dir)
560 .output()
561 .context("Failed to run git config")?;
562 anyhow::ensure!(
563 output.status.success(),
564 "git config name failed: {}",
565 String::from_utf8_lossy(&output.stderr)
566 );
567
568 fs::write(repo_dir.join("README.md"), "# Test")?;
570
571 let output = StdCommand::new("git")
572 .args(["add", "."])
573 .current_dir(repo_dir)
574 .output()
575 .context("Failed to run git add")?;
576 anyhow::ensure!(
577 output.status.success(),
578 "git add failed: {}",
579 String::from_utf8_lossy(&output.stderr)
580 );
581
582 let output = StdCommand::new("git")
583 .args(["commit", "-m", "Initial commit"])
584 .current_dir(repo_dir)
585 .output()
586 .context("Failed to run git commit")?;
587 anyhow::ensure!(
588 output.status.success(),
589 "git commit failed: {}",
590 String::from_utf8_lossy(&output.stderr)
591 );
592
593 Ok(())
594 }
595
596 fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
598 if repo_dir.exists() {
599 fs::remove_dir_all(repo_dir)?;
600 }
601 Ok(())
602 }
603
604 #[test]
605 #[serial_test::serial]
606 fn test_create_worktree_branch_already_exists() -> Result<()> {
607 let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
608 cleanup_test_repo(&repo_dir)?;
609 setup_test_repo(&repo_dir)?;
610
611 let original_dir = std::env::current_dir()?;
612
613 let result = {
614 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
615
616 let spec_id = "test-spec-branch-exists";
617 let branch = "spec/test-spec-branch-exists";
618
619 let output = StdCommand::new("git")
621 .args(["branch", branch])
622 .current_dir(&repo_dir)
623 .output()?;
624 anyhow::ensure!(
625 output.status.success(),
626 "git branch failed: {}",
627 String::from_utf8_lossy(&output.stderr)
628 );
629
630 create_worktree(spec_id, branch)
631 };
632
633 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
635 cleanup_test_repo(&repo_dir)?;
636
637 assert!(result.is_err());
638 assert!(result.unwrap_err().to_string().contains("already exists"));
639 Ok(())
640 }
641
642 #[test]
643 #[serial_test::serial]
644 fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
645 let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
646 cleanup_test_repo(&repo_dir)?;
647 setup_test_repo(&repo_dir)?;
648
649 let original_dir = std::env::current_dir()?;
650
651 let result = {
652 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
653
654 let branch = "feature/conflict-test";
655
656 let output = StdCommand::new("git")
658 .args(["branch", branch])
659 .current_dir(&repo_dir)
660 .output()?;
661 anyhow::ensure!(
662 output.status.success(),
663 "git branch failed: {}",
664 String::from_utf8_lossy(&output.stderr)
665 );
666
667 let output = StdCommand::new("git")
668 .args(["checkout", branch])
669 .current_dir(&repo_dir)
670 .output()?;
671 anyhow::ensure!(
672 output.status.success(),
673 "git checkout branch failed: {}",
674 String::from_utf8_lossy(&output.stderr)
675 );
676
677 fs::write(repo_dir.join("README.md"), "feature version")?;
678
679 let output = StdCommand::new("git")
680 .args(["add", "."])
681 .current_dir(&repo_dir)
682 .output()?;
683 anyhow::ensure!(
684 output.status.success(),
685 "git add failed: {}",
686 String::from_utf8_lossy(&output.stderr)
687 );
688
689 let output = StdCommand::new("git")
690 .args(["commit", "-m", "Modify README on feature"])
691 .current_dir(&repo_dir)
692 .output()?;
693 anyhow::ensure!(
694 output.status.success(),
695 "git commit feature failed: {}",
696 String::from_utf8_lossy(&output.stderr)
697 );
698
699 let output = StdCommand::new("git")
701 .args(["checkout", "main"])
702 .current_dir(&repo_dir)
703 .output()?;
704 anyhow::ensure!(
705 output.status.success(),
706 "git checkout main failed: {}",
707 String::from_utf8_lossy(&output.stderr)
708 );
709
710 fs::write(repo_dir.join("README.md"), "main version")?;
711
712 let output = StdCommand::new("git")
713 .args(["add", "."])
714 .current_dir(&repo_dir)
715 .output()?;
716 anyhow::ensure!(
717 output.status.success(),
718 "git add main failed: {}",
719 String::from_utf8_lossy(&output.stderr)
720 );
721
722 let output = StdCommand::new("git")
723 .args(["commit", "-m", "Modify README on main"])
724 .current_dir(&repo_dir)
725 .output()?;
726 anyhow::ensure!(
727 output.status.success(),
728 "git commit main failed: {}",
729 String::from_utf8_lossy(&output.stderr)
730 );
731
732 merge_and_cleanup_in_dir(branch, Some(&repo_dir), false)
734 };
735
736 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
738
739 let branch_check = StdCommand::new("git")
741 .args(["rev-parse", "--verify", "feature/conflict-test"])
742 .current_dir(&repo_dir)
743 .output()?;
744
745 cleanup_test_repo(&repo_dir)?;
746
747 assert!(!result.success);
749 assert!(branch_check.status.success());
751 Ok(())
752 }
753
754 #[test]
755 #[serial_test::serial]
756 fn test_merge_and_cleanup_successful_merge() -> Result<()> {
757 let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
758 cleanup_test_repo(&repo_dir)?;
759 setup_test_repo(&repo_dir)?;
760
761 let original_dir = std::env::current_dir()?;
762
763 let result = {
764 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
765
766 let branch = "feature/new-feature";
767
768 let output = StdCommand::new("git")
770 .args(["branch", branch])
771 .current_dir(&repo_dir)
772 .output()?;
773 anyhow::ensure!(
774 output.status.success(),
775 "git branch failed: {}",
776 String::from_utf8_lossy(&output.stderr)
777 );
778
779 let output = StdCommand::new("git")
780 .args(["checkout", branch])
781 .current_dir(&repo_dir)
782 .output()?;
783 anyhow::ensure!(
784 output.status.success(),
785 "git checkout failed: {}",
786 String::from_utf8_lossy(&output.stderr)
787 );
788
789 fs::write(repo_dir.join("feature.txt"), "feature content")?;
790
791 let output = StdCommand::new("git")
792 .args(["add", "."])
793 .current_dir(&repo_dir)
794 .output()?;
795 anyhow::ensure!(
796 output.status.success(),
797 "git add failed: {}",
798 String::from_utf8_lossy(&output.stderr)
799 );
800
801 let output = StdCommand::new("git")
802 .args(["commit", "-m", "Add feature"])
803 .current_dir(&repo_dir)
804 .output()?;
805 anyhow::ensure!(
806 output.status.success(),
807 "git commit failed: {}",
808 String::from_utf8_lossy(&output.stderr)
809 );
810
811 merge_and_cleanup_in_dir(branch, Some(&repo_dir), false)
813 };
814
815 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
817
818 let branch_check = StdCommand::new("git")
820 .args(["rev-parse", "--verify", "feature/new-feature"])
821 .current_dir(&repo_dir)
822 .output()?;
823
824 cleanup_test_repo(&repo_dir)?;
825
826 assert!(
827 result.success && result.error.is_none(),
828 "Merge result: {:?}",
829 result
830 );
831 assert!(!branch_check.status.success());
833 Ok(())
834 }
835
836 #[test]
837 fn test_remove_worktree_idempotent() -> Result<()> {
838 let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
839
840 let result = remove_worktree(&path);
842
843 assert!(result.is_ok());
844 Ok(())
845 }
846
847 #[test]
848 fn test_worktree_path_for_spec() {
849 let path = worktree_path_for_spec("2026-01-27-001-abc");
850 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
851 }
852
853 #[test]
854 fn test_get_active_worktree_nonexistent() {
855 let result = get_active_worktree("nonexistent-spec-12345");
857 assert!(result.is_none());
858 }
859
860 #[test]
861 #[serial_test::serial]
862 fn test_commit_in_worktree() -> Result<()> {
863 let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
864 cleanup_test_repo(&repo_dir)?;
865 setup_test_repo(&repo_dir)?;
866
867 fs::write(repo_dir.join("new_file.txt"), "content")?;
869
870 let result = commit_in_worktree(&repo_dir, "Test commit message");
872
873 cleanup_test_repo(&repo_dir)?;
874
875 assert!(result.is_ok());
876 let hash = result.unwrap();
877 assert_eq!(hash.len(), 40);
879 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
880
881 Ok(())
882 }
883
884 #[test]
885 #[serial_test::serial]
886 fn test_commit_in_worktree_no_changes() -> Result<()> {
887 let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
888 cleanup_test_repo(&repo_dir)?;
889 setup_test_repo(&repo_dir)?;
890
891 let result = commit_in_worktree(&repo_dir, "Empty commit");
893
894 cleanup_test_repo(&repo_dir)?;
895
896 assert!(result.is_ok());
898 let hash = result.unwrap();
899 assert_eq!(hash.len(), 40);
900
901 Ok(())
902 }
903}