1use anyhow::{Context, Result};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14use std::sync::Mutex;
15use std::time::Duration;
16
17static WORKTREE_CREATION_LOCK: Mutex<()> = Mutex::new(());
22
23pub fn worktree_path_for_spec(spec_id: &str, project_name: Option<&str>) -> PathBuf {
27 match project_name.filter(|n| !n.is_empty()) {
28 Some(name) => PathBuf::from(format!("/tmp/chant-{}-{}", name, spec_id)),
29 None => PathBuf::from(format!("/tmp/chant-{}", spec_id)),
30 }
31}
32
33pub fn get_active_worktree(spec_id: &str, project_name: Option<&str>) -> Option<PathBuf> {
37 let path = worktree_path_for_spec(spec_id, project_name);
38 if path.exists() && path.is_dir() {
39 Some(path)
40 } else {
41 None
42 }
43}
44
45pub fn has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
55 let output = Command::new("git")
56 .args(["status", "--porcelain"])
57 .current_dir(worktree_path)
58 .output()
59 .context("Failed to check git status in worktree")?;
60
61 if !output.status.success() {
62 let stderr = String::from_utf8_lossy(&output.stderr);
63 anyhow::bail!("Failed to run git status: {}", stderr);
64 }
65
66 let status_output = String::from_utf8_lossy(&output.stdout);
67 Ok(!status_output.trim().is_empty())
68}
69
70pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
81 let output = Command::new("git")
83 .args(["add", "-A"])
84 .current_dir(worktree_path)
85 .output()
86 .context("Failed to stage changes in worktree")?;
87
88 if !output.status.success() {
89 let stderr = String::from_utf8_lossy(&output.stderr);
90 anyhow::bail!("Failed to stage changes: {}", stderr);
91 }
92
93 let output = Command::new("git")
95 .args(["status", "--porcelain"])
96 .current_dir(worktree_path)
97 .output()
98 .context("Failed to check git status in worktree")?;
99
100 let status_output = String::from_utf8_lossy(&output.stdout);
101 if status_output.trim().is_empty() {
102 let output = Command::new("git")
104 .args(["rev-parse", "HEAD"])
105 .current_dir(worktree_path)
106 .output()
107 .context("Failed to get HEAD commit")?;
108
109 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
110 return Ok(hash);
111 }
112
113 let output = Command::new("git")
115 .args(["commit", "-m", message])
116 .current_dir(worktree_path)
117 .output()
118 .context("Failed to commit changes in worktree")?;
119
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 anyhow::bail!("Failed to commit: {}", stderr);
123 }
124
125 let output = Command::new("git")
127 .args(["rev-parse", "HEAD"])
128 .current_dir(worktree_path)
129 .output()
130 .context("Failed to get commit hash")?;
131
132 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
133 Ok(hash)
134}
135
136pub fn create_worktree(spec_id: &str, branch: &str, project_name: Option<&str>) -> Result<PathBuf> {
158 let _lock = WORKTREE_CREATION_LOCK
160 .lock()
161 .map_err(|e| anyhow::anyhow!("Failed to acquire worktree creation lock: {}", e))?;
162
163 let worktree_path = worktree_path_for_spec(spec_id, project_name);
164
165 if worktree_path.exists() {
167 let _ = Command::new("git")
169 .args([
170 "worktree",
171 "remove",
172 "--force",
173 &worktree_path.to_string_lossy(),
174 ])
175 .output();
176
177 if worktree_path.exists() {
179 let _ = std::fs::remove_dir_all(&worktree_path);
180 }
181 }
182
183 let output = Command::new("git")
185 .args(["rev-parse", "--verify", branch])
186 .output()
187 .context("Failed to check if branch exists")?;
188
189 if output.status.success() {
190 let _ = Command::new("git").args(["branch", "-D", branch]).output();
192 }
193
194 let max_attempts = 2;
196 let mut last_error = None;
197
198 for attempt in 1..=max_attempts {
199 let output = Command::new("git")
200 .args([
201 "worktree",
202 "add",
203 "-b",
204 branch,
205 &worktree_path.to_string_lossy(),
206 ])
207 .output()
208 .context("Failed to execute git worktree add")?;
209
210 if output.status.success() {
211 return Ok(worktree_path);
212 }
213
214 let stderr = String::from_utf8_lossy(&output.stderr);
215 let error_msg = stderr.trim().to_string();
216
217 let is_lock_error = error_msg.contains("lock")
219 || error_msg.contains("unable to create")
220 || error_msg.contains("already exists");
221
222 last_error = Some(error_msg.clone());
223
224 if is_lock_error && attempt < max_attempts {
225 eprintln!(
227 "⚠️ [{}] Worktree creation failed (attempt {}/{}): {}. Retrying...",
228 spec_id, attempt, max_attempts, error_msg
229 );
230 std::thread::sleep(Duration::from_millis(250));
231 } else {
232 break;
234 }
235 }
236
237 let error_msg = last_error.unwrap_or_else(|| "Unknown error".to_string());
239 anyhow::bail!(
240 "Failed to create worktree after {} attempts: {}",
241 max_attempts,
242 error_msg
243 )
244}
245
246pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
267 let git_root = std::env::current_dir().context("Failed to get current directory")?;
269 let main_spec_path = git_root
270 .join(".chant/specs")
271 .join(format!("{}.md", spec_id));
272 let worktree_specs_dir = worktree_path.join(".chant/specs");
273 let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
274
275 std::fs::create_dir_all(&worktree_specs_dir).context(format!(
277 "Failed to create specs directory in worktree: {:?}",
278 worktree_specs_dir
279 ))?;
280
281 std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
283 "Failed to copy spec file to worktree: {:?}",
284 worktree_spec_path
285 ))?;
286
287 commit_in_worktree(
289 worktree_path,
290 &format!("chant({}): update spec status to in_progress", spec_id),
291 )?;
292
293 Ok(())
294}
295
296pub fn remove_worktree(path: &Path) -> Result<()> {
308 let _output = Command::new("git")
310 .args(["worktree", "remove", &path.to_string_lossy()])
311 .output()
312 .context("Failed to run git worktree remove")?;
313
314 if path.exists() {
316 std::fs::remove_dir_all(path)
317 .context(format!("Failed to remove worktree directory at {:?}", path))?;
318 }
319
320 Ok(())
321}
322
323#[derive(Debug, Clone)]
325pub struct MergeCleanupResult {
326 pub success: bool,
327 pub has_conflict: bool,
328 pub error: Option<String>,
329}
330
331fn branch_is_behind_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<bool> {
343 let mut cmd = Command::new("git");
344 cmd.args([
345 "rev-list",
346 "--count",
347 &format!("{}..{}", branch, main_branch),
348 ]);
349 if let Some(dir) = work_dir {
350 cmd.current_dir(dir);
351 }
352 let output = cmd
353 .output()
354 .context("Failed to check if branch is behind main")?;
355
356 if !output.status.success() {
357 let stderr = String::from_utf8_lossy(&output.stderr);
358 anyhow::bail!("Failed to check branch status: {}", stderr);
359 }
360
361 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
362 let count: i32 = count_str
363 .parse()
364 .context(format!("Failed to parse commit count: {}", count_str))?;
365 Ok(count > 0)
366}
367
368fn rebase_branch_onto_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<()> {
380 let mut cmd = Command::new("git");
382 cmd.args(["checkout", branch]);
383 if let Some(dir) = work_dir {
384 cmd.current_dir(dir);
385 }
386 let output = cmd
387 .output()
388 .context("Failed to checkout branch for rebase")?;
389
390 if !output.status.success() {
391 let stderr = String::from_utf8_lossy(&output.stderr);
392 anyhow::bail!("Failed to checkout branch: {}", stderr);
393 }
394
395 let mut cmd = Command::new("git");
397 cmd.args(["rebase", main_branch]);
398 if let Some(dir) = work_dir {
399 cmd.current_dir(dir);
400 }
401 let output = cmd.output().context("Failed to rebase onto main")?;
402
403 if !output.status.success() {
404 anyhow::bail!("Rebase had conflicts");
405 }
406
407 let mut cmd = Command::new("git");
409 cmd.args(["checkout", main_branch]);
410 if let Some(dir) = work_dir {
411 cmd.current_dir(dir);
412 }
413 let output = cmd
414 .output()
415 .context("Failed to checkout main after rebase")?;
416
417 if !output.status.success() {
418 let stderr = String::from_utf8_lossy(&output.stderr);
419 anyhow::bail!("Failed to checkout main: {}", stderr);
420 }
421
422 Ok(())
423}
424
425fn abort_rebase(main_branch: &str, work_dir: Option<&Path>) {
434 let mut cmd = Command::new("git");
436 cmd.args(["rebase", "--abort"]);
437 if let Some(dir) = work_dir {
438 cmd.current_dir(dir);
439 }
440 let _ = cmd.output();
441
442 let mut cmd = Command::new("git");
444 cmd.args(["checkout", main_branch]);
445 if let Some(dir) = work_dir {
446 cmd.current_dir(dir);
447 }
448 let _ = cmd.output();
449}
450
451pub fn merge_and_cleanup(branch: &str, main_branch: &str, no_rebase: bool) -> MergeCleanupResult {
468 merge_and_cleanup_in_dir(branch, main_branch, None, no_rebase)
469}
470
471fn merge_and_cleanup_in_dir(
473 branch: &str,
474 main_branch: &str,
475 work_dir: Option<&Path>,
476 no_rebase: bool,
477) -> MergeCleanupResult {
478 let mut cmd = Command::new("git");
480 cmd.args(["checkout", main_branch]);
481 if let Some(dir) = work_dir {
482 cmd.current_dir(dir);
483 }
484 let output = match cmd.output() {
485 Ok(o) => o,
486 Err(e) => {
487 return MergeCleanupResult {
488 success: false,
489 has_conflict: false,
490 error: Some(format!("Failed to checkout {}: {}", main_branch, e)),
491 };
492 }
493 };
494
495 if !output.status.success() {
496 let stderr = String::from_utf8_lossy(&output.stderr);
497 let _ = crate::git::ensure_on_main_branch(main_branch);
499 return MergeCleanupResult {
500 success: false,
501 has_conflict: false,
502 error: Some(format!("Failed to checkout {}: {}", main_branch, stderr)),
503 };
504 }
505
506 if !no_rebase {
508 match branch_is_behind_main(branch, main_branch, work_dir) {
509 Ok(true) => {
510 println!(
512 "Branch '{}' is behind {}, attempting automatic rebase...",
513 branch, main_branch
514 );
515 match rebase_branch_onto_main(branch, main_branch, work_dir) {
516 Ok(()) => {
517 println!("Rebase succeeded, proceeding with merge...");
518 }
519 Err(e) => {
520 abort_rebase(main_branch, work_dir);
522 return MergeCleanupResult {
523 success: false,
524 has_conflict: true,
525 error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
526 };
527 }
528 }
529 }
530 Ok(false) => {
531 }
533 Err(e) => {
534 eprintln!(
536 "Warning: Failed to check if branch is behind {}: {}",
537 main_branch, e
538 );
539 }
540 }
541 }
542
543 let mut cmd = Command::new("git");
545 cmd.args(["merge", "--ff-only", branch]);
546 if let Some(dir) = work_dir {
547 cmd.current_dir(dir);
548 }
549 let output = match cmd.output() {
550 Ok(o) => o,
551 Err(e) => {
552 return MergeCleanupResult {
553 success: false,
554 has_conflict: false,
555 error: Some(format!("Failed to perform merge: {}", e)),
556 };
557 }
558 };
559
560 if !output.status.success() {
561 let stderr = String::from_utf8_lossy(&output.stderr);
562 let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
564
565 if has_conflict {
567 let mut cmd = Command::new("git");
568 cmd.args(["merge", "--abort"]);
569 if let Some(dir) = work_dir {
570 cmd.current_dir(dir);
571 }
572 let _ = cmd.output();
573 }
574
575 let spec_id = branch.rsplit('/').next().unwrap_or(branch);
577 let error_msg = if has_conflict {
578 crate::merge_errors::merge_conflict(spec_id, branch, main_branch)
579 } else {
580 crate::merge_errors::fast_forward_conflict(spec_id, branch, main_branch, &stderr)
581 };
582 let _ = crate::git::ensure_on_main_branch(main_branch);
584 return MergeCleanupResult {
585 success: false,
586 has_conflict,
587 error: Some(error_msg),
588 };
589 }
590
591 let mut cmd = Command::new("git");
593 cmd.args(["branch", "-d", branch]);
594 if let Some(dir) = work_dir {
595 cmd.current_dir(dir);
596 }
597 let output = match cmd.output() {
598 Ok(o) => o,
599 Err(e) => {
600 return MergeCleanupResult {
601 success: false,
602 has_conflict: false,
603 error: Some(format!("Failed to delete branch: {}", e)),
604 };
605 }
606 };
607
608 if !output.status.success() {
609 let stderr = String::from_utf8_lossy(&output.stderr);
610 return MergeCleanupResult {
611 success: false,
612 has_conflict: false,
613 error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
614 };
615 }
616
617 let mut cmd = Command::new("git");
619 cmd.args(["push", "origin", "--delete", branch]);
620 if let Some(dir) = work_dir {
621 cmd.current_dir(dir);
622 }
623 let _ = cmd.output();
625
626 MergeCleanupResult {
627 success: true,
628 has_conflict: false,
629 error: None,
630 }
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use std::fs;
637 use std::process::Command as StdCommand;
638
639 fn setup_test_repo(repo_dir: &Path) -> Result<()> {
641 fs::create_dir_all(repo_dir)?;
642
643 let output = StdCommand::new("git")
644 .args(["init", "-b", "main"])
645 .current_dir(repo_dir)
646 .output()
647 .context("Failed to run git init")?;
648 anyhow::ensure!(
649 output.status.success(),
650 "git init failed: {}",
651 String::from_utf8_lossy(&output.stderr)
652 );
653
654 let output = StdCommand::new("git")
655 .args(["config", "user.email", "test@example.com"])
656 .current_dir(repo_dir)
657 .output()
658 .context("Failed to run git config")?;
659 anyhow::ensure!(
660 output.status.success(),
661 "git config email failed: {}",
662 String::from_utf8_lossy(&output.stderr)
663 );
664
665 let output = StdCommand::new("git")
666 .args(["config", "user.name", "Test User"])
667 .current_dir(repo_dir)
668 .output()
669 .context("Failed to run git config")?;
670 anyhow::ensure!(
671 output.status.success(),
672 "git config name failed: {}",
673 String::from_utf8_lossy(&output.stderr)
674 );
675
676 fs::write(repo_dir.join("README.md"), "# Test")?;
678
679 let output = StdCommand::new("git")
680 .args(["add", "."])
681 .current_dir(repo_dir)
682 .output()
683 .context("Failed to run git add")?;
684 anyhow::ensure!(
685 output.status.success(),
686 "git add failed: {}",
687 String::from_utf8_lossy(&output.stderr)
688 );
689
690 let output = StdCommand::new("git")
691 .args(["commit", "-m", "Initial commit"])
692 .current_dir(repo_dir)
693 .output()
694 .context("Failed to run git commit")?;
695 anyhow::ensure!(
696 output.status.success(),
697 "git commit failed: {}",
698 String::from_utf8_lossy(&output.stderr)
699 );
700
701 Ok(())
702 }
703
704 fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
706 if repo_dir.exists() {
707 fs::remove_dir_all(repo_dir)?;
708 }
709 Ok(())
710 }
711
712 #[test]
713 #[serial_test::serial]
714 fn test_create_worktree_branch_already_exists() -> Result<()> {
715 let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
716 cleanup_test_repo(&repo_dir)?;
717 setup_test_repo(&repo_dir)?;
718
719 let original_dir = std::env::current_dir()?;
720
721 let result = {
722 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
723
724 let spec_id = "test-spec-branch-exists";
725 let branch = "spec/test-spec-branch-exists";
726
727 let output = StdCommand::new("git")
729 .args(["branch", branch])
730 .current_dir(&repo_dir)
731 .output()?;
732 anyhow::ensure!(
733 output.status.success(),
734 "git branch failed: {}",
735 String::from_utf8_lossy(&output.stderr)
736 );
737
738 create_worktree(spec_id, branch, None)
739 };
740
741 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
743 cleanup_test_repo(&repo_dir)?;
744
745 assert!(
747 result.is_ok(),
748 "create_worktree should auto-clean and succeed"
749 );
750 Ok(())
751 }
752
753 #[test]
754 #[serial_test::serial]
755 fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
756 let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
757 cleanup_test_repo(&repo_dir)?;
758 setup_test_repo(&repo_dir)?;
759
760 let original_dir = std::env::current_dir()?;
761
762 let result = {
763 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
764
765 let branch = "feature/conflict-test";
766
767 let output = StdCommand::new("git")
769 .args(["branch", branch])
770 .current_dir(&repo_dir)
771 .output()?;
772 anyhow::ensure!(
773 output.status.success(),
774 "git branch failed: {}",
775 String::from_utf8_lossy(&output.stderr)
776 );
777
778 let output = StdCommand::new("git")
779 .args(["checkout", branch])
780 .current_dir(&repo_dir)
781 .output()?;
782 anyhow::ensure!(
783 output.status.success(),
784 "git checkout branch failed: {}",
785 String::from_utf8_lossy(&output.stderr)
786 );
787
788 fs::write(repo_dir.join("README.md"), "feature version")?;
789
790 let output = StdCommand::new("git")
791 .args(["add", "."])
792 .current_dir(&repo_dir)
793 .output()?;
794 anyhow::ensure!(
795 output.status.success(),
796 "git add failed: {}",
797 String::from_utf8_lossy(&output.stderr)
798 );
799
800 let output = StdCommand::new("git")
801 .args(["commit", "-m", "Modify README on feature"])
802 .current_dir(&repo_dir)
803 .output()?;
804 anyhow::ensure!(
805 output.status.success(),
806 "git commit feature failed: {}",
807 String::from_utf8_lossy(&output.stderr)
808 );
809
810 let output = StdCommand::new("git")
812 .args(["checkout", "main"])
813 .current_dir(&repo_dir)
814 .output()?;
815 anyhow::ensure!(
816 output.status.success(),
817 "git checkout main failed: {}",
818 String::from_utf8_lossy(&output.stderr)
819 );
820
821 fs::write(repo_dir.join("README.md"), "main version")?;
822
823 let output = StdCommand::new("git")
824 .args(["add", "."])
825 .current_dir(&repo_dir)
826 .output()?;
827 anyhow::ensure!(
828 output.status.success(),
829 "git add main failed: {}",
830 String::from_utf8_lossy(&output.stderr)
831 );
832
833 let output = StdCommand::new("git")
834 .args(["commit", "-m", "Modify README on main"])
835 .current_dir(&repo_dir)
836 .output()?;
837 anyhow::ensure!(
838 output.status.success(),
839 "git commit main failed: {}",
840 String::from_utf8_lossy(&output.stderr)
841 );
842
843 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
845 };
846
847 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
849
850 let branch_check = StdCommand::new("git")
852 .args(["rev-parse", "--verify", "feature/conflict-test"])
853 .current_dir(&repo_dir)
854 .output()?;
855
856 cleanup_test_repo(&repo_dir)?;
857
858 assert!(!result.success);
860 assert!(branch_check.status.success());
862 Ok(())
863 }
864
865 #[test]
866 #[serial_test::serial]
867 fn test_merge_and_cleanup_successful_merge() -> Result<()> {
868 let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
869 cleanup_test_repo(&repo_dir)?;
870 setup_test_repo(&repo_dir)?;
871
872 let original_dir = std::env::current_dir()?;
873
874 let result = {
875 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
876
877 let branch = "feature/new-feature";
878
879 let output = StdCommand::new("git")
881 .args(["branch", branch])
882 .current_dir(&repo_dir)
883 .output()?;
884 anyhow::ensure!(
885 output.status.success(),
886 "git branch failed: {}",
887 String::from_utf8_lossy(&output.stderr)
888 );
889
890 let output = StdCommand::new("git")
891 .args(["checkout", branch])
892 .current_dir(&repo_dir)
893 .output()?;
894 anyhow::ensure!(
895 output.status.success(),
896 "git checkout failed: {}",
897 String::from_utf8_lossy(&output.stderr)
898 );
899
900 fs::write(repo_dir.join("feature.txt"), "feature content")?;
901
902 let output = StdCommand::new("git")
903 .args(["add", "."])
904 .current_dir(&repo_dir)
905 .output()?;
906 anyhow::ensure!(
907 output.status.success(),
908 "git add failed: {}",
909 String::from_utf8_lossy(&output.stderr)
910 );
911
912 let output = StdCommand::new("git")
913 .args(["commit", "-m", "Add feature"])
914 .current_dir(&repo_dir)
915 .output()?;
916 anyhow::ensure!(
917 output.status.success(),
918 "git commit failed: {}",
919 String::from_utf8_lossy(&output.stderr)
920 );
921
922 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
924 };
925
926 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
928
929 let branch_check = StdCommand::new("git")
931 .args(["rev-parse", "--verify", "feature/new-feature"])
932 .current_dir(&repo_dir)
933 .output()?;
934
935 cleanup_test_repo(&repo_dir)?;
936
937 assert!(
938 result.success && result.error.is_none(),
939 "Merge result: {:?}",
940 result
941 );
942 assert!(!branch_check.status.success());
944 Ok(())
945 }
946
947 #[test]
948 #[serial_test::serial]
949 fn test_create_worktree_success() -> Result<()> {
950 let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
951 cleanup_test_repo(&repo_dir)?;
952 setup_test_repo(&repo_dir)?;
953
954 let original_dir = std::env::current_dir()?;
955
956 let result = {
957 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
958
959 let spec_id = "test-spec-create-success";
960 let branch = "spec/test-spec-create-success";
961
962 create_worktree(spec_id, branch, None)
963 };
964
965 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
967
968 assert!(result.is_ok(), "create_worktree should succeed");
970 let worktree_path = result.unwrap();
971 assert!(worktree_path.exists(), "Worktree directory should exist");
972 assert_eq!(
973 worktree_path,
974 PathBuf::from("/tmp/chant-test-spec-create-success"),
975 "Worktree path should match expected format"
976 );
977
978 let branch_check = StdCommand::new("git")
980 .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
981 .current_dir(&repo_dir)
982 .output()?;
983 assert!(branch_check.status.success(), "Branch should exist");
984
985 let _ = StdCommand::new("git")
987 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
988 .current_dir(&repo_dir)
989 .output();
990 let _ = fs::remove_dir_all(&worktree_path);
991 cleanup_test_repo(&repo_dir)?;
992
993 Ok(())
994 }
995
996 #[test]
997 #[serial_test::serial]
998 fn test_copy_spec_to_worktree_success() -> Result<()> {
999 let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
1000 cleanup_test_repo(&repo_dir)?;
1001 setup_test_repo(&repo_dir)?;
1002
1003 let original_dir = std::env::current_dir()?;
1004
1005 let result: Result<PathBuf> = {
1006 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1007
1008 let spec_id = "test-spec-copy";
1009 let branch = "spec/test-spec-copy";
1010
1011 let specs_dir = repo_dir.join(".chant/specs");
1013 fs::create_dir_all(&specs_dir)?;
1014
1015 let spec_path = specs_dir.join(format!("{}.md", spec_id));
1017 fs::write(
1018 &spec_path,
1019 "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
1020 )?;
1021
1022 let worktree_path = create_worktree(spec_id, branch, None)?;
1024
1025 copy_spec_to_worktree(spec_id, &worktree_path)?;
1027
1028 let worktree_spec_path = worktree_path
1030 .join(".chant/specs")
1031 .join(format!("{}.md", spec_id));
1032 assert!(
1033 worktree_spec_path.exists(),
1034 "Spec file should exist in worktree"
1035 );
1036
1037 let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
1038 assert!(
1039 worktree_spec_content.contains("in_progress"),
1040 "Spec should contain in_progress status"
1041 );
1042
1043 let log_output = StdCommand::new("git")
1045 .args(["log", "--oneline", "-n", "1"])
1046 .current_dir(&worktree_path)
1047 .output()?;
1048 let log = String::from_utf8_lossy(&log_output.stdout);
1049 assert!(
1050 log.contains("update spec status"),
1051 "Commit message should mention spec update"
1052 );
1053
1054 Ok(worktree_path)
1055 };
1056
1057 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1059
1060 if let Ok(worktree_path) = result {
1062 let _ = StdCommand::new("git")
1063 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1064 .current_dir(&repo_dir)
1065 .output();
1066 let _ = fs::remove_dir_all(&worktree_path);
1067 }
1068 cleanup_test_repo(&repo_dir)?;
1069
1070 Ok(())
1071 }
1072
1073 #[test]
1074 #[serial_test::serial]
1075 fn test_remove_worktree_success() -> Result<()> {
1076 let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1077 cleanup_test_repo(&repo_dir)?;
1078 setup_test_repo(&repo_dir)?;
1079
1080 let original_dir = std::env::current_dir()?;
1081
1082 let worktree_path = {
1083 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1084
1085 let spec_id = "test-spec-remove";
1086 let branch = "spec/test-spec-remove";
1087
1088 let path = create_worktree(spec_id, branch, None)?;
1090 assert!(path.exists(), "Worktree should exist after creation");
1091 path
1092 };
1093
1094 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1096
1097 let result = remove_worktree(&worktree_path);
1099 assert!(result.is_ok(), "remove_worktree should succeed");
1100
1101 assert!(
1103 !worktree_path.exists(),
1104 "Worktree directory should be removed"
1105 );
1106
1107 cleanup_test_repo(&repo_dir)?;
1108 Ok(())
1109 }
1110
1111 #[test]
1112 fn test_remove_worktree_idempotent() -> Result<()> {
1113 let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1114
1115 let result = remove_worktree(&path);
1117
1118 assert!(result.is_ok());
1119 Ok(())
1120 }
1121
1122 #[test]
1123 fn test_worktree_path_for_spec() {
1124 let path = worktree_path_for_spec("2026-01-27-001-abc", None);
1125 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1126
1127 let path = worktree_path_for_spec("2026-01-27-001-abc", Some("myproject"));
1128 assert_eq!(
1129 path,
1130 PathBuf::from("/tmp/chant-myproject-2026-01-27-001-abc")
1131 );
1132
1133 let path = worktree_path_for_spec("2026-01-27-001-abc", Some(""));
1134 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1135 }
1136
1137 #[test]
1138 fn test_get_active_worktree_nonexistent() {
1139 let result = get_active_worktree("nonexistent-spec-12345", None);
1141 assert!(result.is_none());
1142 }
1143
1144 #[test]
1145 #[serial_test::serial]
1146 fn test_commit_in_worktree() -> Result<()> {
1147 let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1148 cleanup_test_repo(&repo_dir)?;
1149 setup_test_repo(&repo_dir)?;
1150
1151 fs::write(repo_dir.join("new_file.txt"), "content")?;
1153
1154 let result = commit_in_worktree(&repo_dir, "Test commit message");
1156
1157 cleanup_test_repo(&repo_dir)?;
1158
1159 assert!(result.is_ok());
1160 let hash = result.unwrap();
1161 assert_eq!(hash.len(), 40);
1163 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1164
1165 Ok(())
1166 }
1167
1168 #[test]
1169 #[serial_test::serial]
1170 fn test_commit_in_worktree_no_changes() -> Result<()> {
1171 let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1172 cleanup_test_repo(&repo_dir)?;
1173 setup_test_repo(&repo_dir)?;
1174
1175 let result = commit_in_worktree(&repo_dir, "Empty commit");
1177
1178 cleanup_test_repo(&repo_dir)?;
1179
1180 assert!(result.is_ok());
1182 let hash = result.unwrap();
1183 assert_eq!(hash.len(), 40);
1184
1185 Ok(())
1186 }
1187
1188 #[test]
1189 #[serial_test::serial]
1190 fn test_has_uncommitted_changes_clean() -> Result<()> {
1191 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1192 cleanup_test_repo(&repo_dir)?;
1193 setup_test_repo(&repo_dir)?;
1194
1195 let has_changes = has_uncommitted_changes(&repo_dir)?;
1197
1198 cleanup_test_repo(&repo_dir)?;
1199
1200 assert!(
1201 !has_changes,
1202 "Clean repo should have no uncommitted changes"
1203 );
1204
1205 Ok(())
1206 }
1207
1208 #[test]
1209 #[serial_test::serial]
1210 fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1211 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1212 cleanup_test_repo(&repo_dir)?;
1213 setup_test_repo(&repo_dir)?;
1214
1215 fs::write(repo_dir.join("newfile.txt"), "content")?;
1217
1218 let has_changes = has_uncommitted_changes(&repo_dir)?;
1220
1221 cleanup_test_repo(&repo_dir)?;
1222
1223 assert!(has_changes, "Repo with unstaged changes should return true");
1224
1225 Ok(())
1226 }
1227
1228 #[test]
1229 #[serial_test::serial]
1230 fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1231 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1232 cleanup_test_repo(&repo_dir)?;
1233 setup_test_repo(&repo_dir)?;
1234
1235 fs::write(repo_dir.join("newfile.txt"), "content")?;
1237 let output = StdCommand::new("git")
1238 .args(["add", "newfile.txt"])
1239 .current_dir(&repo_dir)
1240 .output()?;
1241 assert!(output.status.success());
1242
1243 let has_changes = has_uncommitted_changes(&repo_dir)?;
1245
1246 cleanup_test_repo(&repo_dir)?;
1247
1248 assert!(has_changes, "Repo with staged changes should return true");
1249
1250 Ok(())
1251 }
1252}