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 isolate_worktree_specs(spec_id: &str, worktree_path: &Path) -> Result<()> {
316 let worktree_specs_dir = worktree_path.join(".chant/specs");
317 let worktree_archive_dir = worktree_path.join(".chant/archive");
318 let working_spec_filename = format!("{}.md", spec_id);
319
320 if worktree_archive_dir.exists() {
322 std::fs::remove_dir_all(&worktree_archive_dir).context(format!(
323 "Failed to remove archive directory in worktree: {:?}",
324 worktree_archive_dir
325 ))?;
326 }
327
328 if worktree_specs_dir.exists() {
330 let entries = std::fs::read_dir(&worktree_specs_dir).context(format!(
331 "Failed to read specs directory in worktree: {:?}",
332 worktree_specs_dir
333 ))?;
334
335 for entry in entries {
336 let entry = entry.context("Failed to read directory entry")?;
337 let path = entry.path();
338
339 if !path.is_file() {
341 continue;
342 }
343
344 if let Some(filename) = path.file_name() {
346 if filename == working_spec_filename.as_str() {
347 continue;
348 }
349 }
350
351 std::fs::remove_file(&path).context(format!(
353 "Failed to remove spec file in worktree: {:?}",
354 path
355 ))?;
356 }
357 }
358
359 Ok(())
360}
361
362pub fn remove_worktree(path: &Path) -> Result<()> {
374 let _output = Command::new("git")
376 .args(["worktree", "remove", &path.to_string_lossy()])
377 .output()
378 .context("Failed to run git worktree remove")?;
379
380 if path.exists() {
382 std::fs::remove_dir_all(path)
383 .context(format!("Failed to remove worktree directory at {:?}", path))?;
384 }
385
386 Ok(())
387}
388
389#[derive(Debug, Clone)]
391pub struct MergeCleanupResult {
392 pub success: bool,
393 pub has_conflict: bool,
394 pub error: Option<String>,
395}
396
397fn branch_is_behind_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<bool> {
409 let mut cmd = Command::new("git");
410 cmd.args([
411 "rev-list",
412 "--count",
413 &format!("{}..{}", branch, main_branch),
414 ]);
415 if let Some(dir) = work_dir {
416 cmd.current_dir(dir);
417 }
418 let output = cmd
419 .output()
420 .context("Failed to check if branch is behind main")?;
421
422 if !output.status.success() {
423 let stderr = String::from_utf8_lossy(&output.stderr);
424 anyhow::bail!("Failed to check branch status: {}", stderr);
425 }
426
427 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
428 let count: i32 = count_str
429 .parse()
430 .context(format!("Failed to parse commit count: {}", count_str))?;
431 Ok(count > 0)
432}
433
434fn rebase_branch_onto_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<()> {
446 let mut cmd = Command::new("git");
448 cmd.args(["checkout", branch]);
449 if let Some(dir) = work_dir {
450 cmd.current_dir(dir);
451 }
452 let output = cmd
453 .output()
454 .context("Failed to checkout branch for rebase")?;
455
456 if !output.status.success() {
457 let stderr = String::from_utf8_lossy(&output.stderr);
458 anyhow::bail!("Failed to checkout branch: {}", stderr);
459 }
460
461 let mut cmd = Command::new("git");
463 cmd.args(["rebase", main_branch]);
464 if let Some(dir) = work_dir {
465 cmd.current_dir(dir);
466 }
467 let output = cmd.output().context("Failed to rebase onto main")?;
468
469 if !output.status.success() {
470 anyhow::bail!("Rebase had conflicts");
471 }
472
473 let mut cmd = Command::new("git");
475 cmd.args(["checkout", main_branch]);
476 if let Some(dir) = work_dir {
477 cmd.current_dir(dir);
478 }
479 let output = cmd
480 .output()
481 .context("Failed to checkout main after rebase")?;
482
483 if !output.status.success() {
484 let stderr = String::from_utf8_lossy(&output.stderr);
485 anyhow::bail!("Failed to checkout main: {}", stderr);
486 }
487
488 Ok(())
489}
490
491fn abort_rebase(main_branch: &str, work_dir: Option<&Path>) {
500 let mut cmd = Command::new("git");
502 cmd.args(["rebase", "--abort"]);
503 if let Some(dir) = work_dir {
504 cmd.current_dir(dir);
505 }
506 let _ = cmd.output();
507
508 let mut cmd = Command::new("git");
510 cmd.args(["checkout", main_branch]);
511 if let Some(dir) = work_dir {
512 cmd.current_dir(dir);
513 }
514 let _ = cmd.output();
515}
516
517pub fn merge_and_cleanup(branch: &str, main_branch: &str, no_rebase: bool) -> MergeCleanupResult {
534 merge_and_cleanup_in_dir(branch, main_branch, None, no_rebase)
535}
536
537fn merge_and_cleanup_in_dir(
539 branch: &str,
540 main_branch: &str,
541 work_dir: Option<&Path>,
542 no_rebase: bool,
543) -> MergeCleanupResult {
544 let mut cmd = Command::new("git");
546 cmd.args(["checkout", main_branch]);
547 if let Some(dir) = work_dir {
548 cmd.current_dir(dir);
549 }
550 let output = match cmd.output() {
551 Ok(o) => o,
552 Err(e) => {
553 return MergeCleanupResult {
554 success: false,
555 has_conflict: false,
556 error: Some(format!("Failed to checkout {}: {}", main_branch, e)),
557 };
558 }
559 };
560
561 if !output.status.success() {
562 let stderr = String::from_utf8_lossy(&output.stderr);
563 let _ = crate::git::ensure_on_main_branch(main_branch);
565 return MergeCleanupResult {
566 success: false,
567 has_conflict: false,
568 error: Some(format!("Failed to checkout {}: {}", main_branch, stderr)),
569 };
570 }
571
572 if !no_rebase {
574 match branch_is_behind_main(branch, main_branch, work_dir) {
575 Ok(true) => {
576 println!(
578 "Branch '{}' is behind {}, attempting automatic rebase...",
579 branch, main_branch
580 );
581 match rebase_branch_onto_main(branch, main_branch, work_dir) {
582 Ok(()) => {
583 println!("Rebase succeeded, proceeding with merge...");
584 }
585 Err(e) => {
586 abort_rebase(main_branch, work_dir);
588 return MergeCleanupResult {
589 success: false,
590 has_conflict: true,
591 error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
592 };
593 }
594 }
595 }
596 Ok(false) => {
597 }
599 Err(e) => {
600 eprintln!(
602 "Warning: Failed to check if branch is behind {}: {}",
603 main_branch, e
604 );
605 }
606 }
607 }
608
609 let mut cmd = Command::new("git");
611 cmd.args(["merge", "--ff-only", branch]);
612 if let Some(dir) = work_dir {
613 cmd.current_dir(dir);
614 }
615 let output = match cmd.output() {
616 Ok(o) => o,
617 Err(e) => {
618 return MergeCleanupResult {
619 success: false,
620 has_conflict: false,
621 error: Some(format!("Failed to perform merge: {}", e)),
622 };
623 }
624 };
625
626 if !output.status.success() {
627 let stderr = String::from_utf8_lossy(&output.stderr);
628 let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
630
631 if has_conflict {
633 let mut cmd = Command::new("git");
634 cmd.args(["merge", "--abort"]);
635 if let Some(dir) = work_dir {
636 cmd.current_dir(dir);
637 }
638 let _ = cmd.output();
639 }
640
641 let spec_id = branch.rsplit('/').next().unwrap_or(branch);
643 let error_msg = if has_conflict {
644 crate::merge_errors::merge_conflict(spec_id, branch, main_branch)
645 } else {
646 crate::merge_errors::fast_forward_conflict(spec_id, branch, main_branch, &stderr)
647 };
648 let _ = crate::git::ensure_on_main_branch(main_branch);
650 return MergeCleanupResult {
651 success: false,
652 has_conflict,
653 error: Some(error_msg),
654 };
655 }
656
657 let mut cmd = Command::new("git");
659 cmd.args(["branch", "-d", branch]);
660 if let Some(dir) = work_dir {
661 cmd.current_dir(dir);
662 }
663 let output = match cmd.output() {
664 Ok(o) => o,
665 Err(e) => {
666 return MergeCleanupResult {
667 success: false,
668 has_conflict: false,
669 error: Some(format!("Failed to delete branch: {}", e)),
670 };
671 }
672 };
673
674 if !output.status.success() {
675 let stderr = String::from_utf8_lossy(&output.stderr);
676 return MergeCleanupResult {
677 success: false,
678 has_conflict: false,
679 error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
680 };
681 }
682
683 let mut cmd = Command::new("git");
685 cmd.args(["push", "origin", "--delete", branch]);
686 if let Some(dir) = work_dir {
687 cmd.current_dir(dir);
688 }
689 let _ = cmd.output();
691
692 MergeCleanupResult {
693 success: true,
694 has_conflict: false,
695 error: None,
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use std::fs;
703 use std::process::Command as StdCommand;
704
705 fn setup_test_repo(repo_dir: &Path) -> Result<()> {
707 fs::create_dir_all(repo_dir)?;
708
709 let output = StdCommand::new("git")
710 .args(["init", "-b", "main"])
711 .current_dir(repo_dir)
712 .output()
713 .context("Failed to run git init")?;
714 anyhow::ensure!(
715 output.status.success(),
716 "git init failed: {}",
717 String::from_utf8_lossy(&output.stderr)
718 );
719
720 let output = StdCommand::new("git")
721 .args(["config", "user.email", "test@example.com"])
722 .current_dir(repo_dir)
723 .output()
724 .context("Failed to run git config")?;
725 anyhow::ensure!(
726 output.status.success(),
727 "git config email failed: {}",
728 String::from_utf8_lossy(&output.stderr)
729 );
730
731 let output = StdCommand::new("git")
732 .args(["config", "user.name", "Test User"])
733 .current_dir(repo_dir)
734 .output()
735 .context("Failed to run git config")?;
736 anyhow::ensure!(
737 output.status.success(),
738 "git config name failed: {}",
739 String::from_utf8_lossy(&output.stderr)
740 );
741
742 fs::write(repo_dir.join("README.md"), "# Test")?;
744
745 let output = StdCommand::new("git")
746 .args(["add", "."])
747 .current_dir(repo_dir)
748 .output()
749 .context("Failed to run git add")?;
750 anyhow::ensure!(
751 output.status.success(),
752 "git add failed: {}",
753 String::from_utf8_lossy(&output.stderr)
754 );
755
756 let output = StdCommand::new("git")
757 .args(["commit", "-m", "Initial commit"])
758 .current_dir(repo_dir)
759 .output()
760 .context("Failed to run git commit")?;
761 anyhow::ensure!(
762 output.status.success(),
763 "git commit failed: {}",
764 String::from_utf8_lossy(&output.stderr)
765 );
766
767 Ok(())
768 }
769
770 fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
772 if repo_dir.exists() {
773 fs::remove_dir_all(repo_dir)?;
774 }
775 Ok(())
776 }
777
778 #[test]
779 #[serial_test::serial]
780 fn test_create_worktree_branch_already_exists() -> Result<()> {
781 let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
782 cleanup_test_repo(&repo_dir)?;
783 setup_test_repo(&repo_dir)?;
784
785 let original_dir = std::env::current_dir()?;
786
787 let result = {
788 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
789
790 let spec_id = "test-spec-branch-exists";
791 let branch = "spec/test-spec-branch-exists";
792
793 let output = StdCommand::new("git")
795 .args(["branch", branch])
796 .current_dir(&repo_dir)
797 .output()?;
798 anyhow::ensure!(
799 output.status.success(),
800 "git branch failed: {}",
801 String::from_utf8_lossy(&output.stderr)
802 );
803
804 create_worktree(spec_id, branch, None)
805 };
806
807 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
809 cleanup_test_repo(&repo_dir)?;
810
811 assert!(
813 result.is_ok(),
814 "create_worktree should auto-clean and succeed"
815 );
816 Ok(())
817 }
818
819 #[test]
820 #[serial_test::serial]
821 fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
822 let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
823 cleanup_test_repo(&repo_dir)?;
824 setup_test_repo(&repo_dir)?;
825
826 let original_dir = std::env::current_dir()?;
827
828 let result = {
829 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
830
831 let branch = "feature/conflict-test";
832
833 let output = StdCommand::new("git")
835 .args(["branch", branch])
836 .current_dir(&repo_dir)
837 .output()?;
838 anyhow::ensure!(
839 output.status.success(),
840 "git branch failed: {}",
841 String::from_utf8_lossy(&output.stderr)
842 );
843
844 let output = StdCommand::new("git")
845 .args(["checkout", branch])
846 .current_dir(&repo_dir)
847 .output()?;
848 anyhow::ensure!(
849 output.status.success(),
850 "git checkout branch failed: {}",
851 String::from_utf8_lossy(&output.stderr)
852 );
853
854 fs::write(repo_dir.join("README.md"), "feature version")?;
855
856 let output = StdCommand::new("git")
857 .args(["add", "."])
858 .current_dir(&repo_dir)
859 .output()?;
860 anyhow::ensure!(
861 output.status.success(),
862 "git add failed: {}",
863 String::from_utf8_lossy(&output.stderr)
864 );
865
866 let output = StdCommand::new("git")
867 .args(["commit", "-m", "Modify README on feature"])
868 .current_dir(&repo_dir)
869 .output()?;
870 anyhow::ensure!(
871 output.status.success(),
872 "git commit feature failed: {}",
873 String::from_utf8_lossy(&output.stderr)
874 );
875
876 let output = StdCommand::new("git")
878 .args(["checkout", "main"])
879 .current_dir(&repo_dir)
880 .output()?;
881 anyhow::ensure!(
882 output.status.success(),
883 "git checkout main failed: {}",
884 String::from_utf8_lossy(&output.stderr)
885 );
886
887 fs::write(repo_dir.join("README.md"), "main version")?;
888
889 let output = StdCommand::new("git")
890 .args(["add", "."])
891 .current_dir(&repo_dir)
892 .output()?;
893 anyhow::ensure!(
894 output.status.success(),
895 "git add main failed: {}",
896 String::from_utf8_lossy(&output.stderr)
897 );
898
899 let output = StdCommand::new("git")
900 .args(["commit", "-m", "Modify README on main"])
901 .current_dir(&repo_dir)
902 .output()?;
903 anyhow::ensure!(
904 output.status.success(),
905 "git commit main failed: {}",
906 String::from_utf8_lossy(&output.stderr)
907 );
908
909 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
911 };
912
913 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
915
916 let branch_check = StdCommand::new("git")
918 .args(["rev-parse", "--verify", "feature/conflict-test"])
919 .current_dir(&repo_dir)
920 .output()?;
921
922 cleanup_test_repo(&repo_dir)?;
923
924 assert!(!result.success);
926 assert!(branch_check.status.success());
928 Ok(())
929 }
930
931 #[test]
932 #[serial_test::serial]
933 fn test_merge_and_cleanup_successful_merge() -> Result<()> {
934 let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
935 cleanup_test_repo(&repo_dir)?;
936 setup_test_repo(&repo_dir)?;
937
938 let original_dir = std::env::current_dir()?;
939
940 let result = {
941 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
942
943 let branch = "feature/new-feature";
944
945 let output = StdCommand::new("git")
947 .args(["branch", branch])
948 .current_dir(&repo_dir)
949 .output()?;
950 anyhow::ensure!(
951 output.status.success(),
952 "git branch failed: {}",
953 String::from_utf8_lossy(&output.stderr)
954 );
955
956 let output = StdCommand::new("git")
957 .args(["checkout", branch])
958 .current_dir(&repo_dir)
959 .output()?;
960 anyhow::ensure!(
961 output.status.success(),
962 "git checkout failed: {}",
963 String::from_utf8_lossy(&output.stderr)
964 );
965
966 fs::write(repo_dir.join("feature.txt"), "feature content")?;
967
968 let output = StdCommand::new("git")
969 .args(["add", "."])
970 .current_dir(&repo_dir)
971 .output()?;
972 anyhow::ensure!(
973 output.status.success(),
974 "git add failed: {}",
975 String::from_utf8_lossy(&output.stderr)
976 );
977
978 let output = StdCommand::new("git")
979 .args(["commit", "-m", "Add feature"])
980 .current_dir(&repo_dir)
981 .output()?;
982 anyhow::ensure!(
983 output.status.success(),
984 "git commit failed: {}",
985 String::from_utf8_lossy(&output.stderr)
986 );
987
988 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
990 };
991
992 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
994
995 let branch_check = StdCommand::new("git")
997 .args(["rev-parse", "--verify", "feature/new-feature"])
998 .current_dir(&repo_dir)
999 .output()?;
1000
1001 cleanup_test_repo(&repo_dir)?;
1002
1003 assert!(
1004 result.success && result.error.is_none(),
1005 "Merge result: {:?}",
1006 result
1007 );
1008 assert!(!branch_check.status.success());
1010 Ok(())
1011 }
1012
1013 #[test]
1014 #[serial_test::serial]
1015 fn test_create_worktree_success() -> Result<()> {
1016 let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
1017 cleanup_test_repo(&repo_dir)?;
1018 setup_test_repo(&repo_dir)?;
1019
1020 let original_dir = std::env::current_dir()?;
1021
1022 let result = {
1023 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1024
1025 let spec_id = "test-spec-create-success";
1026 let branch = "spec/test-spec-create-success";
1027
1028 create_worktree(spec_id, branch, None)
1029 };
1030
1031 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1033
1034 assert!(result.is_ok(), "create_worktree should succeed");
1036 let worktree_path = result.unwrap();
1037 assert!(worktree_path.exists(), "Worktree directory should exist");
1038 assert_eq!(
1039 worktree_path,
1040 PathBuf::from("/tmp/chant-test-spec-create-success"),
1041 "Worktree path should match expected format"
1042 );
1043
1044 let branch_check = StdCommand::new("git")
1046 .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
1047 .current_dir(&repo_dir)
1048 .output()?;
1049 assert!(branch_check.status.success(), "Branch should exist");
1050
1051 let _ = StdCommand::new("git")
1053 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1054 .current_dir(&repo_dir)
1055 .output();
1056 let _ = fs::remove_dir_all(&worktree_path);
1057 cleanup_test_repo(&repo_dir)?;
1058
1059 Ok(())
1060 }
1061
1062 #[test]
1063 #[serial_test::serial]
1064 fn test_copy_spec_to_worktree_success() -> Result<()> {
1065 let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
1066 cleanup_test_repo(&repo_dir)?;
1067 setup_test_repo(&repo_dir)?;
1068
1069 let original_dir = std::env::current_dir()?;
1070
1071 let result: Result<PathBuf> = {
1072 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1073
1074 let spec_id = "test-spec-copy";
1075 let branch = "spec/test-spec-copy";
1076
1077 let specs_dir = repo_dir.join(".chant/specs");
1079 fs::create_dir_all(&specs_dir)?;
1080
1081 let spec_path = specs_dir.join(format!("{}.md", spec_id));
1083 fs::write(
1084 &spec_path,
1085 "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
1086 )?;
1087
1088 let worktree_path = create_worktree(spec_id, branch, None)?;
1090
1091 copy_spec_to_worktree(spec_id, &worktree_path)?;
1093
1094 let worktree_spec_path = worktree_path
1096 .join(".chant/specs")
1097 .join(format!("{}.md", spec_id));
1098 assert!(
1099 worktree_spec_path.exists(),
1100 "Spec file should exist in worktree"
1101 );
1102
1103 let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
1104 assert!(
1105 worktree_spec_content.contains("in_progress"),
1106 "Spec should contain in_progress status"
1107 );
1108
1109 let log_output = StdCommand::new("git")
1111 .args(["log", "--oneline", "-n", "1"])
1112 .current_dir(&worktree_path)
1113 .output()?;
1114 let log = String::from_utf8_lossy(&log_output.stdout);
1115 assert!(
1116 log.contains("update spec status"),
1117 "Commit message should mention spec update"
1118 );
1119
1120 Ok(worktree_path)
1121 };
1122
1123 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1125
1126 if let Ok(worktree_path) = result {
1128 let _ = StdCommand::new("git")
1129 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1130 .current_dir(&repo_dir)
1131 .output();
1132 let _ = fs::remove_dir_all(&worktree_path);
1133 }
1134 cleanup_test_repo(&repo_dir)?;
1135
1136 Ok(())
1137 }
1138
1139 #[test]
1140 #[serial_test::serial]
1141 fn test_remove_worktree_success() -> Result<()> {
1142 let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1143 cleanup_test_repo(&repo_dir)?;
1144 setup_test_repo(&repo_dir)?;
1145
1146 let original_dir = std::env::current_dir()?;
1147
1148 let worktree_path = {
1149 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1150
1151 let spec_id = "test-spec-remove";
1152 let branch = "spec/test-spec-remove";
1153
1154 let path = create_worktree(spec_id, branch, None)?;
1156 assert!(path.exists(), "Worktree should exist after creation");
1157 path
1158 };
1159
1160 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1162
1163 let result = remove_worktree(&worktree_path);
1165 assert!(result.is_ok(), "remove_worktree should succeed");
1166
1167 assert!(
1169 !worktree_path.exists(),
1170 "Worktree directory should be removed"
1171 );
1172
1173 cleanup_test_repo(&repo_dir)?;
1174 Ok(())
1175 }
1176
1177 #[test]
1178 fn test_remove_worktree_idempotent() -> Result<()> {
1179 let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1180
1181 let result = remove_worktree(&path);
1183
1184 assert!(result.is_ok());
1185 Ok(())
1186 }
1187
1188 #[test]
1189 fn test_worktree_path_for_spec() {
1190 let path = worktree_path_for_spec("2026-01-27-001-abc", None);
1191 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1192
1193 let path = worktree_path_for_spec("2026-01-27-001-abc", Some("myproject"));
1194 assert_eq!(
1195 path,
1196 PathBuf::from("/tmp/chant-myproject-2026-01-27-001-abc")
1197 );
1198
1199 let path = worktree_path_for_spec("2026-01-27-001-abc", Some(""));
1200 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1201 }
1202
1203 #[test]
1204 fn test_get_active_worktree_nonexistent() {
1205 let result = get_active_worktree("nonexistent-spec-12345", None);
1207 assert!(result.is_none());
1208 }
1209
1210 #[test]
1211 #[serial_test::serial]
1212 fn test_commit_in_worktree() -> Result<()> {
1213 let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1214 cleanup_test_repo(&repo_dir)?;
1215 setup_test_repo(&repo_dir)?;
1216
1217 fs::write(repo_dir.join("new_file.txt"), "content")?;
1219
1220 let result = commit_in_worktree(&repo_dir, "Test commit message");
1222
1223 cleanup_test_repo(&repo_dir)?;
1224
1225 assert!(result.is_ok());
1226 let hash = result.unwrap();
1227 assert_eq!(hash.len(), 40);
1229 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1230
1231 Ok(())
1232 }
1233
1234 #[test]
1235 #[serial_test::serial]
1236 fn test_commit_in_worktree_no_changes() -> Result<()> {
1237 let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1238 cleanup_test_repo(&repo_dir)?;
1239 setup_test_repo(&repo_dir)?;
1240
1241 let result = commit_in_worktree(&repo_dir, "Empty commit");
1243
1244 cleanup_test_repo(&repo_dir)?;
1245
1246 assert!(result.is_ok());
1248 let hash = result.unwrap();
1249 assert_eq!(hash.len(), 40);
1250
1251 Ok(())
1252 }
1253
1254 #[test]
1255 #[serial_test::serial]
1256 fn test_has_uncommitted_changes_clean() -> Result<()> {
1257 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1258 cleanup_test_repo(&repo_dir)?;
1259 setup_test_repo(&repo_dir)?;
1260
1261 let has_changes = has_uncommitted_changes(&repo_dir)?;
1263
1264 cleanup_test_repo(&repo_dir)?;
1265
1266 assert!(
1267 !has_changes,
1268 "Clean repo should have no uncommitted changes"
1269 );
1270
1271 Ok(())
1272 }
1273
1274 #[test]
1275 #[serial_test::serial]
1276 fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1277 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1278 cleanup_test_repo(&repo_dir)?;
1279 setup_test_repo(&repo_dir)?;
1280
1281 fs::write(repo_dir.join("newfile.txt"), "content")?;
1283
1284 let has_changes = has_uncommitted_changes(&repo_dir)?;
1286
1287 cleanup_test_repo(&repo_dir)?;
1288
1289 assert!(has_changes, "Repo with unstaged changes should return true");
1290
1291 Ok(())
1292 }
1293
1294 #[test]
1295 #[serial_test::serial]
1296 fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1297 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1298 cleanup_test_repo(&repo_dir)?;
1299 setup_test_repo(&repo_dir)?;
1300
1301 fs::write(repo_dir.join("newfile.txt"), "content")?;
1303 let output = StdCommand::new("git")
1304 .args(["add", "newfile.txt"])
1305 .current_dir(&repo_dir)
1306 .output()?;
1307 assert!(output.status.success());
1308
1309 let has_changes = has_uncommitted_changes(&repo_dir)?;
1311
1312 cleanup_test_repo(&repo_dir)?;
1313
1314 assert!(has_changes, "Repo with staged changes should return true");
1315
1316 Ok(())
1317 }
1318}