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 has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
44 let output = Command::new("git")
45 .args(["status", "--porcelain"])
46 .current_dir(worktree_path)
47 .output()
48 .context("Failed to check git status in worktree")?;
49
50 if !output.status.success() {
51 let stderr = String::from_utf8_lossy(&output.stderr);
52 anyhow::bail!("Failed to run git status: {}", stderr);
53 }
54
55 let status_output = String::from_utf8_lossy(&output.stdout);
56 Ok(!status_output.trim().is_empty())
57}
58
59pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
70 let output = Command::new("git")
72 .args(["add", "-A"])
73 .current_dir(worktree_path)
74 .output()
75 .context("Failed to stage changes in worktree")?;
76
77 if !output.status.success() {
78 let stderr = String::from_utf8_lossy(&output.stderr);
79 anyhow::bail!("Failed to stage changes: {}", stderr);
80 }
81
82 let output = Command::new("git")
84 .args(["status", "--porcelain"])
85 .current_dir(worktree_path)
86 .output()
87 .context("Failed to check git status in worktree")?;
88
89 let status_output = String::from_utf8_lossy(&output.stdout);
90 if status_output.trim().is_empty() {
91 let output = Command::new("git")
93 .args(["rev-parse", "HEAD"])
94 .current_dir(worktree_path)
95 .output()
96 .context("Failed to get HEAD commit")?;
97
98 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
99 return Ok(hash);
100 }
101
102 let output = Command::new("git")
104 .args(["commit", "-m", message])
105 .current_dir(worktree_path)
106 .output()
107 .context("Failed to commit changes in worktree")?;
108
109 if !output.status.success() {
110 let stderr = String::from_utf8_lossy(&output.stderr);
111 anyhow::bail!("Failed to commit: {}", stderr);
112 }
113
114 let output = Command::new("git")
116 .args(["rev-parse", "HEAD"])
117 .current_dir(worktree_path)
118 .output()
119 .context("Failed to get commit hash")?;
120
121 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
122 Ok(hash)
123}
124
125pub fn create_worktree(spec_id: &str, branch: &str) -> Result<PathBuf> {
143 let worktree_path = PathBuf::from(format!("/tmp/chant-{}", spec_id));
144
145 if worktree_path.exists() {
147 let _ = Command::new("git")
149 .args([
150 "worktree",
151 "remove",
152 "--force",
153 &worktree_path.to_string_lossy(),
154 ])
155 .output();
156
157 if worktree_path.exists() {
159 let _ = std::fs::remove_dir_all(&worktree_path);
160 }
161 }
162
163 let output = Command::new("git")
165 .args(["rev-parse", "--verify", branch])
166 .output()
167 .context("Failed to check if branch exists")?;
168
169 if output.status.success() {
170 let _ = Command::new("git").args(["branch", "-D", branch]).output();
172 }
173
174 let output = Command::new("git")
176 .args([
177 "worktree",
178 "add",
179 "-b",
180 branch,
181 &worktree_path.to_string_lossy(),
182 ])
183 .output()
184 .context("Failed to create git worktree")?;
185
186 if !output.status.success() {
187 let stderr = String::from_utf8_lossy(&output.stderr);
188 anyhow::bail!("Failed to create worktree: {}", stderr);
189 }
190
191 Ok(worktree_path)
192}
193
194pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
215 let git_root = std::env::current_dir().context("Failed to get current directory")?;
217 let main_spec_path = git_root
218 .join(".chant/specs")
219 .join(format!("{}.md", spec_id));
220 let worktree_specs_dir = worktree_path.join(".chant/specs");
221 let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
222
223 std::fs::create_dir_all(&worktree_specs_dir).context(format!(
225 "Failed to create specs directory in worktree: {:?}",
226 worktree_specs_dir
227 ))?;
228
229 std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
231 "Failed to copy spec file to worktree: {:?}",
232 worktree_spec_path
233 ))?;
234
235 commit_in_worktree(
237 worktree_path,
238 &format!("chant({}): update spec status to in_progress", spec_id),
239 )?;
240
241 Ok(())
242}
243
244pub fn remove_worktree(path: &Path) -> Result<()> {
256 let _output = Command::new("git")
258 .args(["worktree", "remove", &path.to_string_lossy()])
259 .output()
260 .context("Failed to run git worktree remove")?;
261
262 if path.exists() {
264 std::fs::remove_dir_all(path)
265 .context(format!("Failed to remove worktree directory at {:?}", path))?;
266 }
267
268 Ok(())
269}
270
271#[derive(Debug, Clone)]
273pub struct MergeCleanupResult {
274 pub success: bool,
275 pub has_conflict: bool,
276 pub error: Option<String>,
277}
278
279fn branch_is_behind_main(branch: &str, work_dir: Option<&Path>) -> Result<bool> {
290 let mut cmd = Command::new("git");
291 cmd.args(["rev-list", "--count", &format!("{}..main", branch)]);
292 if let Some(dir) = work_dir {
293 cmd.current_dir(dir);
294 }
295 let output = cmd
296 .output()
297 .context("Failed to check if branch is behind main")?;
298
299 if !output.status.success() {
300 let stderr = String::from_utf8_lossy(&output.stderr);
301 anyhow::bail!("Failed to check branch status: {}", stderr);
302 }
303
304 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
305 let count: i32 = count_str
306 .parse()
307 .context(format!("Failed to parse commit count: {}", count_str))?;
308 Ok(count > 0)
309}
310
311fn rebase_branch_onto_main(branch: &str, work_dir: Option<&Path>) -> Result<()> {
322 let mut cmd = Command::new("git");
324 cmd.args(["checkout", branch]);
325 if let Some(dir) = work_dir {
326 cmd.current_dir(dir);
327 }
328 let output = cmd
329 .output()
330 .context("Failed to checkout branch for rebase")?;
331
332 if !output.status.success() {
333 let stderr = String::from_utf8_lossy(&output.stderr);
334 anyhow::bail!("Failed to checkout branch: {}", stderr);
335 }
336
337 let mut cmd = Command::new("git");
339 cmd.args(["rebase", "main"]);
340 if let Some(dir) = work_dir {
341 cmd.current_dir(dir);
342 }
343 let output = cmd.output().context("Failed to rebase onto main")?;
344
345 if !output.status.success() {
346 anyhow::bail!("Rebase had conflicts");
347 }
348
349 let mut cmd = Command::new("git");
351 cmd.args(["checkout", "main"]);
352 if let Some(dir) = work_dir {
353 cmd.current_dir(dir);
354 }
355 let output = cmd
356 .output()
357 .context("Failed to checkout main after rebase")?;
358
359 if !output.status.success() {
360 let stderr = String::from_utf8_lossy(&output.stderr);
361 anyhow::bail!("Failed to checkout main: {}", stderr);
362 }
363
364 Ok(())
365}
366
367fn abort_rebase(work_dir: Option<&Path>) {
375 let mut cmd = Command::new("git");
377 cmd.args(["rebase", "--abort"]);
378 if let Some(dir) = work_dir {
379 cmd.current_dir(dir);
380 }
381 let _ = cmd.output();
382
383 let mut cmd = Command::new("git");
385 cmd.args(["checkout", "main"]);
386 if let Some(dir) = work_dir {
387 cmd.current_dir(dir);
388 }
389 let _ = cmd.output();
390}
391
392pub fn merge_and_cleanup(branch: &str, no_rebase: bool) -> MergeCleanupResult {
408 merge_and_cleanup_in_dir(branch, None, no_rebase)
409}
410
411fn merge_and_cleanup_in_dir(
413 branch: &str,
414 work_dir: Option<&Path>,
415 no_rebase: bool,
416) -> MergeCleanupResult {
417 let mut cmd = Command::new("git");
419 cmd.args(["checkout", "main"]);
420 if let Some(dir) = work_dir {
421 cmd.current_dir(dir);
422 }
423 let output = match cmd.output() {
424 Ok(o) => o,
425 Err(e) => {
426 return MergeCleanupResult {
427 success: false,
428 has_conflict: false,
429 error: Some(format!("Failed to checkout main: {}", e)),
430 };
431 }
432 };
433
434 if !output.status.success() {
435 let stderr = String::from_utf8_lossy(&output.stderr);
436 let _ = crate::git::ensure_on_main_branch("main");
438 return MergeCleanupResult {
439 success: false,
440 has_conflict: false,
441 error: Some(format!("Failed to checkout main: {}", stderr)),
442 };
443 }
444
445 if !no_rebase {
447 match branch_is_behind_main(branch, work_dir) {
448 Ok(true) => {
449 println!(
451 "Branch '{}' is behind main, attempting automatic rebase...",
452 branch
453 );
454 match rebase_branch_onto_main(branch, work_dir) {
455 Ok(()) => {
456 println!("Rebase succeeded, proceeding with merge...");
457 }
458 Err(e) => {
459 abort_rebase(work_dir);
461 return MergeCleanupResult {
462 success: false,
463 has_conflict: true,
464 error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
465 };
466 }
467 }
468 }
469 Ok(false) => {
470 }
472 Err(e) => {
473 eprintln!("Warning: Failed to check if branch is behind main: {}", e);
475 }
476 }
477 }
478
479 let mut cmd = Command::new("git");
481 cmd.args(["merge", "--ff-only", branch]);
482 if let Some(dir) = work_dir {
483 cmd.current_dir(dir);
484 }
485 let output = match cmd.output() {
486 Ok(o) => o,
487 Err(e) => {
488 return MergeCleanupResult {
489 success: false,
490 has_conflict: false,
491 error: Some(format!("Failed to perform merge: {}", e)),
492 };
493 }
494 };
495
496 if !output.status.success() {
497 let stderr = String::from_utf8_lossy(&output.stderr);
498 let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
500
501 if has_conflict {
503 let mut cmd = Command::new("git");
504 cmd.args(["merge", "--abort"]);
505 if let Some(dir) = work_dir {
506 cmd.current_dir(dir);
507 }
508 let _ = cmd.output();
509 }
510
511 let spec_id = branch.trim_start_matches("chant/");
513 let error_msg = if has_conflict {
514 crate::merge_errors::merge_conflict(spec_id, branch, "main")
515 } else {
516 crate::merge_errors::fast_forward_conflict(spec_id, branch, "main", &stderr)
517 };
518 let _ = crate::git::ensure_on_main_branch("main");
520 return MergeCleanupResult {
521 success: false,
522 has_conflict,
523 error: Some(error_msg),
524 };
525 }
526
527 let mut cmd = Command::new("git");
529 cmd.args(["branch", "-d", branch]);
530 if let Some(dir) = work_dir {
531 cmd.current_dir(dir);
532 }
533 let output = match cmd.output() {
534 Ok(o) => o,
535 Err(e) => {
536 return MergeCleanupResult {
537 success: false,
538 has_conflict: false,
539 error: Some(format!("Failed to delete branch: {}", e)),
540 };
541 }
542 };
543
544 if !output.status.success() {
545 let stderr = String::from_utf8_lossy(&output.stderr);
546 return MergeCleanupResult {
547 success: false,
548 has_conflict: false,
549 error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
550 };
551 }
552
553 let mut cmd = Command::new("git");
555 cmd.args(["push", "origin", "--delete", branch]);
556 if let Some(dir) = work_dir {
557 cmd.current_dir(dir);
558 }
559 let _ = cmd.output();
561
562 MergeCleanupResult {
563 success: true,
564 has_conflict: false,
565 error: None,
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use std::fs;
573 use std::process::Command as StdCommand;
574
575 fn setup_test_repo(repo_dir: &Path) -> Result<()> {
577 fs::create_dir_all(repo_dir)?;
578
579 let output = StdCommand::new("git")
580 .args(["init", "-b", "main"])
581 .current_dir(repo_dir)
582 .output()
583 .context("Failed to run git init")?;
584 anyhow::ensure!(
585 output.status.success(),
586 "git init failed: {}",
587 String::from_utf8_lossy(&output.stderr)
588 );
589
590 let output = StdCommand::new("git")
591 .args(["config", "user.email", "test@example.com"])
592 .current_dir(repo_dir)
593 .output()
594 .context("Failed to run git config")?;
595 anyhow::ensure!(
596 output.status.success(),
597 "git config email failed: {}",
598 String::from_utf8_lossy(&output.stderr)
599 );
600
601 let output = StdCommand::new("git")
602 .args(["config", "user.name", "Test User"])
603 .current_dir(repo_dir)
604 .output()
605 .context("Failed to run git config")?;
606 anyhow::ensure!(
607 output.status.success(),
608 "git config name failed: {}",
609 String::from_utf8_lossy(&output.stderr)
610 );
611
612 fs::write(repo_dir.join("README.md"), "# Test")?;
614
615 let output = StdCommand::new("git")
616 .args(["add", "."])
617 .current_dir(repo_dir)
618 .output()
619 .context("Failed to run git add")?;
620 anyhow::ensure!(
621 output.status.success(),
622 "git add failed: {}",
623 String::from_utf8_lossy(&output.stderr)
624 );
625
626 let output = StdCommand::new("git")
627 .args(["commit", "-m", "Initial commit"])
628 .current_dir(repo_dir)
629 .output()
630 .context("Failed to run git commit")?;
631 anyhow::ensure!(
632 output.status.success(),
633 "git commit failed: {}",
634 String::from_utf8_lossy(&output.stderr)
635 );
636
637 Ok(())
638 }
639
640 fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
642 if repo_dir.exists() {
643 fs::remove_dir_all(repo_dir)?;
644 }
645 Ok(())
646 }
647
648 #[test]
649 #[serial_test::serial]
650 fn test_create_worktree_branch_already_exists() -> Result<()> {
651 let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
652 cleanup_test_repo(&repo_dir)?;
653 setup_test_repo(&repo_dir)?;
654
655 let original_dir = std::env::current_dir()?;
656
657 let result = {
658 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
659
660 let spec_id = "test-spec-branch-exists";
661 let branch = "spec/test-spec-branch-exists";
662
663 let output = StdCommand::new("git")
665 .args(["branch", branch])
666 .current_dir(&repo_dir)
667 .output()?;
668 anyhow::ensure!(
669 output.status.success(),
670 "git branch failed: {}",
671 String::from_utf8_lossy(&output.stderr)
672 );
673
674 create_worktree(spec_id, branch)
675 };
676
677 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
679 cleanup_test_repo(&repo_dir)?;
680
681 assert!(
683 result.is_ok(),
684 "create_worktree should auto-clean and succeed"
685 );
686 Ok(())
687 }
688
689 #[test]
690 #[serial_test::serial]
691 fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
692 let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
693 cleanup_test_repo(&repo_dir)?;
694 setup_test_repo(&repo_dir)?;
695
696 let original_dir = std::env::current_dir()?;
697
698 let result = {
699 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
700
701 let branch = "feature/conflict-test";
702
703 let output = StdCommand::new("git")
705 .args(["branch", branch])
706 .current_dir(&repo_dir)
707 .output()?;
708 anyhow::ensure!(
709 output.status.success(),
710 "git branch failed: {}",
711 String::from_utf8_lossy(&output.stderr)
712 );
713
714 let output = StdCommand::new("git")
715 .args(["checkout", branch])
716 .current_dir(&repo_dir)
717 .output()?;
718 anyhow::ensure!(
719 output.status.success(),
720 "git checkout branch failed: {}",
721 String::from_utf8_lossy(&output.stderr)
722 );
723
724 fs::write(repo_dir.join("README.md"), "feature version")?;
725
726 let output = StdCommand::new("git")
727 .args(["add", "."])
728 .current_dir(&repo_dir)
729 .output()?;
730 anyhow::ensure!(
731 output.status.success(),
732 "git add failed: {}",
733 String::from_utf8_lossy(&output.stderr)
734 );
735
736 let output = StdCommand::new("git")
737 .args(["commit", "-m", "Modify README on feature"])
738 .current_dir(&repo_dir)
739 .output()?;
740 anyhow::ensure!(
741 output.status.success(),
742 "git commit feature failed: {}",
743 String::from_utf8_lossy(&output.stderr)
744 );
745
746 let output = StdCommand::new("git")
748 .args(["checkout", "main"])
749 .current_dir(&repo_dir)
750 .output()?;
751 anyhow::ensure!(
752 output.status.success(),
753 "git checkout main failed: {}",
754 String::from_utf8_lossy(&output.stderr)
755 );
756
757 fs::write(repo_dir.join("README.md"), "main version")?;
758
759 let output = StdCommand::new("git")
760 .args(["add", "."])
761 .current_dir(&repo_dir)
762 .output()?;
763 anyhow::ensure!(
764 output.status.success(),
765 "git add main failed: {}",
766 String::from_utf8_lossy(&output.stderr)
767 );
768
769 let output = StdCommand::new("git")
770 .args(["commit", "-m", "Modify README on main"])
771 .current_dir(&repo_dir)
772 .output()?;
773 anyhow::ensure!(
774 output.status.success(),
775 "git commit main failed: {}",
776 String::from_utf8_lossy(&output.stderr)
777 );
778
779 merge_and_cleanup_in_dir(branch, Some(&repo_dir), false)
781 };
782
783 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
785
786 let branch_check = StdCommand::new("git")
788 .args(["rev-parse", "--verify", "feature/conflict-test"])
789 .current_dir(&repo_dir)
790 .output()?;
791
792 cleanup_test_repo(&repo_dir)?;
793
794 assert!(!result.success);
796 assert!(branch_check.status.success());
798 Ok(())
799 }
800
801 #[test]
802 #[serial_test::serial]
803 fn test_merge_and_cleanup_successful_merge() -> Result<()> {
804 let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
805 cleanup_test_repo(&repo_dir)?;
806 setup_test_repo(&repo_dir)?;
807
808 let original_dir = std::env::current_dir()?;
809
810 let result = {
811 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
812
813 let branch = "feature/new-feature";
814
815 let output = StdCommand::new("git")
817 .args(["branch", branch])
818 .current_dir(&repo_dir)
819 .output()?;
820 anyhow::ensure!(
821 output.status.success(),
822 "git branch failed: {}",
823 String::from_utf8_lossy(&output.stderr)
824 );
825
826 let output = StdCommand::new("git")
827 .args(["checkout", branch])
828 .current_dir(&repo_dir)
829 .output()?;
830 anyhow::ensure!(
831 output.status.success(),
832 "git checkout failed: {}",
833 String::from_utf8_lossy(&output.stderr)
834 );
835
836 fs::write(repo_dir.join("feature.txt"), "feature content")?;
837
838 let output = StdCommand::new("git")
839 .args(["add", "."])
840 .current_dir(&repo_dir)
841 .output()?;
842 anyhow::ensure!(
843 output.status.success(),
844 "git add failed: {}",
845 String::from_utf8_lossy(&output.stderr)
846 );
847
848 let output = StdCommand::new("git")
849 .args(["commit", "-m", "Add feature"])
850 .current_dir(&repo_dir)
851 .output()?;
852 anyhow::ensure!(
853 output.status.success(),
854 "git commit failed: {}",
855 String::from_utf8_lossy(&output.stderr)
856 );
857
858 merge_and_cleanup_in_dir(branch, Some(&repo_dir), false)
860 };
861
862 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
864
865 let branch_check = StdCommand::new("git")
867 .args(["rev-parse", "--verify", "feature/new-feature"])
868 .current_dir(&repo_dir)
869 .output()?;
870
871 cleanup_test_repo(&repo_dir)?;
872
873 assert!(
874 result.success && result.error.is_none(),
875 "Merge result: {:?}",
876 result
877 );
878 assert!(!branch_check.status.success());
880 Ok(())
881 }
882
883 #[test]
884 #[serial_test::serial]
885 fn test_create_worktree_success() -> Result<()> {
886 let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
887 cleanup_test_repo(&repo_dir)?;
888 setup_test_repo(&repo_dir)?;
889
890 let original_dir = std::env::current_dir()?;
891
892 let result = {
893 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
894
895 let spec_id = "test-spec-create-success";
896 let branch = "spec/test-spec-create-success";
897
898 create_worktree(spec_id, branch)
899 };
900
901 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
903
904 assert!(result.is_ok(), "create_worktree should succeed");
906 let worktree_path = result.unwrap();
907 assert!(worktree_path.exists(), "Worktree directory should exist");
908 assert_eq!(
909 worktree_path,
910 PathBuf::from("/tmp/chant-test-spec-create-success"),
911 "Worktree path should match expected format"
912 );
913
914 let branch_check = StdCommand::new("git")
916 .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
917 .current_dir(&repo_dir)
918 .output()?;
919 assert!(branch_check.status.success(), "Branch should exist");
920
921 let _ = StdCommand::new("git")
923 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
924 .current_dir(&repo_dir)
925 .output();
926 let _ = fs::remove_dir_all(&worktree_path);
927 cleanup_test_repo(&repo_dir)?;
928
929 Ok(())
930 }
931
932 #[test]
933 #[serial_test::serial]
934 fn test_copy_spec_to_worktree_success() -> Result<()> {
935 let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
936 cleanup_test_repo(&repo_dir)?;
937 setup_test_repo(&repo_dir)?;
938
939 let original_dir = std::env::current_dir()?;
940
941 let result: Result<PathBuf> = {
942 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
943
944 let spec_id = "test-spec-copy";
945 let branch = "spec/test-spec-copy";
946
947 let specs_dir = repo_dir.join(".chant/specs");
949 fs::create_dir_all(&specs_dir)?;
950
951 let spec_path = specs_dir.join(format!("{}.md", spec_id));
953 fs::write(
954 &spec_path,
955 "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
956 )?;
957
958 let worktree_path = create_worktree(spec_id, branch)?;
960
961 copy_spec_to_worktree(spec_id, &worktree_path)?;
963
964 let worktree_spec_path = worktree_path
966 .join(".chant/specs")
967 .join(format!("{}.md", spec_id));
968 assert!(
969 worktree_spec_path.exists(),
970 "Spec file should exist in worktree"
971 );
972
973 let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
974 assert!(
975 worktree_spec_content.contains("in_progress"),
976 "Spec should contain in_progress status"
977 );
978
979 let log_output = StdCommand::new("git")
981 .args(["log", "--oneline", "-n", "1"])
982 .current_dir(&worktree_path)
983 .output()?;
984 let log = String::from_utf8_lossy(&log_output.stdout);
985 assert!(
986 log.contains("update spec status"),
987 "Commit message should mention spec update"
988 );
989
990 Ok(worktree_path)
991 };
992
993 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
995
996 if let Ok(worktree_path) = result {
998 let _ = StdCommand::new("git")
999 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1000 .current_dir(&repo_dir)
1001 .output();
1002 let _ = fs::remove_dir_all(&worktree_path);
1003 }
1004 cleanup_test_repo(&repo_dir)?;
1005
1006 Ok(())
1007 }
1008
1009 #[test]
1010 #[serial_test::serial]
1011 fn test_remove_worktree_success() -> Result<()> {
1012 let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1013 cleanup_test_repo(&repo_dir)?;
1014 setup_test_repo(&repo_dir)?;
1015
1016 let original_dir = std::env::current_dir()?;
1017
1018 let worktree_path = {
1019 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1020
1021 let spec_id = "test-spec-remove";
1022 let branch = "spec/test-spec-remove";
1023
1024 let path = create_worktree(spec_id, branch)?;
1026 assert!(path.exists(), "Worktree should exist after creation");
1027 path
1028 };
1029
1030 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1032
1033 let result = remove_worktree(&worktree_path);
1035 assert!(result.is_ok(), "remove_worktree should succeed");
1036
1037 assert!(
1039 !worktree_path.exists(),
1040 "Worktree directory should be removed"
1041 );
1042
1043 cleanup_test_repo(&repo_dir)?;
1044 Ok(())
1045 }
1046
1047 #[test]
1048 fn test_remove_worktree_idempotent() -> Result<()> {
1049 let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1050
1051 let result = remove_worktree(&path);
1053
1054 assert!(result.is_ok());
1055 Ok(())
1056 }
1057
1058 #[test]
1059 fn test_worktree_path_for_spec() {
1060 let path = worktree_path_for_spec("2026-01-27-001-abc");
1061 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1062 }
1063
1064 #[test]
1065 fn test_get_active_worktree_nonexistent() {
1066 let result = get_active_worktree("nonexistent-spec-12345");
1068 assert!(result.is_none());
1069 }
1070
1071 #[test]
1072 #[serial_test::serial]
1073 fn test_commit_in_worktree() -> Result<()> {
1074 let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1075 cleanup_test_repo(&repo_dir)?;
1076 setup_test_repo(&repo_dir)?;
1077
1078 fs::write(repo_dir.join("new_file.txt"), "content")?;
1080
1081 let result = commit_in_worktree(&repo_dir, "Test commit message");
1083
1084 cleanup_test_repo(&repo_dir)?;
1085
1086 assert!(result.is_ok());
1087 let hash = result.unwrap();
1088 assert_eq!(hash.len(), 40);
1090 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1091
1092 Ok(())
1093 }
1094
1095 #[test]
1096 #[serial_test::serial]
1097 fn test_commit_in_worktree_no_changes() -> Result<()> {
1098 let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1099 cleanup_test_repo(&repo_dir)?;
1100 setup_test_repo(&repo_dir)?;
1101
1102 let result = commit_in_worktree(&repo_dir, "Empty commit");
1104
1105 cleanup_test_repo(&repo_dir)?;
1106
1107 assert!(result.is_ok());
1109 let hash = result.unwrap();
1110 assert_eq!(hash.len(), 40);
1111
1112 Ok(())
1113 }
1114
1115 #[test]
1116 #[serial_test::serial]
1117 fn test_has_uncommitted_changes_clean() -> Result<()> {
1118 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1119 cleanup_test_repo(&repo_dir)?;
1120 setup_test_repo(&repo_dir)?;
1121
1122 let has_changes = has_uncommitted_changes(&repo_dir)?;
1124
1125 cleanup_test_repo(&repo_dir)?;
1126
1127 assert!(
1128 !has_changes,
1129 "Clean repo should have no uncommitted changes"
1130 );
1131
1132 Ok(())
1133 }
1134
1135 #[test]
1136 #[serial_test::serial]
1137 fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1138 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1139 cleanup_test_repo(&repo_dir)?;
1140 setup_test_repo(&repo_dir)?;
1141
1142 fs::write(repo_dir.join("newfile.txt"), "content")?;
1144
1145 let has_changes = has_uncommitted_changes(&repo_dir)?;
1147
1148 cleanup_test_repo(&repo_dir)?;
1149
1150 assert!(has_changes, "Repo with unstaged changes should return true");
1151
1152 Ok(())
1153 }
1154
1155 #[test]
1156 #[serial_test::serial]
1157 fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1158 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1159 cleanup_test_repo(&repo_dir)?;
1160 setup_test_repo(&repo_dir)?;
1161
1162 fs::write(repo_dir.join("newfile.txt"), "content")?;
1164 let output = StdCommand::new("git")
1165 .args(["add", "newfile.txt"])
1166 .current_dir(&repo_dir)
1167 .output()?;
1168 assert!(output.status.success());
1169
1170 let has_changes = has_uncommitted_changes(&repo_dir)?;
1172
1173 cleanup_test_repo(&repo_dir)?;
1174
1175 assert!(has_changes, "Repo with staged changes should return true");
1176
1177 Ok(())
1178 }
1179}