1use anyhow::{Context, Result};
9use std::process::Command;
10
11pub fn get_git_config(key: &str) -> Option<String> {
16 let output = Command::new("git").args(["config", key]).output().ok()?;
17
18 if !output.status.success() {
19 return None;
20 }
21
22 let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
23 if value.is_empty() {
24 None
25 } else {
26 Some(value)
27 }
28}
29
30pub fn get_git_user_info() -> (Option<String>, Option<String>) {
34 (get_git_config("user.name"), get_git_config("user.email"))
35}
36
37pub fn get_current_branch() -> Result<String> {
40 let output = Command::new("git")
41 .args(["rev-parse", "--abbrev-ref", "HEAD"])
42 .output()
43 .context("Failed to run git rev-parse")?;
44
45 if !output.status.success() {
46 anyhow::bail!("Failed to get current branch");
47 }
48
49 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
50 Ok(branch)
51}
52
53pub fn ensure_on_main_branch(main_branch: &str) -> Result<()> {
60 let current = get_current_branch()?;
61
62 if current != main_branch {
63 let output = Command::new("git")
64 .args(["checkout", main_branch])
65 .output()
66 .context("Failed to checkout main branch")?;
67
68 if !output.status.success() {
69 let stderr = String::from_utf8_lossy(&output.stderr);
70 eprintln!("Warning: Could not return to {}: {}", main_branch, stderr);
72 }
73 }
74
75 Ok(())
76}
77
78pub fn branch_exists(branch_name: &str) -> Result<bool> {
80 let output = Command::new("git")
81 .args(["branch", "--list", branch_name])
82 .output()
83 .context("Failed to check if branch exists")?;
84
85 if !output.status.success() {
86 anyhow::bail!("Failed to check if branch exists");
87 }
88
89 let stdout = String::from_utf8_lossy(&output.stdout);
90 Ok(!stdout.trim().is_empty())
91}
92
93pub fn is_branch_merged(branch_name: &str, target_branch: &str) -> Result<bool> {
104 let output = Command::new("git")
106 .args(["branch", "--merged", target_branch, "--list", branch_name])
107 .output()
108 .context("Failed to check if branch is merged")?;
109
110 if !output.status.success() {
111 anyhow::bail!("Failed to check if branch is merged");
112 }
113
114 let stdout = String::from_utf8_lossy(&output.stdout);
115 Ok(!stdout.trim().is_empty())
116}
117
118fn checkout_branch(branch: &str, dry_run: bool) -> Result<()> {
121 if dry_run {
122 return Ok(());
123 }
124
125 let output = Command::new("git")
126 .args(["checkout", branch])
127 .output()
128 .context("Failed to run git checkout")?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr);
132 anyhow::bail!("Failed to checkout {}: {}", branch, stderr);
133 }
134
135 Ok(())
136}
137
138fn branches_have_diverged(spec_branch: &str) -> Result<bool> {
146 let output = Command::new("git")
147 .args(["merge-base", "--is-ancestor", "HEAD", spec_branch])
148 .output()
149 .context("Failed to check if branches have diverged")?;
150
151 Ok(!output.status.success())
154}
155
156#[derive(Debug)]
158pub struct MergeAttemptResult {
159 pub success: bool,
161 pub conflict_type: Option<crate::merge_errors::ConflictType>,
163 pub conflicting_files: Vec<String>,
165 pub stderr: String,
167}
168
169fn merge_branch_ff_only(spec_branch: &str, dry_run: bool) -> Result<MergeAttemptResult> {
179 if dry_run {
180 return Ok(MergeAttemptResult {
181 success: true,
182 conflict_type: None,
183 conflicting_files: vec![],
184 stderr: String::new(),
185 });
186 }
187
188 let diverged = branches_have_diverged(spec_branch)?;
190
191 let merge_message = format!("Merge {}", spec_branch);
192
193 let mut cmd = Command::new("git");
194 if diverged {
195 cmd.args(["merge", "--no-ff", spec_branch, "-m", &merge_message]);
197 } else {
198 cmd.args(["merge", "--ff-only", spec_branch]);
200 }
201
202 let output = cmd.output().context("Failed to run git merge")?;
203
204 if !output.status.success() {
205 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
206
207 let status_output = Command::new("git")
209 .args(["status", "--porcelain"])
210 .output()
211 .ok()
212 .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
213
214 let conflict_type =
215 crate::merge_errors::classify_conflict_type(&stderr, status_output.as_deref());
216
217 let conflicting_files = status_output
218 .as_deref()
219 .map(crate::merge_errors::parse_conflicting_files)
220 .unwrap_or_default();
221
222 let _ = Command::new("git").args(["merge", "--abort"]).output();
224
225 return Ok(MergeAttemptResult {
226 success: false,
227 conflict_type: Some(conflict_type),
228 conflicting_files,
229 stderr,
230 });
231 }
232
233 Ok(MergeAttemptResult {
234 success: true,
235 conflict_type: None,
236 conflicting_files: vec![],
237 stderr: String::new(),
238 })
239}
240
241pub fn delete_branch(branch_name: &str, dry_run: bool) -> Result<()> {
244 if dry_run {
245 return Ok(());
246 }
247
248 remove_worktrees_for_branch(branch_name)?;
250
251 let output = Command::new("git")
252 .args(["branch", "-d", branch_name])
253 .output()
254 .context("Failed to run git branch -d")?;
255
256 if !output.status.success() {
257 let stderr = String::from_utf8_lossy(&output.stderr);
258 anyhow::bail!("Failed to delete branch {}: {}", branch_name, stderr);
259 }
260
261 Ok(())
262}
263
264fn remove_worktrees_for_branch(branch_name: &str) -> Result<()> {
267 let output = Command::new("git")
269 .args(["worktree", "list", "--porcelain"])
270 .output()
271 .context("Failed to list worktrees")?;
272
273 if !output.status.success() {
274 return Ok(());
276 }
277
278 let worktree_list = String::from_utf8_lossy(&output.stdout);
279 let mut current_path: Option<String> = None;
280 let mut worktrees_to_remove = Vec::new();
281
282 for line in worktree_list.lines() {
284 if line.starts_with("worktree ") {
285 current_path = Some(line.trim_start_matches("worktree ").to_string());
286 } else if line.starts_with("branch ") {
287 let branch = line
288 .trim_start_matches("branch ")
289 .trim_start_matches("refs/heads/");
290 if branch == branch_name {
291 if let Some(path) = current_path.take() {
292 worktrees_to_remove.push(path);
293 }
294 }
295 }
296 }
297
298 for path in worktrees_to_remove {
300 let _ = Command::new("git")
302 .args(["worktree", "remove", &path, "--force"])
303 .output();
304
305 let _ = std::fs::remove_dir_all(&path);
307 }
308
309 Ok(())
310}
311
312#[derive(Debug)]
314pub struct RebaseResult {
315 pub success: bool,
317 pub conflicting_files: Vec<String>,
319}
320
321pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
324 checkout_branch(spec_branch, false)?;
326
327 let output = Command::new("git")
329 .args(["rebase", onto_branch])
330 .output()
331 .context("Failed to run git rebase")?;
332
333 if output.status.success() {
334 return Ok(RebaseResult {
335 success: true,
336 conflicting_files: vec![],
337 });
338 }
339
340 let stderr = String::from_utf8_lossy(&output.stderr);
342 if stderr.contains("CONFLICT") || stderr.contains("conflict") {
343 let conflicting_files = get_conflicting_files()?;
345
346 let _ = Command::new("git").args(["rebase", "--abort"]).output();
348
349 return Ok(RebaseResult {
350 success: false,
351 conflicting_files,
352 });
353 }
354
355 let _ = Command::new("git").args(["rebase", "--abort"]).output();
357 anyhow::bail!("Rebase failed: {}", stderr);
358}
359
360pub fn get_conflicting_files() -> Result<Vec<String>> {
362 let output = Command::new("git")
363 .args(["status", "--porcelain"])
364 .output()
365 .context("Failed to run git status")?;
366
367 let stdout = String::from_utf8_lossy(&output.stdout);
368 let mut files = Vec::new();
369
370 for line in stdout.lines() {
371 if line.len() >= 3 {
373 let status = &line[0..2];
374 if status.contains('U') || status == "AA" || status == "DD" {
375 let file = line[3..].trim();
376 files.push(file.to_string());
377 }
378 }
379 }
380
381 Ok(files)
382}
383
384pub fn rebase_continue() -> Result<bool> {
386 let output = Command::new("git")
387 .args(["rebase", "--continue"])
388 .env("GIT_EDITOR", "true") .output()
390 .context("Failed to run git rebase --continue")?;
391
392 Ok(output.status.success())
393}
394
395pub fn rebase_abort() -> Result<()> {
397 let _ = Command::new("git").args(["rebase", "--abort"]).output();
398 Ok(())
399}
400
401pub fn stage_file(file_path: &str) -> Result<()> {
403 let output = Command::new("git")
404 .args(["add", file_path])
405 .output()
406 .context("Failed to run git add")?;
407
408 if !output.status.success() {
409 let stderr = String::from_utf8_lossy(&output.stderr);
410 anyhow::bail!("Failed to stage file {}: {}", file_path, stderr);
411 }
412
413 Ok(())
414}
415
416pub fn merge_single_spec(
428 spec_id: &str,
429 spec_branch: &str,
430 main_branch: &str,
431 should_delete_branch: bool,
432 dry_run: bool,
433) -> Result<MergeResult> {
434 if dry_run {
436 let original_branch = get_current_branch().unwrap_or_default();
437 return Ok(MergeResult {
438 spec_id: spec_id.to_string(),
439 success: true,
440 original_branch,
441 merged_to: main_branch.to_string(),
442 branch_deleted: should_delete_branch,
443 branch_delete_warning: None,
444 dry_run: true,
445 });
446 }
447
448 let original_branch = get_current_branch()?;
450
451 if !dry_run && !branch_exists(main_branch)? {
453 anyhow::bail!(
454 "{}",
455 crate::merge_errors::main_branch_not_found(main_branch)
456 );
457 }
458
459 if !dry_run && !branch_exists(spec_branch)? {
461 anyhow::bail!(
462 "{}",
463 crate::merge_errors::branch_not_found(spec_id, spec_branch)
464 );
465 }
466
467 if let Err(e) = checkout_branch(main_branch, dry_run) {
469 let _ = checkout_branch(&original_branch, false);
471 return Err(e);
472 }
473
474 let merge_result = match merge_branch_ff_only(spec_branch, dry_run) {
476 Ok(result) => result,
477 Err(e) => {
478 let _ = checkout_branch(&original_branch, false);
480 return Err(e);
481 }
482 };
483
484 if !merge_result.success && !dry_run {
485 let _ = checkout_branch(&original_branch, false);
487
488 let conflict_type = merge_result
490 .conflict_type
491 .unwrap_or(crate::merge_errors::ConflictType::Unknown);
492
493 anyhow::bail!(
494 "{}",
495 crate::merge_errors::merge_conflict_detailed(
496 spec_id,
497 spec_branch,
498 main_branch,
499 conflict_type,
500 &merge_result.conflicting_files
501 )
502 );
503 }
504
505 let merge_success = merge_result.success;
506
507 let mut branch_delete_warning: Option<String> = None;
509 let mut branch_actually_deleted = false;
510 if should_delete_branch && merge_success {
511 if let Err(e) = delete_branch(spec_branch, dry_run) {
512 branch_delete_warning = Some(format!("Warning: Failed to delete branch: {}", e));
514 } else {
515 branch_actually_deleted = true;
516 }
517 }
518
519 let should_checkout_original = original_branch != main_branch
523 && !(branch_actually_deleted && original_branch == spec_branch);
524
525 if should_checkout_original {
526 if let Err(e) = checkout_branch(&original_branch, false) {
527 eprintln!(
530 "Warning: Could not return to original branch '{}': {}. Staying on {}.",
531 original_branch, e, main_branch
532 );
533 }
534 }
535
536 Ok(MergeResult {
537 spec_id: spec_id.to_string(),
538 success: merge_success,
539 original_branch,
540 merged_to: main_branch.to_string(),
541 branch_deleted: should_delete_branch && merge_success,
542 branch_delete_warning,
543 dry_run,
544 })
545}
546
547#[derive(Debug, Clone)]
549pub struct MergeResult {
550 pub spec_id: String,
551 pub success: bool,
552 pub original_branch: String,
553 pub merged_to: String,
554 pub branch_deleted: bool,
555 pub branch_delete_warning: Option<String>,
556 pub dry_run: bool,
557}
558
559pub fn format_merge_summary(result: &MergeResult) -> String {
561 let mut output = String::new();
562
563 if result.dry_run {
564 output.push_str("[DRY RUN] ");
565 }
566
567 if result.success {
568 output.push_str(&format!(
569 "✓ Successfully merged {} to {}",
570 result.spec_id, result.merged_to
571 ));
572 if result.branch_deleted {
573 output.push_str(&format!(" and deleted branch {}", result.spec_id));
574 }
575 } else {
576 output.push_str(&format!(
577 "✗ Failed to merge {} to {}",
578 result.spec_id, result.merged_to
579 ));
580 }
581
582 if let Some(warning) = &result.branch_delete_warning {
583 output.push_str(&format!("\n {}", warning));
584 }
585
586 output.push_str(&format!("\nReturned to branch: {}", result.original_branch));
587
588 output
589}
590
591pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
594 let output = Command::new("git")
596 .args(["merge-base", target, branch])
597 .output()
598 .context("Failed to find merge base")?;
599
600 if !output.status.success() {
601 return Ok(false);
602 }
603
604 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
605
606 let output = Command::new("git")
608 .args(["rev-parse", target])
609 .output()
610 .context("Failed to get target commit")?;
611
612 if !output.status.success() {
613 return Ok(false);
614 }
615
616 let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
617
618 Ok(merge_base == target_commit)
620}
621
622pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
625 let output = Command::new("git")
627 .args(["merge-base", branch, target])
628 .output()
629 .context("Failed to find merge base")?;
630
631 if !output.status.success() {
632 return Ok(false);
633 }
634
635 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
636
637 let output = Command::new("git")
639 .args(["rev-parse", branch])
640 .output()
641 .context("Failed to get branch commit")?;
642
643 if !output.status.success() {
644 return Ok(false);
645 }
646
647 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
648
649 Ok(merge_base == branch_commit)
651}
652
653pub fn count_commits(branch: &str) -> Result<usize> {
655 let output = Command::new("git")
656 .args(["rev-list", "--count", branch])
657 .output()
658 .context("Failed to count commits")?;
659
660 if !output.status.success() {
661 return Ok(0);
662 }
663
664 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
665 Ok(count_str.parse().unwrap_or(0))
666}
667
668#[derive(Debug, Clone)]
670pub struct CommitInfo {
671 pub hash: String,
672 pub message: String,
673 pub author: String,
674 pub timestamp: i64,
675}
676
677pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
685 let range = format!("{}..{}", from_ref, to_ref);
686
687 let output = Command::new("git")
688 .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
689 .output()
690 .context("Failed to execute git log")?;
691
692 if !output.status.success() {
693 let stderr = String::from_utf8_lossy(&output.stderr);
694 anyhow::bail!("Invalid git refs {}: {}", range, stderr);
695 }
696
697 let stdout = String::from_utf8_lossy(&output.stdout);
698 let mut commits = Vec::new();
699
700 for line in stdout.lines() {
701 if line.is_empty() {
702 continue;
703 }
704
705 let parts: Vec<&str> = line.splitn(4, '|').collect();
706 if parts.len() != 4 {
707 continue;
708 }
709
710 commits.push(CommitInfo {
711 hash: parts[0].to_string(),
712 author: parts[1].to_string(),
713 timestamp: parts[2].parse().unwrap_or(0),
714 message: parts[3].to_string(),
715 });
716 }
717
718 Ok(commits)
719}
720
721pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
728 let output = Command::new("git")
729 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
730 .output()
731 .context("Failed to execute git diff-tree")?;
732
733 if !output.status.success() {
734 let stderr = String::from_utf8_lossy(&output.stderr);
735 anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
736 }
737
738 let stdout = String::from_utf8_lossy(&output.stdout);
739 let files: Vec<String> = stdout
740 .lines()
741 .filter(|line| !line.is_empty())
742 .map(|line| line.to_string())
743 .collect();
744
745 Ok(files)
746}
747
748pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
755 let output = Command::new("git")
756 .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
757 .output()
758 .context("Failed to execute git diff-tree")?;
759
760 if !output.status.success() {
761 return Ok(Vec::new());
762 }
763
764 let stdout = String::from_utf8_lossy(&output.stdout);
765 let mut files = Vec::new();
766
767 for line in stdout.lines() {
768 let parts: Vec<&str> = line.split('\t').collect();
769 if parts.len() >= 2 {
770 files.push(format!("{}:{}", parts[0], parts[1]));
772 }
773 }
774
775 Ok(files)
776}
777
778pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
785 let output = Command::new("git")
786 .args(["show", &format!("{}:{}", commit, file)])
787 .output()
788 .context("Failed to get file at commit")?;
789
790 if !output.status.success() {
791 return Ok(String::new());
792 }
793
794 Ok(String::from_utf8_lossy(&output.stdout).to_string())
795}
796
797pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
804 let output = Command::new("git")
805 .args(["show", &format!("{}^:{}", commit, file)])
806 .output()
807 .context("Failed to get file at parent")?;
808
809 if !output.status.success() {
810 return Ok(String::new());
811 }
812
813 Ok(String::from_utf8_lossy(&output.stdout).to_string())
814}
815
816pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
821 let count_str = count.to_string();
822
823 let output = Command::new("git")
824 .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
825 .output()
826 .context("Failed to execute git log")?;
827
828 if !output.status.success() {
829 let stderr = String::from_utf8_lossy(&output.stderr);
830 anyhow::bail!("Failed to get recent commits: {}", stderr);
831 }
832
833 let stdout = String::from_utf8_lossy(&output.stdout);
834 let mut commits = Vec::new();
835
836 for line in stdout.lines() {
837 if line.is_empty() {
838 continue;
839 }
840
841 let parts: Vec<&str> = line.splitn(4, '|').collect();
842 if parts.len() != 4 {
843 continue;
844 }
845
846 commits.push(CommitInfo {
847 hash: parts[0].to_string(),
848 author: parts[1].to_string(),
849 timestamp: parts[2].parse().unwrap_or(0),
850 message: parts[3].to_string(),
851 });
852 }
853
854 Ok(commits)
855}
856
857pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
865 let output = Command::new("git")
866 .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
867 .output()
868 .context("Failed to execute git log")?;
869
870 if !output.status.success() {
871 let stderr = String::from_utf8_lossy(&output.stderr);
872 anyhow::bail!("git log failed: {}", stderr);
873 }
874
875 let stdout = String::from_utf8_lossy(&output.stdout);
876 let mut commits = Vec::new();
877
878 for line in stdout.lines() {
879 if line.is_empty() {
880 continue;
881 }
882
883 let parts: Vec<&str> = line.splitn(4, '|').collect();
884 if parts.len() != 4 {
885 continue;
886 }
887
888 commits.push(CommitInfo {
889 hash: parts[0].to_string(),
890 author: parts[1].to_string(),
891 timestamp: parts[2].parse().unwrap_or(0),
892 message: parts[3].to_string(),
893 });
894 }
895
896 Ok(commits)
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use std::fs;
903 use tempfile::TempDir;
904
905 #[test]
906 fn test_get_current_branch_returns_string() {
907 let result = get_current_branch();
909 if let Ok(branch) = result {
911 assert!(!branch.is_empty());
913 }
914 }
915
916 fn setup_test_repo() -> Result<TempDir> {
918 let temp_dir = TempDir::new()?;
919 let repo_path = temp_dir.path();
920
921 Command::new("git")
923 .arg("init")
924 .current_dir(repo_path)
925 .output()?;
926
927 Command::new("git")
929 .args(["config", "user.email", "test@example.com"])
930 .current_dir(repo_path)
931 .output()?;
932
933 Command::new("git")
934 .args(["config", "user.name", "Test User"])
935 .current_dir(repo_path)
936 .output()?;
937
938 let file_path = repo_path.join("test.txt");
940 fs::write(&file_path, "test content")?;
941 Command::new("git")
942 .args(["add", "test.txt"])
943 .current_dir(repo_path)
944 .output()?;
945
946 Command::new("git")
947 .args(["commit", "-m", "Initial commit"])
948 .current_dir(repo_path)
949 .output()?;
950
951 Command::new("git")
953 .args(["branch", "main"])
954 .current_dir(repo_path)
955 .output()?;
956
957 Command::new("git")
958 .args(["checkout", "main"])
959 .current_dir(repo_path)
960 .output()?;
961
962 Ok(temp_dir)
963 }
964
965 #[test]
966 #[serial_test::serial]
967 fn test_merge_single_spec_successful_dry_run() -> Result<()> {
968 let temp_dir = setup_test_repo()?;
969 let repo_path = temp_dir.path();
970 let original_dir = std::env::current_dir()?;
971
972 std::env::set_current_dir(repo_path)?;
973
974 Command::new("git")
976 .args(["checkout", "-b", "spec-001"])
977 .output()?;
978
979 let file_path = repo_path.join("spec-file.txt");
981 fs::write(&file_path, "spec content")?;
982 Command::new("git")
983 .args(["add", "spec-file.txt"])
984 .output()?;
985 Command::new("git")
986 .args(["commit", "-m", "Add spec-file"])
987 .output()?;
988
989 Command::new("git").args(["checkout", "main"]).output()?;
991
992 let result = merge_single_spec("spec-001", "spec-001", "main", false, true)?;
994
995 assert!(result.success);
996 assert!(result.dry_run);
997 assert_eq!(result.spec_id, "spec-001");
998 assert_eq!(result.merged_to, "main");
999 assert_eq!(result.original_branch, "main");
1000
1001 let current = get_current_branch()?;
1003 assert_eq!(current, "main");
1004
1005 assert!(branch_exists("spec-001")?);
1007
1008 std::env::set_current_dir(original_dir)?;
1009 Ok(())
1010 }
1011
1012 #[test]
1013 #[serial_test::serial]
1014 fn test_merge_single_spec_successful_with_delete() -> Result<()> {
1015 let temp_dir = setup_test_repo()?;
1016 let repo_path = temp_dir.path();
1017 let original_dir = std::env::current_dir()?;
1018
1019 std::env::set_current_dir(repo_path)?;
1020
1021 Command::new("git")
1023 .args(["checkout", "-b", "spec-002"])
1024 .output()?;
1025
1026 let file_path = repo_path.join("spec-file2.txt");
1028 fs::write(&file_path, "spec content 2")?;
1029 Command::new("git")
1030 .args(["add", "spec-file2.txt"])
1031 .output()?;
1032 Command::new("git")
1033 .args(["commit", "-m", "Add spec-file2"])
1034 .output()?;
1035
1036 Command::new("git").args(["checkout", "main"]).output()?;
1038
1039 let result = merge_single_spec("spec-002", "spec-002", "main", true, false)?;
1041
1042 assert!(result.success);
1043 assert!(!result.dry_run);
1044 assert!(result.branch_deleted);
1045
1046 assert!(!branch_exists("spec-002")?);
1048
1049 let current = get_current_branch()?;
1051 assert_eq!(current, "main");
1052
1053 std::env::set_current_dir(original_dir)?;
1054 Ok(())
1055 }
1056
1057 #[test]
1058 #[serial_test::serial]
1059 fn test_merge_single_spec_nonexistent_main_branch() -> Result<()> {
1060 let temp_dir = setup_test_repo()?;
1061 let repo_path = temp_dir.path();
1062 let original_dir = std::env::current_dir()?;
1063
1064 std::env::set_current_dir(repo_path)?;
1065
1066 Command::new("git")
1068 .args(["checkout", "-b", "spec-003"])
1069 .output()?;
1070
1071 let file_path = repo_path.join("spec-file3.txt");
1073 fs::write(&file_path, "spec content 3")?;
1074 Command::new("git")
1075 .args(["add", "spec-file3.txt"])
1076 .output()?;
1077 Command::new("git")
1078 .args(["commit", "-m", "Add spec-file3"])
1079 .output()?;
1080
1081 let result = merge_single_spec("spec-003", "spec-003", "nonexistent", false, false);
1083
1084 assert!(result.is_err());
1085 assert!(result.unwrap_err().to_string().contains("does not exist"));
1086
1087 let current = get_current_branch()?;
1089 assert_eq!(current, "spec-003");
1090
1091 std::env::set_current_dir(original_dir)?;
1092 Ok(())
1093 }
1094
1095 #[test]
1096 #[serial_test::serial]
1097 fn test_merge_single_spec_nonexistent_spec_branch() -> Result<()> {
1098 let temp_dir = setup_test_repo()?;
1099 let repo_path = temp_dir.path();
1100 let original_dir = std::env::current_dir()?;
1101
1102 std::env::set_current_dir(repo_path)?;
1103
1104 let result = merge_single_spec("nonexistent", "nonexistent", "main", false, false);
1106
1107 assert!(result.is_err());
1108 assert!(result.unwrap_err().to_string().contains("not found"));
1109
1110 let current = get_current_branch()?;
1112 assert_eq!(current, "main");
1113
1114 std::env::set_current_dir(original_dir)?;
1115 Ok(())
1116 }
1117
1118 #[test]
1119 fn test_format_merge_summary_success() {
1120 let result = MergeResult {
1121 spec_id: "spec-001".to_string(),
1122 success: true,
1123 original_branch: "main".to_string(),
1124 merged_to: "main".to_string(),
1125 branch_deleted: false,
1126 branch_delete_warning: None,
1127 dry_run: false,
1128 };
1129
1130 let summary = format_merge_summary(&result);
1131 assert!(summary.contains("✓"));
1132 assert!(summary.contains("spec-001"));
1133 assert!(summary.contains("Returned to branch: main"));
1134 }
1135
1136 #[test]
1137 fn test_format_merge_summary_with_delete() {
1138 let result = MergeResult {
1139 spec_id: "spec-002".to_string(),
1140 success: true,
1141 original_branch: "main".to_string(),
1142 merged_to: "main".to_string(),
1143 branch_deleted: true,
1144 branch_delete_warning: None,
1145 dry_run: false,
1146 };
1147
1148 let summary = format_merge_summary(&result);
1149 assert!(summary.contains("✓"));
1150 assert!(summary.contains("deleted branch spec-002"));
1151 }
1152
1153 #[test]
1154 fn test_format_merge_summary_dry_run() {
1155 let result = MergeResult {
1156 spec_id: "spec-003".to_string(),
1157 success: true,
1158 original_branch: "main".to_string(),
1159 merged_to: "main".to_string(),
1160 branch_deleted: false,
1161 branch_delete_warning: None,
1162 dry_run: true,
1163 };
1164
1165 let summary = format_merge_summary(&result);
1166 assert!(summary.contains("[DRY RUN]"));
1167 }
1168
1169 #[test]
1170 fn test_format_merge_summary_with_warning() {
1171 let result = MergeResult {
1172 spec_id: "spec-004".to_string(),
1173 success: true,
1174 original_branch: "main".to_string(),
1175 merged_to: "main".to_string(),
1176 branch_deleted: false,
1177 branch_delete_warning: Some("Warning: Could not delete branch".to_string()),
1178 dry_run: false,
1179 };
1180
1181 let summary = format_merge_summary(&result);
1182 assert!(summary.contains("Warning"));
1183 }
1184
1185 #[test]
1186 fn test_format_merge_summary_failure() {
1187 let result = MergeResult {
1188 spec_id: "spec-005".to_string(),
1189 success: false,
1190 original_branch: "main".to_string(),
1191 merged_to: "main".to_string(),
1192 branch_deleted: false,
1193 branch_delete_warning: None,
1194 dry_run: false,
1195 };
1196
1197 let summary = format_merge_summary(&result);
1198 assert!(summary.contains("✗"));
1199 assert!(summary.contains("Failed to merge"));
1200 }
1201
1202 #[test]
1203 #[serial_test::serial]
1204 fn test_branches_have_diverged_no_divergence() -> Result<()> {
1205 let temp_dir = setup_test_repo()?;
1206 let repo_path = temp_dir.path();
1207 let original_dir = std::env::current_dir()?;
1208
1209 std::env::set_current_dir(repo_path)?;
1210
1211 Command::new("git")
1213 .args(["checkout", "-b", "spec-no-diverge"])
1214 .output()?;
1215
1216 let file_path = repo_path.join("diverge-test.txt");
1218 fs::write(&file_path, "spec content")?;
1219 Command::new("git")
1220 .args(["add", "diverge-test.txt"])
1221 .output()?;
1222 Command::new("git")
1223 .args(["commit", "-m", "Add diverge-test"])
1224 .output()?;
1225
1226 Command::new("git").args(["checkout", "main"]).output()?;
1228
1229 let diverged = branches_have_diverged("spec-no-diverge")?;
1231 assert!(!diverged, "Fast-forward merge should be possible");
1232
1233 std::env::set_current_dir(original_dir)?;
1234 Ok(())
1235 }
1236
1237 #[test]
1238 #[serial_test::serial]
1239 fn test_branches_have_diverged_with_divergence() -> Result<()> {
1240 let temp_dir = setup_test_repo()?;
1241 let repo_path = temp_dir.path();
1242 let original_dir = std::env::current_dir()?;
1243
1244 std::env::set_current_dir(repo_path)?;
1245
1246 Command::new("git")
1248 .args(["checkout", "-b", "spec-diverge"])
1249 .output()?;
1250
1251 let file_path = repo_path.join("spec-file.txt");
1253 fs::write(&file_path, "spec content")?;
1254 Command::new("git")
1255 .args(["add", "spec-file.txt"])
1256 .output()?;
1257 Command::new("git")
1258 .args(["commit", "-m", "Add spec-file"])
1259 .output()?;
1260
1261 Command::new("git").args(["checkout", "main"]).output()?;
1263 let main_file = repo_path.join("main-file.txt");
1264 fs::write(&main_file, "main content")?;
1265 Command::new("git")
1266 .args(["add", "main-file.txt"])
1267 .output()?;
1268 Command::new("git")
1269 .args(["commit", "-m", "Add main-file"])
1270 .output()?;
1271
1272 let diverged = branches_have_diverged("spec-diverge")?;
1274 assert!(diverged, "Branches should have diverged");
1275
1276 std::env::set_current_dir(original_dir)?;
1277 Ok(())
1278 }
1279
1280 #[test]
1281 #[serial_test::serial]
1282 fn test_merge_single_spec_with_diverged_branches() -> Result<()> {
1283 let temp_dir = setup_test_repo()?;
1284 let repo_path = temp_dir.path();
1285 let original_dir = std::env::current_dir()?;
1286
1287 std::env::set_current_dir(repo_path)?;
1288
1289 Command::new("git")
1291 .args(["checkout", "-b", "spec-diverged"])
1292 .output()?;
1293
1294 let file_path = repo_path.join("spec-change.txt");
1296 fs::write(&file_path, "spec content")?;
1297 Command::new("git")
1298 .args(["add", "spec-change.txt"])
1299 .output()?;
1300 Command::new("git")
1301 .args(["commit", "-m", "Add spec-change"])
1302 .output()?;
1303
1304 Command::new("git").args(["checkout", "main"]).output()?;
1306 let main_file = repo_path.join("main-change.txt");
1307 fs::write(&main_file, "main content")?;
1308 Command::new("git")
1309 .args(["add", "main-change.txt"])
1310 .output()?;
1311 Command::new("git")
1312 .args(["commit", "-m", "Add main-change"])
1313 .output()?;
1314
1315 let result = merge_single_spec("spec-diverged", "spec-diverged", "main", false, false)?;
1317
1318 assert!(result.success, "Merge should succeed with --no-ff");
1319 assert_eq!(result.spec_id, "spec-diverged");
1320 assert_eq!(result.merged_to, "main");
1321
1322 let current = get_current_branch()?;
1324 assert_eq!(current, "main");
1325
1326 std::env::set_current_dir(original_dir)?;
1327 Ok(())
1328 }
1329
1330 #[test]
1331 #[serial_test::serial]
1332 fn test_ensure_on_main_branch() -> Result<()> {
1333 let temp_dir = setup_test_repo()?;
1334 let repo_path = temp_dir.path();
1335 let original_dir = std::env::current_dir()?;
1336
1337 std::env::set_current_dir(repo_path)?;
1338
1339 Command::new("git")
1341 .args(["checkout", "-b", "spec-test"])
1342 .output()?;
1343
1344 let current = get_current_branch()?;
1346 assert_eq!(current, "spec-test");
1347
1348 ensure_on_main_branch("main")?;
1350
1351 let current = get_current_branch()?;
1353 assert_eq!(current, "main");
1354
1355 std::env::set_current_dir(original_dir)?;
1356 Ok(())
1357 }
1358
1359 #[test]
1360 #[serial_test::serial]
1361 fn test_ensure_on_main_branch_already_on_main() -> Result<()> {
1362 let temp_dir = setup_test_repo()?;
1363 let repo_path = temp_dir.path();
1364 let original_dir = std::env::current_dir()?;
1365
1366 std::env::set_current_dir(repo_path)?;
1367
1368 let current = get_current_branch()?;
1370 assert_eq!(current, "main");
1371
1372 ensure_on_main_branch("main")?;
1374
1375 let current = get_current_branch()?;
1377 assert_eq!(current, "main");
1378
1379 std::env::set_current_dir(original_dir)?;
1380 Ok(())
1381 }
1382
1383 #[test]
1384 #[serial_test::serial]
1385 fn test_get_commits_in_range() -> Result<()> {
1386 let temp_dir = setup_test_repo()?;
1387 let repo_path = temp_dir.path();
1388 let original_dir = std::env::current_dir()?;
1389
1390 std::env::set_current_dir(repo_path)?;
1391
1392 for i in 1..=5 {
1394 let file_path = repo_path.join(format!("test{}.txt", i));
1395 fs::write(&file_path, format!("content {}", i))?;
1396 Command::new("git").args(["add", "."]).output()?;
1397 Command::new("git")
1398 .args(["commit", "-m", &format!("Commit {}", i)])
1399 .output()?;
1400 }
1401
1402 let commits = get_commits_in_range("HEAD~5", "HEAD")?;
1404
1405 assert_eq!(commits.len(), 5);
1406 assert_eq!(commits[0].message, "Commit 1");
1407 assert_eq!(commits[4].message, "Commit 5");
1408
1409 std::env::set_current_dir(original_dir)?;
1410 Ok(())
1411 }
1412
1413 #[test]
1414 #[serial_test::serial]
1415 fn test_get_commits_in_range_invalid_refs() -> Result<()> {
1416 let temp_dir = setup_test_repo()?;
1417 let repo_path = temp_dir.path();
1418 let original_dir = std::env::current_dir()?;
1419
1420 std::env::set_current_dir(repo_path)?;
1421
1422 let result = get_commits_in_range("invalid", "HEAD");
1423 assert!(result.is_err());
1424
1425 std::env::set_current_dir(original_dir)?;
1426 Ok(())
1427 }
1428
1429 #[test]
1430 #[serial_test::serial]
1431 fn test_get_commits_in_range_empty() -> Result<()> {
1432 let temp_dir = setup_test_repo()?;
1433 let repo_path = temp_dir.path();
1434 let original_dir = std::env::current_dir()?;
1435
1436 std::env::set_current_dir(repo_path)?;
1437
1438 let commits = get_commits_in_range("HEAD", "HEAD")?;
1440 assert_eq!(commits.len(), 0);
1441
1442 std::env::set_current_dir(original_dir)?;
1443 Ok(())
1444 }
1445
1446 #[test]
1447 #[serial_test::serial]
1448 fn test_get_commit_changed_files() -> Result<()> {
1449 let temp_dir = setup_test_repo()?;
1450 let repo_path = temp_dir.path();
1451 let original_dir = std::env::current_dir()?;
1452
1453 std::env::set_current_dir(repo_path)?;
1454
1455 let file1 = repo_path.join("file1.txt");
1457 let file2 = repo_path.join("file2.txt");
1458 fs::write(&file1, "content1")?;
1459 fs::write(&file2, "content2")?;
1460 Command::new("git").args(["add", "."]).output()?;
1461 Command::new("git")
1462 .args(["commit", "-m", "Add files"])
1463 .output()?;
1464
1465 let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1466 let hash = String::from_utf8_lossy(&hash_output.stdout)
1467 .trim()
1468 .to_string();
1469
1470 let files = get_commit_changed_files(&hash)?;
1471 assert_eq!(files.len(), 2);
1472 assert!(files.contains(&"file1.txt".to_string()));
1473 assert!(files.contains(&"file2.txt".to_string()));
1474
1475 std::env::set_current_dir(original_dir)?;
1476 Ok(())
1477 }
1478
1479 #[test]
1480 #[serial_test::serial]
1481 fn test_get_commit_changed_files_invalid_hash() -> Result<()> {
1482 let temp_dir = setup_test_repo()?;
1483 let repo_path = temp_dir.path();
1484 let original_dir = std::env::current_dir()?;
1485
1486 std::env::set_current_dir(repo_path)?;
1487
1488 let result = get_commit_changed_files("invalid_hash");
1489 assert!(result.is_err());
1490
1491 std::env::set_current_dir(original_dir)?;
1492 Ok(())
1493 }
1494
1495 #[test]
1496 #[serial_test::serial]
1497 fn test_get_commit_changed_files_empty() -> Result<()> {
1498 let temp_dir = setup_test_repo()?;
1499 let repo_path = temp_dir.path();
1500 let original_dir = std::env::current_dir()?;
1501
1502 std::env::set_current_dir(repo_path)?;
1503
1504 Command::new("git")
1506 .args(["commit", "--allow-empty", "-m", "Empty commit"])
1507 .output()?;
1508
1509 let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1510 let hash = String::from_utf8_lossy(&hash_output.stdout)
1511 .trim()
1512 .to_string();
1513
1514 let files = get_commit_changed_files(&hash)?;
1515 assert_eq!(files.len(), 0);
1516
1517 std::env::set_current_dir(original_dir)?;
1518 Ok(())
1519 }
1520
1521 #[test]
1522 #[serial_test::serial]
1523 fn test_get_recent_commits() -> Result<()> {
1524 let temp_dir = setup_test_repo()?;
1525 let repo_path = temp_dir.path();
1526 let original_dir = std::env::current_dir()?;
1527
1528 std::env::set_current_dir(repo_path)?;
1529
1530 for i in 1..=5 {
1532 let file_path = repo_path.join(format!("test{}.txt", i));
1533 fs::write(&file_path, format!("content {}", i))?;
1534 Command::new("git").args(["add", "."]).output()?;
1535 Command::new("git")
1536 .args(["commit", "-m", &format!("Recent {}", i)])
1537 .output()?;
1538 }
1539
1540 let commits = get_recent_commits(3)?;
1542 assert_eq!(commits.len(), 3);
1543 assert_eq!(commits[0].message, "Recent 5");
1544 assert_eq!(commits[1].message, "Recent 4");
1545 assert_eq!(commits[2].message, "Recent 3");
1546
1547 std::env::set_current_dir(original_dir)?;
1548 Ok(())
1549 }
1550}