1use anyhow::{Context, Result};
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15pub fn worktree_path_for_spec(spec_id: &str, project_name: Option<&str>) -> PathBuf {
19 match project_name.filter(|n| !n.is_empty()) {
20 Some(name) => PathBuf::from(format!("/tmp/chant-{}-{}", name, spec_id)),
21 None => PathBuf::from(format!("/tmp/chant-{}", spec_id)),
22 }
23}
24
25pub fn get_active_worktree(spec_id: &str, project_name: Option<&str>) -> Option<PathBuf> {
29 let path = worktree_path_for_spec(spec_id, project_name);
30 if path.exists() && path.is_dir() {
31 Some(path)
32 } else {
33 None
34 }
35}
36
37pub fn has_uncommitted_changes(worktree_path: &Path) -> Result<bool> {
47 let output = Command::new("git")
48 .args(["status", "--porcelain"])
49 .current_dir(worktree_path)
50 .output()
51 .context("Failed to check git status in worktree")?;
52
53 if !output.status.success() {
54 let stderr = String::from_utf8_lossy(&output.stderr);
55 anyhow::bail!("Failed to run git status: {}", stderr);
56 }
57
58 let status_output = String::from_utf8_lossy(&output.stdout);
59 Ok(!status_output.trim().is_empty())
60}
61
62pub fn commit_in_worktree(worktree_path: &Path, message: &str) -> Result<String> {
73 let output = Command::new("git")
75 .args(["add", "-A"])
76 .current_dir(worktree_path)
77 .output()
78 .context("Failed to stage changes in worktree")?;
79
80 if !output.status.success() {
81 let stderr = String::from_utf8_lossy(&output.stderr);
82 anyhow::bail!("Failed to stage changes: {}", stderr);
83 }
84
85 let output = Command::new("git")
87 .args(["status", "--porcelain"])
88 .current_dir(worktree_path)
89 .output()
90 .context("Failed to check git status in worktree")?;
91
92 let status_output = String::from_utf8_lossy(&output.stdout);
93 if status_output.trim().is_empty() {
94 let output = Command::new("git")
96 .args(["rev-parse", "HEAD"])
97 .current_dir(worktree_path)
98 .output()
99 .context("Failed to get HEAD commit")?;
100
101 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
102 return Ok(hash);
103 }
104
105 let output = Command::new("git")
107 .args(["commit", "-m", message])
108 .current_dir(worktree_path)
109 .output()
110 .context("Failed to commit changes in worktree")?;
111
112 if !output.status.success() {
113 let stderr = String::from_utf8_lossy(&output.stderr);
114 anyhow::bail!("Failed to commit: {}", stderr);
115 }
116
117 let output = Command::new("git")
119 .args(["rev-parse", "HEAD"])
120 .current_dir(worktree_path)
121 .output()
122 .context("Failed to get commit hash")?;
123
124 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
125 Ok(hash)
126}
127
128pub fn create_worktree(spec_id: &str, branch: &str, project_name: Option<&str>) -> Result<PathBuf> {
146 let worktree_path = worktree_path_for_spec(spec_id, project_name);
147
148 if worktree_path.exists() {
150 let _ = Command::new("git")
152 .args([
153 "worktree",
154 "remove",
155 "--force",
156 &worktree_path.to_string_lossy(),
157 ])
158 .output();
159
160 if worktree_path.exists() {
162 let _ = std::fs::remove_dir_all(&worktree_path);
163 }
164 }
165
166 let output = Command::new("git")
168 .args(["rev-parse", "--verify", branch])
169 .output()
170 .context("Failed to check if branch exists")?;
171
172 if output.status.success() {
173 let _ = Command::new("git").args(["branch", "-D", branch]).output();
175 }
176
177 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 create git worktree")?;
188
189 if !output.status.success() {
190 let stderr = String::from_utf8_lossy(&output.stderr);
191 anyhow::bail!("Failed to create worktree: {}", stderr);
192 }
193
194 Ok(worktree_path)
195}
196
197pub fn copy_spec_to_worktree(spec_id: &str, worktree_path: &Path) -> Result<()> {
218 let git_root = std::env::current_dir().context("Failed to get current directory")?;
220 let main_spec_path = git_root
221 .join(".chant/specs")
222 .join(format!("{}.md", spec_id));
223 let worktree_specs_dir = worktree_path.join(".chant/specs");
224 let worktree_spec_path = worktree_specs_dir.join(format!("{}.md", spec_id));
225
226 std::fs::create_dir_all(&worktree_specs_dir).context(format!(
228 "Failed to create specs directory in worktree: {:?}",
229 worktree_specs_dir
230 ))?;
231
232 std::fs::copy(&main_spec_path, &worktree_spec_path).context(format!(
234 "Failed to copy spec file to worktree: {:?}",
235 worktree_spec_path
236 ))?;
237
238 commit_in_worktree(
240 worktree_path,
241 &format!("chant({}): update spec status to in_progress", spec_id),
242 )?;
243
244 Ok(())
245}
246
247pub fn remove_worktree(path: &Path) -> Result<()> {
259 let _output = Command::new("git")
261 .args(["worktree", "remove", &path.to_string_lossy()])
262 .output()
263 .context("Failed to run git worktree remove")?;
264
265 if path.exists() {
267 std::fs::remove_dir_all(path)
268 .context(format!("Failed to remove worktree directory at {:?}", path))?;
269 }
270
271 Ok(())
272}
273
274#[derive(Debug, Clone)]
276pub struct MergeCleanupResult {
277 pub success: bool,
278 pub has_conflict: bool,
279 pub error: Option<String>,
280}
281
282fn branch_is_behind_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<bool> {
294 let mut cmd = Command::new("git");
295 cmd.args([
296 "rev-list",
297 "--count",
298 &format!("{}..{}", branch, main_branch),
299 ]);
300 if let Some(dir) = work_dir {
301 cmd.current_dir(dir);
302 }
303 let output = cmd
304 .output()
305 .context("Failed to check if branch is behind main")?;
306
307 if !output.status.success() {
308 let stderr = String::from_utf8_lossy(&output.stderr);
309 anyhow::bail!("Failed to check branch status: {}", stderr);
310 }
311
312 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
313 let count: i32 = count_str
314 .parse()
315 .context(format!("Failed to parse commit count: {}", count_str))?;
316 Ok(count > 0)
317}
318
319fn rebase_branch_onto_main(branch: &str, main_branch: &str, work_dir: Option<&Path>) -> Result<()> {
331 let mut cmd = Command::new("git");
333 cmd.args(["checkout", branch]);
334 if let Some(dir) = work_dir {
335 cmd.current_dir(dir);
336 }
337 let output = cmd
338 .output()
339 .context("Failed to checkout branch for rebase")?;
340
341 if !output.status.success() {
342 let stderr = String::from_utf8_lossy(&output.stderr);
343 anyhow::bail!("Failed to checkout branch: {}", stderr);
344 }
345
346 let mut cmd = Command::new("git");
348 cmd.args(["rebase", main_branch]);
349 if let Some(dir) = work_dir {
350 cmd.current_dir(dir);
351 }
352 let output = cmd.output().context("Failed to rebase onto main")?;
353
354 if !output.status.success() {
355 anyhow::bail!("Rebase had conflicts");
356 }
357
358 let mut cmd = Command::new("git");
360 cmd.args(["checkout", main_branch]);
361 if let Some(dir) = work_dir {
362 cmd.current_dir(dir);
363 }
364 let output = cmd
365 .output()
366 .context("Failed to checkout main after rebase")?;
367
368 if !output.status.success() {
369 let stderr = String::from_utf8_lossy(&output.stderr);
370 anyhow::bail!("Failed to checkout main: {}", stderr);
371 }
372
373 Ok(())
374}
375
376fn abort_rebase(main_branch: &str, work_dir: Option<&Path>) {
385 let mut cmd = Command::new("git");
387 cmd.args(["rebase", "--abort"]);
388 if let Some(dir) = work_dir {
389 cmd.current_dir(dir);
390 }
391 let _ = cmd.output();
392
393 let mut cmd = Command::new("git");
395 cmd.args(["checkout", main_branch]);
396 if let Some(dir) = work_dir {
397 cmd.current_dir(dir);
398 }
399 let _ = cmd.output();
400}
401
402pub fn merge_and_cleanup(branch: &str, main_branch: &str, no_rebase: bool) -> MergeCleanupResult {
419 merge_and_cleanup_in_dir(branch, main_branch, None, no_rebase)
420}
421
422fn merge_and_cleanup_in_dir(
424 branch: &str,
425 main_branch: &str,
426 work_dir: Option<&Path>,
427 no_rebase: bool,
428) -> MergeCleanupResult {
429 let mut cmd = Command::new("git");
431 cmd.args(["checkout", main_branch]);
432 if let Some(dir) = work_dir {
433 cmd.current_dir(dir);
434 }
435 let output = match cmd.output() {
436 Ok(o) => o,
437 Err(e) => {
438 return MergeCleanupResult {
439 success: false,
440 has_conflict: false,
441 error: Some(format!("Failed to checkout {}: {}", main_branch, e)),
442 };
443 }
444 };
445
446 if !output.status.success() {
447 let stderr = String::from_utf8_lossy(&output.stderr);
448 let _ = crate::git::ensure_on_main_branch(main_branch);
450 return MergeCleanupResult {
451 success: false,
452 has_conflict: false,
453 error: Some(format!("Failed to checkout {}: {}", main_branch, stderr)),
454 };
455 }
456
457 if !no_rebase {
459 match branch_is_behind_main(branch, main_branch, work_dir) {
460 Ok(true) => {
461 println!(
463 "Branch '{}' is behind {}, attempting automatic rebase...",
464 branch, main_branch
465 );
466 match rebase_branch_onto_main(branch, main_branch, work_dir) {
467 Ok(()) => {
468 println!("Rebase succeeded, proceeding with merge...");
469 }
470 Err(e) => {
471 abort_rebase(main_branch, work_dir);
473 return MergeCleanupResult {
474 success: false,
475 has_conflict: true,
476 error: Some(format!("Auto-rebase failed due to conflicts: {}", e)),
477 };
478 }
479 }
480 }
481 Ok(false) => {
482 }
484 Err(e) => {
485 eprintln!(
487 "Warning: Failed to check if branch is behind {}: {}",
488 main_branch, e
489 );
490 }
491 }
492 }
493
494 let mut cmd = Command::new("git");
496 cmd.args(["merge", "--ff-only", branch]);
497 if let Some(dir) = work_dir {
498 cmd.current_dir(dir);
499 }
500 let output = match cmd.output() {
501 Ok(o) => o,
502 Err(e) => {
503 return MergeCleanupResult {
504 success: false,
505 has_conflict: false,
506 error: Some(format!("Failed to perform merge: {}", e)),
507 };
508 }
509 };
510
511 if !output.status.success() {
512 let stderr = String::from_utf8_lossy(&output.stderr);
513 let has_conflict = stderr.contains("CONFLICT") || stderr.contains("merge conflict");
515
516 if has_conflict {
518 let mut cmd = Command::new("git");
519 cmd.args(["merge", "--abort"]);
520 if let Some(dir) = work_dir {
521 cmd.current_dir(dir);
522 }
523 let _ = cmd.output();
524 }
525
526 let spec_id = branch.rsplit('/').next().unwrap_or(branch);
528 let error_msg = if has_conflict {
529 crate::merge_errors::merge_conflict(spec_id, branch, main_branch)
530 } else {
531 crate::merge_errors::fast_forward_conflict(spec_id, branch, main_branch, &stderr)
532 };
533 let _ = crate::git::ensure_on_main_branch(main_branch);
535 return MergeCleanupResult {
536 success: false,
537 has_conflict,
538 error: Some(error_msg),
539 };
540 }
541
542 let mut cmd = Command::new("git");
544 cmd.args(["branch", "-d", branch]);
545 if let Some(dir) = work_dir {
546 cmd.current_dir(dir);
547 }
548 let output = match cmd.output() {
549 Ok(o) => o,
550 Err(e) => {
551 return MergeCleanupResult {
552 success: false,
553 has_conflict: false,
554 error: Some(format!("Failed to delete branch: {}", e)),
555 };
556 }
557 };
558
559 if !output.status.success() {
560 let stderr = String::from_utf8_lossy(&output.stderr);
561 return MergeCleanupResult {
562 success: false,
563 has_conflict: false,
564 error: Some(format!("Failed to delete branch '{}': {}", branch, stderr)),
565 };
566 }
567
568 let mut cmd = Command::new("git");
570 cmd.args(["push", "origin", "--delete", branch]);
571 if let Some(dir) = work_dir {
572 cmd.current_dir(dir);
573 }
574 let _ = cmd.output();
576
577 MergeCleanupResult {
578 success: true,
579 has_conflict: false,
580 error: None,
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587 use std::fs;
588 use std::process::Command as StdCommand;
589
590 fn setup_test_repo(repo_dir: &Path) -> Result<()> {
592 fs::create_dir_all(repo_dir)?;
593
594 let output = StdCommand::new("git")
595 .args(["init", "-b", "main"])
596 .current_dir(repo_dir)
597 .output()
598 .context("Failed to run git init")?;
599 anyhow::ensure!(
600 output.status.success(),
601 "git init failed: {}",
602 String::from_utf8_lossy(&output.stderr)
603 );
604
605 let output = StdCommand::new("git")
606 .args(["config", "user.email", "test@example.com"])
607 .current_dir(repo_dir)
608 .output()
609 .context("Failed to run git config")?;
610 anyhow::ensure!(
611 output.status.success(),
612 "git config email failed: {}",
613 String::from_utf8_lossy(&output.stderr)
614 );
615
616 let output = StdCommand::new("git")
617 .args(["config", "user.name", "Test User"])
618 .current_dir(repo_dir)
619 .output()
620 .context("Failed to run git config")?;
621 anyhow::ensure!(
622 output.status.success(),
623 "git config name failed: {}",
624 String::from_utf8_lossy(&output.stderr)
625 );
626
627 fs::write(repo_dir.join("README.md"), "# Test")?;
629
630 let output = StdCommand::new("git")
631 .args(["add", "."])
632 .current_dir(repo_dir)
633 .output()
634 .context("Failed to run git add")?;
635 anyhow::ensure!(
636 output.status.success(),
637 "git add failed: {}",
638 String::from_utf8_lossy(&output.stderr)
639 );
640
641 let output = StdCommand::new("git")
642 .args(["commit", "-m", "Initial commit"])
643 .current_dir(repo_dir)
644 .output()
645 .context("Failed to run git commit")?;
646 anyhow::ensure!(
647 output.status.success(),
648 "git commit failed: {}",
649 String::from_utf8_lossy(&output.stderr)
650 );
651
652 Ok(())
653 }
654
655 fn cleanup_test_repo(repo_dir: &Path) -> Result<()> {
657 if repo_dir.exists() {
658 fs::remove_dir_all(repo_dir)?;
659 }
660 Ok(())
661 }
662
663 #[test]
664 #[serial_test::serial]
665 fn test_create_worktree_branch_already_exists() -> Result<()> {
666 let repo_dir = PathBuf::from("/tmp/test-chant-repo-branch-exists");
667 cleanup_test_repo(&repo_dir)?;
668 setup_test_repo(&repo_dir)?;
669
670 let original_dir = std::env::current_dir()?;
671
672 let result = {
673 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
674
675 let spec_id = "test-spec-branch-exists";
676 let branch = "spec/test-spec-branch-exists";
677
678 let output = StdCommand::new("git")
680 .args(["branch", branch])
681 .current_dir(&repo_dir)
682 .output()?;
683 anyhow::ensure!(
684 output.status.success(),
685 "git branch failed: {}",
686 String::from_utf8_lossy(&output.stderr)
687 );
688
689 create_worktree(spec_id, branch, None)
690 };
691
692 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
694 cleanup_test_repo(&repo_dir)?;
695
696 assert!(
698 result.is_ok(),
699 "create_worktree should auto-clean and succeed"
700 );
701 Ok(())
702 }
703
704 #[test]
705 #[serial_test::serial]
706 fn test_merge_and_cleanup_with_conflict_preserves_branch() -> Result<()> {
707 let repo_dir = PathBuf::from("/tmp/test-chant-repo-conflict-preserve");
708 cleanup_test_repo(&repo_dir)?;
709 setup_test_repo(&repo_dir)?;
710
711 let original_dir = std::env::current_dir()?;
712
713 let result = {
714 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
715
716 let branch = "feature/conflict-test";
717
718 let output = StdCommand::new("git")
720 .args(["branch", branch])
721 .current_dir(&repo_dir)
722 .output()?;
723 anyhow::ensure!(
724 output.status.success(),
725 "git branch failed: {}",
726 String::from_utf8_lossy(&output.stderr)
727 );
728
729 let output = StdCommand::new("git")
730 .args(["checkout", branch])
731 .current_dir(&repo_dir)
732 .output()?;
733 anyhow::ensure!(
734 output.status.success(),
735 "git checkout branch failed: {}",
736 String::from_utf8_lossy(&output.stderr)
737 );
738
739 fs::write(repo_dir.join("README.md"), "feature version")?;
740
741 let output = StdCommand::new("git")
742 .args(["add", "."])
743 .current_dir(&repo_dir)
744 .output()?;
745 anyhow::ensure!(
746 output.status.success(),
747 "git add failed: {}",
748 String::from_utf8_lossy(&output.stderr)
749 );
750
751 let output = StdCommand::new("git")
752 .args(["commit", "-m", "Modify README on feature"])
753 .current_dir(&repo_dir)
754 .output()?;
755 anyhow::ensure!(
756 output.status.success(),
757 "git commit feature failed: {}",
758 String::from_utf8_lossy(&output.stderr)
759 );
760
761 let output = StdCommand::new("git")
763 .args(["checkout", "main"])
764 .current_dir(&repo_dir)
765 .output()?;
766 anyhow::ensure!(
767 output.status.success(),
768 "git checkout main failed: {}",
769 String::from_utf8_lossy(&output.stderr)
770 );
771
772 fs::write(repo_dir.join("README.md"), "main version")?;
773
774 let output = StdCommand::new("git")
775 .args(["add", "."])
776 .current_dir(&repo_dir)
777 .output()?;
778 anyhow::ensure!(
779 output.status.success(),
780 "git add main failed: {}",
781 String::from_utf8_lossy(&output.stderr)
782 );
783
784 let output = StdCommand::new("git")
785 .args(["commit", "-m", "Modify README on main"])
786 .current_dir(&repo_dir)
787 .output()?;
788 anyhow::ensure!(
789 output.status.success(),
790 "git commit main failed: {}",
791 String::from_utf8_lossy(&output.stderr)
792 );
793
794 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
796 };
797
798 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
800
801 let branch_check = StdCommand::new("git")
803 .args(["rev-parse", "--verify", "feature/conflict-test"])
804 .current_dir(&repo_dir)
805 .output()?;
806
807 cleanup_test_repo(&repo_dir)?;
808
809 assert!(!result.success);
811 assert!(branch_check.status.success());
813 Ok(())
814 }
815
816 #[test]
817 #[serial_test::serial]
818 fn test_merge_and_cleanup_successful_merge() -> Result<()> {
819 let repo_dir = PathBuf::from("/tmp/test-chant-repo-merge-success");
820 cleanup_test_repo(&repo_dir)?;
821 setup_test_repo(&repo_dir)?;
822
823 let original_dir = std::env::current_dir()?;
824
825 let result = {
826 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
827
828 let branch = "feature/new-feature";
829
830 let output = StdCommand::new("git")
832 .args(["branch", branch])
833 .current_dir(&repo_dir)
834 .output()?;
835 anyhow::ensure!(
836 output.status.success(),
837 "git branch failed: {}",
838 String::from_utf8_lossy(&output.stderr)
839 );
840
841 let output = StdCommand::new("git")
842 .args(["checkout", branch])
843 .current_dir(&repo_dir)
844 .output()?;
845 anyhow::ensure!(
846 output.status.success(),
847 "git checkout failed: {}",
848 String::from_utf8_lossy(&output.stderr)
849 );
850
851 fs::write(repo_dir.join("feature.txt"), "feature content")?;
852
853 let output = StdCommand::new("git")
854 .args(["add", "."])
855 .current_dir(&repo_dir)
856 .output()?;
857 anyhow::ensure!(
858 output.status.success(),
859 "git add failed: {}",
860 String::from_utf8_lossy(&output.stderr)
861 );
862
863 let output = StdCommand::new("git")
864 .args(["commit", "-m", "Add feature"])
865 .current_dir(&repo_dir)
866 .output()?;
867 anyhow::ensure!(
868 output.status.success(),
869 "git commit failed: {}",
870 String::from_utf8_lossy(&output.stderr)
871 );
872
873 merge_and_cleanup_in_dir(branch, "main", Some(&repo_dir), false)
875 };
876
877 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
879
880 let branch_check = StdCommand::new("git")
882 .args(["rev-parse", "--verify", "feature/new-feature"])
883 .current_dir(&repo_dir)
884 .output()?;
885
886 cleanup_test_repo(&repo_dir)?;
887
888 assert!(
889 result.success && result.error.is_none(),
890 "Merge result: {:?}",
891 result
892 );
893 assert!(!branch_check.status.success());
895 Ok(())
896 }
897
898 #[test]
899 #[serial_test::serial]
900 fn test_create_worktree_success() -> Result<()> {
901 let repo_dir = PathBuf::from("/tmp/test-chant-repo-create-success");
902 cleanup_test_repo(&repo_dir)?;
903 setup_test_repo(&repo_dir)?;
904
905 let original_dir = std::env::current_dir()?;
906
907 let result = {
908 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
909
910 let spec_id = "test-spec-create-success";
911 let branch = "spec/test-spec-create-success";
912
913 create_worktree(spec_id, branch, None)
914 };
915
916 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
918
919 assert!(result.is_ok(), "create_worktree should succeed");
921 let worktree_path = result.unwrap();
922 assert!(worktree_path.exists(), "Worktree directory should exist");
923 assert_eq!(
924 worktree_path,
925 PathBuf::from("/tmp/chant-test-spec-create-success"),
926 "Worktree path should match expected format"
927 );
928
929 let branch_check = StdCommand::new("git")
931 .args(["rev-parse", "--verify", "spec/test-spec-create-success"])
932 .current_dir(&repo_dir)
933 .output()?;
934 assert!(branch_check.status.success(), "Branch should exist");
935
936 let _ = StdCommand::new("git")
938 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
939 .current_dir(&repo_dir)
940 .output();
941 let _ = fs::remove_dir_all(&worktree_path);
942 cleanup_test_repo(&repo_dir)?;
943
944 Ok(())
945 }
946
947 #[test]
948 #[serial_test::serial]
949 fn test_copy_spec_to_worktree_success() -> Result<()> {
950 let repo_dir = PathBuf::from("/tmp/test-chant-repo-copy-spec");
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: Result<PathBuf> = {
957 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
958
959 let spec_id = "test-spec-copy";
960 let branch = "spec/test-spec-copy";
961
962 let specs_dir = repo_dir.join(".chant/specs");
964 fs::create_dir_all(&specs_dir)?;
965
966 let spec_path = specs_dir.join(format!("{}.md", spec_id));
968 fs::write(
969 &spec_path,
970 "---\ntype: code\nstatus: in_progress\n---\n# Test Spec\n",
971 )?;
972
973 let worktree_path = create_worktree(spec_id, branch, None)?;
975
976 copy_spec_to_worktree(spec_id, &worktree_path)?;
978
979 let worktree_spec_path = worktree_path
981 .join(".chant/specs")
982 .join(format!("{}.md", spec_id));
983 assert!(
984 worktree_spec_path.exists(),
985 "Spec file should exist in worktree"
986 );
987
988 let worktree_spec_content = fs::read_to_string(&worktree_spec_path)?;
989 assert!(
990 worktree_spec_content.contains("in_progress"),
991 "Spec should contain in_progress status"
992 );
993
994 let log_output = StdCommand::new("git")
996 .args(["log", "--oneline", "-n", "1"])
997 .current_dir(&worktree_path)
998 .output()?;
999 let log = String::from_utf8_lossy(&log_output.stdout);
1000 assert!(
1001 log.contains("update spec status"),
1002 "Commit message should mention spec update"
1003 );
1004
1005 Ok(worktree_path)
1006 };
1007
1008 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1010
1011 if let Ok(worktree_path) = result {
1013 let _ = StdCommand::new("git")
1014 .args(["worktree", "remove", worktree_path.to_str().unwrap()])
1015 .current_dir(&repo_dir)
1016 .output();
1017 let _ = fs::remove_dir_all(&worktree_path);
1018 }
1019 cleanup_test_repo(&repo_dir)?;
1020
1021 Ok(())
1022 }
1023
1024 #[test]
1025 #[serial_test::serial]
1026 fn test_remove_worktree_success() -> Result<()> {
1027 let repo_dir = PathBuf::from("/tmp/test-chant-repo-remove-success");
1028 cleanup_test_repo(&repo_dir)?;
1029 setup_test_repo(&repo_dir)?;
1030
1031 let original_dir = std::env::current_dir()?;
1032
1033 let worktree_path = {
1034 std::env::set_current_dir(&repo_dir).context("Failed to change to repo directory")?;
1035
1036 let spec_id = "test-spec-remove";
1037 let branch = "spec/test-spec-remove";
1038
1039 let path = create_worktree(spec_id, branch, None)?;
1041 assert!(path.exists(), "Worktree should exist after creation");
1042 path
1043 };
1044
1045 std::env::set_current_dir(&original_dir).context("Failed to restore original directory")?;
1047
1048 let result = remove_worktree(&worktree_path);
1050 assert!(result.is_ok(), "remove_worktree should succeed");
1051
1052 assert!(
1054 !worktree_path.exists(),
1055 "Worktree directory should be removed"
1056 );
1057
1058 cleanup_test_repo(&repo_dir)?;
1059 Ok(())
1060 }
1061
1062 #[test]
1063 fn test_remove_worktree_idempotent() -> Result<()> {
1064 let path = PathBuf::from("/tmp/nonexistent-worktree-12345");
1065
1066 let result = remove_worktree(&path);
1068
1069 assert!(result.is_ok());
1070 Ok(())
1071 }
1072
1073 #[test]
1074 fn test_worktree_path_for_spec() {
1075 let path = worktree_path_for_spec("2026-01-27-001-abc", None);
1076 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1077
1078 let path = worktree_path_for_spec("2026-01-27-001-abc", Some("myproject"));
1079 assert_eq!(
1080 path,
1081 PathBuf::from("/tmp/chant-myproject-2026-01-27-001-abc")
1082 );
1083
1084 let path = worktree_path_for_spec("2026-01-27-001-abc", Some(""));
1085 assert_eq!(path, PathBuf::from("/tmp/chant-2026-01-27-001-abc"));
1086 }
1087
1088 #[test]
1089 fn test_get_active_worktree_nonexistent() {
1090 let result = get_active_worktree("nonexistent-spec-12345", None);
1092 assert!(result.is_none());
1093 }
1094
1095 #[test]
1096 #[serial_test::serial]
1097 fn test_commit_in_worktree() -> Result<()> {
1098 let repo_dir = PathBuf::from("/tmp/test-chant-commit-in-worktree");
1099 cleanup_test_repo(&repo_dir)?;
1100 setup_test_repo(&repo_dir)?;
1101
1102 fs::write(repo_dir.join("new_file.txt"), "content")?;
1104
1105 let result = commit_in_worktree(&repo_dir, "Test commit message");
1107
1108 cleanup_test_repo(&repo_dir)?;
1109
1110 assert!(result.is_ok());
1111 let hash = result.unwrap();
1112 assert_eq!(hash.len(), 40);
1114 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
1115
1116 Ok(())
1117 }
1118
1119 #[test]
1120 #[serial_test::serial]
1121 fn test_commit_in_worktree_no_changes() -> Result<()> {
1122 let repo_dir = PathBuf::from("/tmp/test-chant-commit-no-changes");
1123 cleanup_test_repo(&repo_dir)?;
1124 setup_test_repo(&repo_dir)?;
1125
1126 let result = commit_in_worktree(&repo_dir, "Empty commit");
1128
1129 cleanup_test_repo(&repo_dir)?;
1130
1131 assert!(result.is_ok());
1133 let hash = result.unwrap();
1134 assert_eq!(hash.len(), 40);
1135
1136 Ok(())
1137 }
1138
1139 #[test]
1140 #[serial_test::serial]
1141 fn test_has_uncommitted_changes_clean() -> Result<()> {
1142 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-clean");
1143 cleanup_test_repo(&repo_dir)?;
1144 setup_test_repo(&repo_dir)?;
1145
1146 let has_changes = has_uncommitted_changes(&repo_dir)?;
1148
1149 cleanup_test_repo(&repo_dir)?;
1150
1151 assert!(
1152 !has_changes,
1153 "Clean repo should have no uncommitted changes"
1154 );
1155
1156 Ok(())
1157 }
1158
1159 #[test]
1160 #[serial_test::serial]
1161 fn test_has_uncommitted_changes_with_unstaged() -> Result<()> {
1162 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-unstaged");
1163 cleanup_test_repo(&repo_dir)?;
1164 setup_test_repo(&repo_dir)?;
1165
1166 fs::write(repo_dir.join("newfile.txt"), "content")?;
1168
1169 let has_changes = has_uncommitted_changes(&repo_dir)?;
1171
1172 cleanup_test_repo(&repo_dir)?;
1173
1174 assert!(has_changes, "Repo with unstaged changes should return true");
1175
1176 Ok(())
1177 }
1178
1179 #[test]
1180 #[serial_test::serial]
1181 fn test_has_uncommitted_changes_with_staged() -> Result<()> {
1182 let repo_dir = PathBuf::from("/tmp/test-chant-uncommitted-staged");
1183 cleanup_test_repo(&repo_dir)?;
1184 setup_test_repo(&repo_dir)?;
1185
1186 fs::write(repo_dir.join("newfile.txt"), "content")?;
1188 let output = StdCommand::new("git")
1189 .args(["add", "newfile.txt"])
1190 .current_dir(&repo_dir)
1191 .output()?;
1192 assert!(output.status.success());
1193
1194 let has_changes = has_uncommitted_changes(&repo_dir)?;
1196
1197 cleanup_test_repo(&repo_dir)?;
1198
1199 assert!(has_changes, "Repo with staged changes should return true");
1200
1201 Ok(())
1202 }
1203}