1use anyhow::{Context, Result};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::Mutex;
15use std::time::Duration;
16
17fn run_git_in_dir(args: &[&str], work_dir: Option<&Path>) -> Result<String> {
23 let mut cmd = Command::new("git");
24 cmd.args(args);
25 if let Some(dir) = work_dir {
26 cmd.current_dir(dir);
27 }
28
29 let output = cmd
30 .output()
31 .context(format!("Failed to run git {}", args.join(" ")))?;
32
33 if !output.status.success() {
34 let stderr = String::from_utf8_lossy(&output.stderr);
35 anyhow::bail!("git {} failed: {}", args.join(" "), stderr);
36 }
37
38 Ok(String::from_utf8_lossy(&output.stdout).to_string())
39}
40
41static WORKTREE_CREATION_LOCK: Mutex<()> = Mutex::new(());
46
47pub fn worktree_path_for_spec(spec_id: &str, project_name: Option<&str>) -> PathBuf {
51 match project_name.filter(|n| !n.is_empty()) {
52 Some(name) => PathBuf::from(format!("/tmp/chant-{}-{}", name, spec_id)),
53 None => PathBuf::from(format!("/tmp/chant-{}", spec_id)),
54 }
55}
56
57pub fn get_active_worktree(spec_id: &str, project_name: Option<&str>) -> Option<PathBuf> {
61 let path = worktree_path_for_spec(spec_id, project_name);
62 if path.exists() && path.is_dir() {
63 Some(path)
64 } else {
65 None
66 }
67}
68
69pub fn has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
79 let status_output = run_git_in_dir(&["status", "--porcelain"], Some(worktree_path))?;
80 Ok(!status_output.trim().is_empty())
81}
82
83pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
94 run_git_in_dir(&["add", "-A"], Some(worktree_path))
96 .context("Failed to stage changes in worktree")?;
97
98 let status_output = run_git_in_dir(&["status", "--porcelain"], Some(worktree_path))?;
100 if status_output.trim().is_empty() {
101 let hash = run_git_in_dir(&["rev-parse", "HEAD"], Some(worktree_path))?;
103 return Ok(hash.trim().to_string());
104 }
105
106 run_git_in_dir(&["commit", "-m", message], Some(worktree_path))
108 .context("Failed to commit changes in worktree")?;
109
110 let hash = run_git_in_dir(&["rev-parse", "HEAD"], Some(worktree_path))?;
112 Ok(hash.trim().to_string())
113}
114
115pub fn create_worktree(spec_id: &str, branch: &str, project_name: Option<&str>) -> Result<PathBuf> {
137 let _lock = WORKTREE_CREATION_LOCK
139 .lock()
140 .map_err(|e| anyhow::anyhow!("Failed to acquire worktree creation lock: {}", e))?;
141
142 let worktree_path = worktree_path_for_spec(spec_id, project_name);
143
144 if worktree_path.exists() {
146 let _ = Command::new("git")
148 .args([
149 "worktree",
150 "remove",
151 "--force",
152 &worktree_path.to_string_lossy(),
153 ])
154 .output();
155
156 if worktree_path.exists() {
158 let _ = std::fs::remove_dir_all(&worktree_path);
159 }
160 }
161
162 let output = Command::new("git")
164 .args(["rev-parse", "--verify", branch])
165 .output()
166 .context("Failed to check if branch exists")?;
167
168 if output.status.success() {
169 let _ = Command::new("git").args(["branch", "-D", branch]).output();
171 }
172
173 let max_attempts = 2;
175 let mut last_error = None;
176
177 for attempt in 1..=max_attempts {
178 let output = Command::new("git")
179 .args([
180 "worktree",
181 "add",
182 "-b",
183 branch,
184 &worktree_path.to_string_lossy(),
185 ])
186 .output()
187 .context("Failed to execute git worktree add")?;
188
189 if output.status.success() {
190 return Ok(worktree_path);
191 }
192
193 let stderr = String::from_utf8_lossy(&output.stderr);
194 let error_msg = stderr.trim().to_string();
195
196 let is_lock_error = error_msg.contains("lock")
198 || error_msg.contains("unable to create")
199 || error_msg.contains("already exists");
200
201 last_error = Some(error_msg.clone());
202
203 if is_lock_error && attempt < max_attempts {
204 eprintln!(
206 "⚠️ [{}] Worktree creation failed (attempt {}/{}): {}. Retrying...",
207 spec_id, attempt, max_attempts, error_msg
208 );
209 std::thread::sleep(Duration::from_millis(250));
210 } else {
211 break;
213 }
214 }
215
216 let error_msg = last_error.unwrap_or_else(|| "Unknown error".to_string());
218 anyhow::bail!(
219 "Failed to create worktree after {} attempts: {}",
220 max_attempts,
221 error_msg
222 )
223}
224
225pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
246 let git_root = std::env::current_dir().context("Failed to get current directory")?;
248 let main_spec_path = git_root
249 .join(".chant/specs")
250 .join(format!("{}.md", spec_id));
251 let worktree_specs_dir = worktree_path.join(".chant/specs");
252 let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
253
254 std::fs::create_dir_all(&worktree_specs_dir).context(format!(
256 "Failed to create specs directory in worktree: {:?}",
257 worktree_specs_dir
258 ))?;
259
260 std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
262 "Failed to copy spec file to worktree: {:?}",
263 worktree_spec_path
264 ))?;
265
266 commit_in_worktree(
268 worktree_path,
269 &format!("chant({}): update spec status to in_progress", spec_id),
270 )?;
271
272 Ok(())
273}
274
275pub fn isolate_worktree_specs(spec_id: &str, worktree_path: &Path) -> Result<()> {
295 let worktree_specs_dir = worktree_path.join(".chant/specs");
296 let worktree_archive_dir = worktree_path.join(".chant/archive");
297 let working_spec_filename = format!("{}.md", spec_id);
298
299 if worktree_archive_dir.exists() {
301 std::fs::remove_dir_all(&worktree_archive_dir).context(format!(
302 "Failed to remove archive directory in worktree: {:?}",
303 worktree_archive_dir
304 ))?;
305 }
306
307 if worktree_specs_dir.exists() {
309 let entries = std::fs::read_dir(&worktree_specs_dir).context(format!(
310 "Failed to read specs directory in worktree: {:?}",
311 worktree_specs_dir
312 ))?;
313
314 for entry in entries {
315 let entry = entry.context("Failed to read directory entry")?;
316 let path = entry.path();
317
318 if !path.is_file() {
320 continue;
321 }
322
323 if let Some(filename) = path.file_name() {
325 if filename == working_spec_filename.as_str() {
326 continue;
327 }
328 }
329
330 std::fs::remove_file(&path).context(format!(
332 "Failed to remove spec file in worktree: {:?}",
333 path
334 ))?;
335 }
336 }
337
338 Ok(())
339}
340
341pub fn remove_worktree(path: &Path) -> Result<()> {
353 let _output = Command::new("git")
355 .args(["worktree", "remove", &path.to_string_lossy()])
356 .output()
357 .context("Failed to run git worktree remove")?;
358
359 if path.exists() {
361 std::fs::remove_dir_all(path)
362 .context(format!("Failed to remove worktree directory at {:?}", path))?;
363 }
364
365 Ok(())
366}
367
368#[derive(Debug, Clone)]
370pub struct MergeCleanupResult {
371 pub success: bool,
372 pub has_conflict: bool,
373 pub error: Option<String>,
374}
375
376fn branch_is_behind_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<bool> {
388 let mut cmd = Command::new("git");
389 cmd.args([
390 "rev-list",
391 "--count",
392 &format!("{}..{}", branch, main_branch),
393 ]);
394 if let Some(dir) = work_dir {
395 cmd.current_dir(dir);
396 }
397 let output = cmd
398 .output()
399 .context("Failed to check if branch is behind main")?;
400
401 if !output.status.success() {
402 let stderr = String::from_utf8_lossy(&output.stderr);
403 anyhow::bail!("Failed to check branch status: {}", stderr);
404 }
405
406 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
407 let count: i32 = count_str
408 .parse()
409 .context(format!("Failed to parse commit count: {}", count_str))?;
410 Ok(count > 0)
411}
412
413fn rebase_branch_onto_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<()> {
425 let mut cmd = Command::new("git");
427 cmd.args(["checkout", branch]);
428 if let Some(dir) = work_dir {
429 cmd.current_dir(dir);
430 }
431 let output = cmd
432 .output()
433 .context("Failed to checkout branch for rebase")?;
434
435 if !output.status.success() {
436 let stderr = String::from_utf8_lossy(&output.stderr);
437 anyhow::bail!("Failed to checkout branch: {}", stderr);
438 }
439
440 let mut cmd = Command::new("git");
442 cmd.args(["rebase", main_branch]);
443 if let Some(dir) = work_dir {
444 cmd.current_dir(dir);
445 }
446 let output = cmd.output().context("Failed to rebase onto main")?;
447
448 if !output.status.success() {
449 anyhow::bail!("Rebase had conflicts");
450 }
451
452 let mut cmd = Command::new("git");
454 cmd.args(["checkout", main_branch]);
455 if let Some(dir) = work_dir {
456 cmd.current_dir(dir);
457 }
458 let output = cmd
459 .output()
460 .context("Failed to checkout main after rebase")?;
461
462 if !output.status.success() {
463 let stderr = String::from_utf8_lossy(&output.stderr);
464 anyhow::bail!("Failed to checkout main: {}", stderr);
465 }
466
467 Ok(())
468}
469
470fn abort_rebase(main_branch: &str, work_dir: Option<&Path>) {
479 let mut cmd = Command::new("git");
481 cmd.args(["rebase", "--abort"]);
482 if let Some(dir) = work_dir {
483 cmd.current_dir(dir);
484 }
485 let _ = cmd.output();
486
487 let mut cmd = Command::new("git");
489 cmd.args(["checkout", main_branch]);
490 if let Some(dir) = work_dir {
491 cmd.current_dir(dir);
492 }
493 let _ = cmd.output();
494}
495
496pub fn merge_and_cleanup(branch: &str, main_branch: &str, no_rebase: bool) -> MergeCleanupResult {
513 merge_and_cleanup_in_dir(branch, main_branch, None, no_rebase)
514}
515
516fn merge_and_cleanup_in_dir(
518 branch: &str,
519 main_branch: &str,
520 work_dir: Option<&Path>,
521 no_rebase: bool,
522) -> MergeCleanupResult {
523 let mut cmd = Command::new("git");
525 cmd.args(["checkout", main_branch]);
526 if let Some(dir) = work_dir {
527 cmd.current_dir(dir);
528 }
529 let output = match cmd.output() {
530 Ok(o) => o,
531 Err(e) => {
532 return MergeCleanupResult {
533 success: false,
534 has_conflict: false,
535 error: Some(format!("Failed to checkout {}: {}", main_branch, e)),
536 };
537 }
538 };
539
540 if !output.status.success() {
541 let stderr = String::from_utf8_lossy(&output.stderr);
542 let _ = crate::git::ensure_on_main_branch(main_branch);
544 return MergeCleanupResult {
545 success: false,
546 has_conflict: false,
547 error: Some(format!("Failed to checkout {}: {}", main_branch, stderr)),
548 };
549 }
550
551 if !no_rebase {
553 match branch_is_behind_main(branch, main_branch, work_dir) {
554 Ok(true) => {
555 println!(
557 "Branch '{}' is behind {}, attempting automatic rebase...",
558 branch, main_branch
559 );
560 match rebase_branch_onto_main(branch, main_branch, work_dir) {
561 Ok(()) => {
562 println!("Rebase succeeded, proceeding with merge...");
563 }
564 Err(e) => {
565 abort_rebase(main_branch, work_dir);
567 return MergeCleanupResult {
568 success: false,
569 has_conflict: true,
570 error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
571 };
572 }
573 }
574 }
575 Ok(false) => {
576 }
578 Err(e) => {
579 eprintln!(
581 "Warning: Failed to check if branch is behind {}: {}",
582 main_branch, e
583 );
584 }
585 }
586 }
587
588 let mut cmd = Command::new("git");
590 cmd.args(["merge", "--ff-only", branch]);
591 if let Some(dir) = work_dir {
592 cmd.current_dir(dir);
593 }
594 let output = match cmd.output() {
595 Ok(o) => o,
596 Err(e) => {
597 return MergeCleanupResult {
598 success: false,
599 has_conflict: false,
600 error: Some(format!("Failed to perform merge: {}", e)),
601 };
602 }
603 };
604
605 if !output.status.success() {
606 let stderr = String::from_utf8_lossy(&output.stderr);
607 let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
609
610 if has_conflict {
612 let mut cmd = Command::new("git");
613 cmd.args(["merge", "--abort"]);
614 if let Some(dir) = work_dir {
615 cmd.current_dir(dir);
616 }
617 let _ = cmd.output();
618 }
619
620 let spec_id = branch.rsplit('/').next().unwrap_or(branch);
622 let error_msg = if has_conflict {
623 crate::merge_errors::merge_conflict(spec_id, branch, main_branch)
624 } else {
625 crate::merge_errors::fast_forward_conflict(spec_id, branch, main_branch, &stderr)
626 };
627 let _ = crate::git::ensure_on_main_branch(main_branch);
629 return MergeCleanupResult {
630 success: false,
631 has_conflict,
632 error: Some(error_msg),
633 };
634 }
635
636 let mut cmd = Command::new("git");
638 cmd.args(["branch", "-d", branch]);
639 if let Some(dir) = work_dir {
640 cmd.current_dir(dir);
641 }
642 let output = match cmd.output() {
643 Ok(o) => o,
644 Err(e) => {
645 return MergeCleanupResult {
646 success: false,
647 has_conflict: false,
648 error: Some(format!("Failed to delete branch: {}", e)),
649 };
650 }
651 };
652
653 if !output.status.success() {
654 let stderr = String::from_utf8_lossy(&output.stderr);
655 return MergeCleanupResult {
656 success: false,
657 has_conflict: false,
658 error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
659 };
660 }
661
662 let mut cmd = Command::new("git");
664 cmd.args(["push", "origin", "--delete", branch]);
665 if let Some(dir) = work_dir {
666 cmd.current_dir(dir);
667 }
668 let _ = cmd.output();
670
671 MergeCleanupResult {
672 success: true,
673 has_conflict: false,
674 error: None,
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use std::fs;
682 use std::process::Command as StdCommand;
683
684 fn setup_test_repo(repo_dir: &Path) -> Result<()> {
686 fs::create_dir_all(repo_dir)?;
687
688 let output = StdCommand::new("git")
689 .args(["init", "-b", "main"])
690 .current_dir(repo_dir)
691 .output()
692 .context("Failed to run git init")?;
693 anyhow::ensure!(
694 output.status.success(),
695 "git init failed: {}",
696 String::from_utf8_lossy(&output.stderr)
697 );
698
699 let output = StdCommand::new("git")
700 .args(["config", "user.email", "test@example.com"])
701 .current_dir(repo_dir)
702 .output()
703 .context("Failed to run git config")?;
704 anyhow::ensure!(
705 output.status.success(),
706 "git config email failed: {}",
707 String::from_utf8_lossy(&output.stderr)
708 );
709
710 let output = StdCommand::new("git")
711 .args(["config", "user.name", "Test User"])
712 .current_dir(repo_dir)
713 .output()
714 .context("Failed to run git config")?;
715 anyhow::ensure!(
716 output.status.success(),
717 "git config name failed: {}",
718 String::from_utf8_lossy(&output.stderr)
719 );
720
721 fs::write(repo_dir.join("README.md"), "# Test")?;
723
724 let output = StdCommand::new("git")
725 .args(["add", "."])
726 .current_dir(repo_dir)
727 .output()
728 .context("Failed to run git add")?;
729 anyhow::ensure!(
730 output.status.success(),
731 "git add failed: {}",
732 String::from_utf8_lossy(&output.stderr)
733 );
734
735 let output = StdCommand::new("git")
736 .args(["commit", "-m", "Initial commit"])
737 .current_dir(repo_dir)
738 .output()
739 .context("Failed to run git commit")?;
740 anyhow::ensure!(
741 output.status.success(),
742 "git commit failed: {}",
743 String::from_utf8_lossy(&output.stderr)
744 );
745
746 Ok(())
747 }
748
749 fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
751 if repo_dir.exists() {
752 fs::remove_dir_all(repo_dir)?;
753 }
754 Ok(())
755 }
756
757 #[test]
758 #[serial_test::serial]
759 fn test_create_worktree_branch_already_exists() -> Result<()> {
760 let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
761 cleanup_test_repo(&repo_dir)?;
762 setup_test_repo(&repo_dir)?;
763
764 let original_dir = std::env::current_dir()?;
765
766 let result = {
767 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
768
769 let spec_id = "test-spec-branch-exists";
770 let branch = "spec/test-spec-branch-exists";
771
772 let output = StdCommand::new("git")
774 .args(["branch", branch])
775 .current_dir(&repo_dir)
776 .output()?;
777 anyhow::ensure!(
778 output.status.success(),
779 "git branch failed: {}",
780 String::from_utf8_lossy(&output.stderr)
781 );
782
783 create_worktree(spec_id, branch, None)
784 };
785
786 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
788 cleanup_test_repo(&repo_dir)?;
789
790 assert!(
792 result.is_ok(),
793 "create_worktree should auto-clean and succeed"
794 );
795 Ok(())
796 }
797
798 #[test]
799 #[serial_test::serial]
800 fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
801 let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
802 cleanup_test_repo(&repo_dir)?;
803 setup_test_repo(&repo_dir)?;
804
805 let original_dir = std::env::current_dir()?;
806
807 let result = {
808 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
809
810 let branch = "feature/conflict-test";
811
812 let output = StdCommand::new("git")
814 .args(["branch", branch])
815 .current_dir(&repo_dir)
816 .output()?;
817 anyhow::ensure!(
818 output.status.success(),
819 "git branch failed: {}",
820 String::from_utf8_lossy(&output.stderr)
821 );
822
823 let output = StdCommand::new("git")
824 .args(["checkout", branch])
825 .current_dir(&repo_dir)
826 .output()?;
827 anyhow::ensure!(
828 output.status.success(),
829 "git checkout branch failed: {}",
830 String::from_utf8_lossy(&output.stderr)
831 );
832
833 fs::write(repo_dir.join("README.md"), "feature version")?;
834
835 let output = StdCommand::new("git")
836 .args(["add", "."])
837 .current_dir(&repo_dir)
838 .output()?;
839 anyhow::ensure!(
840 output.status.success(),
841 "git add failed: {}",
842 String::from_utf8_lossy(&output.stderr)
843 );
844
845 let output = StdCommand::new("git")
846 .args(["commit", "-m", "Modify README on feature"])
847 .current_dir(&repo_dir)
848 .output()?;
849 anyhow::ensure!(
850 output.status.success(),
851 "git commit feature failed: {}",
852 String::from_utf8_lossy(&output.stderr)
853 );
854
855 let output = StdCommand::new("git")
857 .args(["checkout", "main"])
858 .current_dir(&repo_dir)
859 .output()?;
860 anyhow::ensure!(
861 output.status.success(),
862 "git checkout main failed: {}",
863 String::from_utf8_lossy(&output.stderr)
864 );
865
866 fs::write(repo_dir.join("README.md"), "main version")?;
867
868 let output = StdCommand::new("git")
869 .args(["add", "."])
870 .current_dir(&repo_dir)
871 .output()?;
872 anyhow::ensure!(
873 output.status.success(),
874 "git add main failed: {}",
875 String::from_utf8_lossy(&output.stderr)
876 );
877
878 let output = StdCommand::new("git")
879 .args(["commit", "-m", "Modify README on main"])
880 .current_dir(&repo_dir)
881 .output()?;
882 anyhow::ensure!(
883 output.status.success(),
884 "git commit main failed: {}",
885 String::from_utf8_lossy(&output.stderr)
886 );
887
888 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
890 };
891
892 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
894
895 let branch_check = StdCommand::new("git")
897 .args(["rev-parse", "--verify", "feature/conflict-test"])
898 .current_dir(&repo_dir)
899 .output()?;
900
901 cleanup_test_repo(&repo_dir)?;
902
903 assert!(!result.success);
905 assert!(branch_check.status.success());
907 Ok(())
908 }
909
910 #[test]
911 #[serial_test::serial]
912 fn test_merge_and_cleanup_successful_merge() -> Result<()> {
913 let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
914 cleanup_test_repo(&repo_dir)?;
915 setup_test_repo(&repo_dir)?;
916
917 let original_dir = std::env::current_dir()?;
918
919 let result = {
920 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
921
922 let branch = "feature/new-feature";
923
924 let output = StdCommand::new("git")
926 .args(["branch", branch])
927 .current_dir(&repo_dir)
928 .output()?;
929 anyhow::ensure!(
930 output.status.success(),
931 "git branch failed: {}",
932 String::from_utf8_lossy(&output.stderr)
933 );
934
935 let output = StdCommand::new("git")
936 .args(["checkout", branch])
937 .current_dir(&repo_dir)
938 .output()?;
939 anyhow::ensure!(
940 output.status.success(),
941 "git checkout failed: {}",
942 String::from_utf8_lossy(&output.stderr)
943 );
944
945 fs::write(repo_dir.join("feature.txt"), "feature content")?;
946
947 let output = StdCommand::new("git")
948 .args(["add", "."])
949 .current_dir(&repo_dir)
950 .output()?;
951 anyhow::ensure!(
952 output.status.success(),
953 "git add failed: {}",
954 String::from_utf8_lossy(&output.stderr)
955 );
956
957 let output = StdCommand::new("git")
958 .args(["commit", "-m", "Add feature"])
959 .current_dir(&repo_dir)
960 .output()?;
961 anyhow::ensure!(
962 output.status.success(),
963 "git commit failed: {}",
964 String::from_utf8_lossy(&output.stderr)
965 );
966
967 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
969 };
970
971 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
973
974 let branch_check = StdCommand::new("git")
976 .args(["rev-parse", "--verify", "feature/new-feature"])
977 .current_dir(&repo_dir)
978 .output()?;
979
980 cleanup_test_repo(&repo_dir)?;
981
982 assert!(
983 result.success && result.error.is_none(),
984 "Merge result: {:?}",
985 result
986 );
987 assert!(!branch_check.status.success());
989 Ok(())
990 }
991
992 #[test]
993 #[serial_test::serial]
994 fn test_create_worktree_success() -> Result<()> {
995 let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
996 cleanup_test_repo(&repo_dir)?;
997 setup_test_repo(&repo_dir)?;
998
999 let original_dir = std::env::current_dir()?;
1000
1001 let result = {
1002 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1003
1004 let spec_id = "test-spec-create-success";
1005 let branch = "spec/test-spec-create-success";
1006
1007 create_worktree(spec_id, branch, None)
1008 };
1009
1010 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1012
1013 assert!(result.is_ok(), "create_worktree should succeed");
1015 let worktree_path = result.unwrap();
1016 assert!(worktree_path.exists(), "Worktree directory should exist");
1017 assert_eq!(
1018 worktree_path,
1019 PathBuf::from("/tmp/chant-test-spec-create-success"),
1020 "Worktree path should match expected format"
1021 );
1022
1023 let branch_check = StdCommand::new("git")
1025 .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
1026 .current_dir(&repo_dir)
1027 .output()?;
1028 assert!(branch_check.status.success(), "Branch should exist");
1029
1030 let _ = StdCommand::new("git")
1032 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1033 .current_dir(&repo_dir)
1034 .output();
1035 let _ = fs::remove_dir_all(&worktree_path);
1036 cleanup_test_repo(&repo_dir)?;
1037
1038 Ok(())
1039 }
1040
1041 #[test]
1042 #[serial_test::serial]
1043 fn test_copy_spec_to_worktree_success() -> Result<()> {
1044 let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
1045 cleanup_test_repo(&repo_dir)?;
1046 setup_test_repo(&repo_dir)?;
1047
1048 let original_dir = std::env::current_dir()?;
1049
1050 let result: Result<PathBuf> = {
1051 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1052
1053 let spec_id = "test-spec-copy";
1054 let branch = "spec/test-spec-copy";
1055
1056 let specs_dir = repo_dir.join(".chant/specs");
1058 fs::create_dir_all(&specs_dir)?;
1059
1060 let spec_path = specs_dir.join(format!("{}.md", spec_id));
1062 fs::write(
1063 &spec_path,
1064 "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
1065 )?;
1066
1067 let worktree_path = create_worktree(spec_id, branch, None)?;
1069
1070 copy_spec_to_worktree(spec_id, &worktree_path)?;
1072
1073 let worktree_spec_path = worktree_path
1075 .join(".chant/specs")
1076 .join(format!("{}.md", spec_id));
1077 assert!(
1078 worktree_spec_path.exists(),
1079 "Spec file should exist in worktree"
1080 );
1081
1082 let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
1083 assert!(
1084 worktree_spec_content.contains("in_progress"),
1085 "Spec should contain in_progress status"
1086 );
1087
1088 let log_output = StdCommand::new("git")
1090 .args(["log", "--oneline", "-n", "1"])
1091 .current_dir(&worktree_path)
1092 .output()?;
1093 let log = String::from_utf8_lossy(&log_output.stdout);
1094 assert!(
1095 log.contains("update spec status"),
1096 "Commit message should mention spec update"
1097 );
1098
1099 Ok(worktree_path)
1100 };
1101
1102 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1104
1105 if let Ok(worktree_path) = result {
1107 let _ = StdCommand::new("git")
1108 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1109 .current_dir(&repo_dir)
1110 .output();
1111 let _ = fs::remove_dir_all(&worktree_path);
1112 }
1113 cleanup_test_repo(&repo_dir)?;
1114
1115 Ok(())
1116 }
1117
1118 #[test]
1119 #[serial_test::serial]
1120 fn test_remove_worktree_success() -> Result<()> {
1121 let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1122 cleanup_test_repo(&repo_dir)?;
1123 setup_test_repo(&repo_dir)?;
1124
1125 let original_dir = std::env::current_dir()?;
1126
1127 let worktree_path = {
1128 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1129
1130 let spec_id = "test-spec-remove";
1131 let branch = "spec/test-spec-remove";
1132
1133 let path = create_worktree(spec_id, branch, None)?;
1135 assert!(path.exists(), "Worktree should exist after creation");
1136 path
1137 };
1138
1139 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1141
1142 let result = remove_worktree(&worktree_path);
1144 assert!(result.is_ok(), "remove_worktree should succeed");
1145
1146 assert!(
1148 !worktree_path.exists(),
1149 "Worktree directory should be removed"
1150 );
1151
1152 cleanup_test_repo(&repo_dir)?;
1153 Ok(())
1154 }
1155
1156 #[test]
1157 fn test_remove_worktree_idempotent() -> Result<()> {
1158 let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1159
1160 let result = remove_worktree(&path);
1162
1163 assert!(result.is_ok());
1164 Ok(())
1165 }
1166
1167 #[test]
1168 fn test_worktree_path_for_spec() {
1169 let path = worktree_path_for_spec("2026-01-27-001-abc", None);
1170 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1171
1172 let path = worktree_path_for_spec("2026-01-27-001-abc", Some("myproject"));
1173 assert_eq!(
1174 path,
1175 PathBuf::from("/tmp/chant-myproject-2026-01-27-001-abc")
1176 );
1177
1178 let path = worktree_path_for_spec("2026-01-27-001-abc", Some(""));
1179 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1180 }
1181
1182 #[test]
1183 fn test_get_active_worktree_nonexistent() {
1184 let result = get_active_worktree("nonexistent-spec-12345", None);
1186 assert!(result.is_none());
1187 }
1188
1189 #[test]
1190 #[serial_test::serial]
1191 fn test_commit_in_worktree() -> Result<()> {
1192 let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1193 cleanup_test_repo(&repo_dir)?;
1194 setup_test_repo(&repo_dir)?;
1195
1196 fs::write(repo_dir.join("new_file.txt"), "content")?;
1198
1199 let result = commit_in_worktree(&repo_dir, "Test commit message");
1201
1202 cleanup_test_repo(&repo_dir)?;
1203
1204 assert!(result.is_ok());
1205 let hash = result.unwrap();
1206 assert_eq!(hash.len(), 40);
1208 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1209
1210 Ok(())
1211 }
1212
1213 #[test]
1214 #[serial_test::serial]
1215 fn test_commit_in_worktree_no_changes() -> Result<()> {
1216 let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1217 cleanup_test_repo(&repo_dir)?;
1218 setup_test_repo(&repo_dir)?;
1219
1220 let result = commit_in_worktree(&repo_dir, "Empty commit");
1222
1223 cleanup_test_repo(&repo_dir)?;
1224
1225 assert!(result.is_ok());
1227 let hash = result.unwrap();
1228 assert_eq!(hash.len(), 40);
1229
1230 Ok(())
1231 }
1232
1233 #[test]
1234 #[serial_test::serial]
1235 fn test_has_uncommitted_changes_clean() -> Result<()> {
1236 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1237 cleanup_test_repo(&repo_dir)?;
1238 setup_test_repo(&repo_dir)?;
1239
1240 let has_changes = has_uncommitted_changes(&repo_dir)?;
1242
1243 cleanup_test_repo(&repo_dir)?;
1244
1245 assert!(
1246 !has_changes,
1247 "Clean repo should have no uncommitted changes"
1248 );
1249
1250 Ok(())
1251 }
1252
1253 #[test]
1254 #[serial_test::serial]
1255 fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1256 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1257 cleanup_test_repo(&repo_dir)?;
1258 setup_test_repo(&repo_dir)?;
1259
1260 fs::write(repo_dir.join("newfile.txt"), "content")?;
1262
1263 let has_changes = has_uncommitted_changes(&repo_dir)?;
1265
1266 cleanup_test_repo(&repo_dir)?;
1267
1268 assert!(has_changes, "Repo with unstaged changes should return true");
1269
1270 Ok(())
1271 }
1272
1273 #[test]
1274 #[serial_test::serial]
1275 fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1276 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1277 cleanup_test_repo(&repo_dir)?;
1278 setup_test_repo(&repo_dir)?;
1279
1280 fs::write(repo_dir.join("newfile.txt"), "content")?;
1282 let output = StdCommand::new("git")
1283 .args(["add", "newfile.txt"])
1284 .current_dir(&repo_dir)
1285 .output()?;
1286 assert!(output.status.success());
1287
1288 let has_changes = has_uncommitted_changes(&repo_dir)?;
1290
1291 cleanup_test_repo(&repo_dir)?;
1292
1293 assert!(has_changes, "Repo with staged changes should return true");
1294
1295 Ok(())
1296 }
1297}