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 let output = Command::new("git")
249 .args(["branch", "-d", branch_name])
250 .output()
251 .context("Failed to run git branch -d")?;
252
253 if !output.status.success() {
254 let stderr = String::from_utf8_lossy(&output.stderr);
255 anyhow::bail!("Failed to delete branch {}: {}", branch_name, stderr);
256 }
257
258 Ok(())
259}
260
261#[derive(Debug)]
263pub struct RebaseResult {
264 pub success: bool,
266 pub conflicting_files: Vec<String>,
268}
269
270pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
273 checkout_branch(spec_branch, false)?;
275
276 let output = Command::new("git")
278 .args(["rebase", onto_branch])
279 .output()
280 .context("Failed to run git rebase")?;
281
282 if output.status.success() {
283 return Ok(RebaseResult {
284 success: true,
285 conflicting_files: vec![],
286 });
287 }
288
289 let stderr = String::from_utf8_lossy(&output.stderr);
291 if stderr.contains("CONFLICT") || stderr.contains("conflict") {
292 let conflicting_files = get_conflicting_files()?;
294
295 let _ = Command::new("git").args(["rebase", "--abort"]).output();
297
298 return Ok(RebaseResult {
299 success: false,
300 conflicting_files,
301 });
302 }
303
304 let _ = Command::new("git").args(["rebase", "--abort"]).output();
306 anyhow::bail!("Rebase failed: {}", stderr);
307}
308
309pub fn get_conflicting_files() -> Result<Vec<String>> {
311 let output = Command::new("git")
312 .args(["status", "--porcelain"])
313 .output()
314 .context("Failed to run git status")?;
315
316 let stdout = String::from_utf8_lossy(&output.stdout);
317 let mut files = Vec::new();
318
319 for line in stdout.lines() {
320 if line.len() >= 3 {
322 let status = &line[0..2];
323 if status.contains('U') || status == "AA" || status == "DD" {
324 let file = line[3..].trim();
325 files.push(file.to_string());
326 }
327 }
328 }
329
330 Ok(files)
331}
332
333pub fn rebase_continue() -> Result<bool> {
335 let output = Command::new("git")
336 .args(["rebase", "--continue"])
337 .env("GIT_EDITOR", "true") .output()
339 .context("Failed to run git rebase --continue")?;
340
341 Ok(output.status.success())
342}
343
344pub fn rebase_abort() -> Result<()> {
346 let _ = Command::new("git").args(["rebase", "--abort"]).output();
347 Ok(())
348}
349
350pub fn stage_file(file_path: &str) -> Result<()> {
352 let output = Command::new("git")
353 .args(["add", file_path])
354 .output()
355 .context("Failed to run git add")?;
356
357 if !output.status.success() {
358 let stderr = String::from_utf8_lossy(&output.stderr);
359 anyhow::bail!("Failed to stage file {}: {}", file_path, stderr);
360 }
361
362 Ok(())
363}
364
365pub fn merge_single_spec(
377 spec_id: &str,
378 spec_branch: &str,
379 main_branch: &str,
380 should_delete_branch: bool,
381 dry_run: bool,
382) -> Result<MergeResult> {
383 if dry_run {
385 let original_branch = get_current_branch().unwrap_or_default();
386 return Ok(MergeResult {
387 spec_id: spec_id.to_string(),
388 success: true,
389 original_branch,
390 merged_to: main_branch.to_string(),
391 branch_deleted: should_delete_branch,
392 branch_delete_warning: None,
393 dry_run: true,
394 });
395 }
396
397 let original_branch = get_current_branch()?;
399
400 if !dry_run && !branch_exists(main_branch)? {
402 anyhow::bail!(
403 "{}",
404 crate::merge_errors::main_branch_not_found(main_branch)
405 );
406 }
407
408 if !dry_run && !branch_exists(spec_branch)? {
410 anyhow::bail!(
411 "{}",
412 crate::merge_errors::branch_not_found(spec_id, spec_branch)
413 );
414 }
415
416 if let Err(e) = checkout_branch(main_branch, dry_run) {
418 let _ = checkout_branch(&original_branch, false);
420 return Err(e);
421 }
422
423 let merge_result = match merge_branch_ff_only(spec_branch, dry_run) {
425 Ok(result) => result,
426 Err(e) => {
427 let _ = checkout_branch(&original_branch, false);
429 return Err(e);
430 }
431 };
432
433 if !merge_result.success && !dry_run {
434 let _ = checkout_branch(&original_branch, false);
436
437 let conflict_type = merge_result
439 .conflict_type
440 .unwrap_or(crate::merge_errors::ConflictType::Unknown);
441
442 anyhow::bail!(
443 "{}",
444 crate::merge_errors::merge_conflict_detailed(
445 spec_id,
446 spec_branch,
447 main_branch,
448 conflict_type,
449 &merge_result.conflicting_files
450 )
451 );
452 }
453
454 let merge_success = merge_result.success;
455
456 let mut branch_delete_warning: Option<String> = None;
458 let mut branch_actually_deleted = false;
459 if should_delete_branch && merge_success {
460 if let Err(e) = delete_branch(spec_branch, dry_run) {
461 branch_delete_warning = Some(format!("Warning: Failed to delete branch: {}", e));
463 } else {
464 branch_actually_deleted = true;
465 }
466 }
467
468 let should_checkout_original = original_branch != main_branch
472 && !(branch_actually_deleted && original_branch == spec_branch);
473
474 if should_checkout_original {
475 if let Err(e) = checkout_branch(&original_branch, false) {
476 eprintln!(
479 "Warning: Could not return to original branch '{}': {}. Staying on {}.",
480 original_branch, e, main_branch
481 );
482 }
483 }
484
485 Ok(MergeResult {
486 spec_id: spec_id.to_string(),
487 success: merge_success,
488 original_branch,
489 merged_to: main_branch.to_string(),
490 branch_deleted: should_delete_branch && merge_success,
491 branch_delete_warning,
492 dry_run,
493 })
494}
495
496#[derive(Debug, Clone)]
498pub struct MergeResult {
499 pub spec_id: String,
500 pub success: bool,
501 pub original_branch: String,
502 pub merged_to: String,
503 pub branch_deleted: bool,
504 pub branch_delete_warning: Option<String>,
505 pub dry_run: bool,
506}
507
508pub fn format_merge_summary(result: &MergeResult) -> String {
510 let mut output = String::new();
511
512 if result.dry_run {
513 output.push_str("[DRY RUN] ");
514 }
515
516 if result.success {
517 output.push_str(&format!(
518 "✓ Successfully merged {} to {}",
519 result.spec_id, result.merged_to
520 ));
521 if result.branch_deleted {
522 output.push_str(&format!(" and deleted branch {}", result.spec_id));
523 }
524 } else {
525 output.push_str(&format!(
526 "✗ Failed to merge {} to {}",
527 result.spec_id, result.merged_to
528 ));
529 }
530
531 if let Some(warning) = &result.branch_delete_warning {
532 output.push_str(&format!("\n {}", warning));
533 }
534
535 output.push_str(&format!("\nReturned to branch: {}", result.original_branch));
536
537 output
538}
539
540pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
543 let output = Command::new("git")
545 .args(["merge-base", target, branch])
546 .output()
547 .context("Failed to find merge base")?;
548
549 if !output.status.success() {
550 return Ok(false);
551 }
552
553 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
554
555 let output = Command::new("git")
557 .args(["rev-parse", target])
558 .output()
559 .context("Failed to get target commit")?;
560
561 if !output.status.success() {
562 return Ok(false);
563 }
564
565 let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
566
567 Ok(merge_base == target_commit)
569}
570
571pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
574 let output = Command::new("git")
576 .args(["merge-base", branch, target])
577 .output()
578 .context("Failed to find merge base")?;
579
580 if !output.status.success() {
581 return Ok(false);
582 }
583
584 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
585
586 let output = Command::new("git")
588 .args(["rev-parse", branch])
589 .output()
590 .context("Failed to get branch commit")?;
591
592 if !output.status.success() {
593 return Ok(false);
594 }
595
596 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
597
598 Ok(merge_base == branch_commit)
600}
601
602pub fn count_commits(branch: &str) -> Result<usize> {
604 let output = Command::new("git")
605 .args(["rev-list", "--count", branch])
606 .output()
607 .context("Failed to count commits")?;
608
609 if !output.status.success() {
610 return Ok(0);
611 }
612
613 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
614 Ok(count_str.parse().unwrap_or(0))
615}
616
617#[derive(Debug, Clone)]
619pub struct CommitInfo {
620 pub hash: String,
621 pub message: String,
622 pub author: String,
623 pub timestamp: i64,
624}
625
626pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
634 let range = format!("{}..{}", from_ref, to_ref);
635
636 let output = Command::new("git")
637 .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
638 .output()
639 .context("Failed to execute git log")?;
640
641 if !output.status.success() {
642 let stderr = String::from_utf8_lossy(&output.stderr);
643 anyhow::bail!("Invalid git refs {}: {}", range, stderr);
644 }
645
646 let stdout = String::from_utf8_lossy(&output.stdout);
647 let mut commits = Vec::new();
648
649 for line in stdout.lines() {
650 if line.is_empty() {
651 continue;
652 }
653
654 let parts: Vec<&str> = line.splitn(4, '|').collect();
655 if parts.len() != 4 {
656 continue;
657 }
658
659 commits.push(CommitInfo {
660 hash: parts[0].to_string(),
661 author: parts[1].to_string(),
662 timestamp: parts[2].parse().unwrap_or(0),
663 message: parts[3].to_string(),
664 });
665 }
666
667 Ok(commits)
668}
669
670pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
677 let output = Command::new("git")
678 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
679 .output()
680 .context("Failed to execute git diff-tree")?;
681
682 if !output.status.success() {
683 let stderr = String::from_utf8_lossy(&output.stderr);
684 anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
685 }
686
687 let stdout = String::from_utf8_lossy(&output.stdout);
688 let files: Vec<String> = stdout
689 .lines()
690 .filter(|line| !line.is_empty())
691 .map(|line| line.to_string())
692 .collect();
693
694 Ok(files)
695}
696
697pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
704 let output = Command::new("git")
705 .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
706 .output()
707 .context("Failed to execute git diff-tree")?;
708
709 if !output.status.success() {
710 return Ok(Vec::new());
711 }
712
713 let stdout = String::from_utf8_lossy(&output.stdout);
714 let mut files = Vec::new();
715
716 for line in stdout.lines() {
717 let parts: Vec<&str> = line.split('\t').collect();
718 if parts.len() >= 2 {
719 files.push(format!("{}:{}", parts[0], parts[1]));
721 }
722 }
723
724 Ok(files)
725}
726
727pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
734 let output = Command::new("git")
735 .args(["show", &format!("{}:{}", commit, file)])
736 .output()
737 .context("Failed to get file at commit")?;
738
739 if !output.status.success() {
740 return Ok(String::new());
741 }
742
743 Ok(String::from_utf8_lossy(&output.stdout).to_string())
744}
745
746pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
753 let output = Command::new("git")
754 .args(["show", &format!("{}^:{}", commit, file)])
755 .output()
756 .context("Failed to get file at parent")?;
757
758 if !output.status.success() {
759 return Ok(String::new());
760 }
761
762 Ok(String::from_utf8_lossy(&output.stdout).to_string())
763}
764
765pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
770 let count_str = count.to_string();
771
772 let output = Command::new("git")
773 .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
774 .output()
775 .context("Failed to execute git log")?;
776
777 if !output.status.success() {
778 let stderr = String::from_utf8_lossy(&output.stderr);
779 anyhow::bail!("Failed to get recent commits: {}", stderr);
780 }
781
782 let stdout = String::from_utf8_lossy(&output.stdout);
783 let mut commits = Vec::new();
784
785 for line in stdout.lines() {
786 if line.is_empty() {
787 continue;
788 }
789
790 let parts: Vec<&str> = line.splitn(4, '|').collect();
791 if parts.len() != 4 {
792 continue;
793 }
794
795 commits.push(CommitInfo {
796 hash: parts[0].to_string(),
797 author: parts[1].to_string(),
798 timestamp: parts[2].parse().unwrap_or(0),
799 message: parts[3].to_string(),
800 });
801 }
802
803 Ok(commits)
804}
805
806pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
814 let output = Command::new("git")
815 .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
816 .output()
817 .context("Failed to execute git log")?;
818
819 if !output.status.success() {
820 let stderr = String::from_utf8_lossy(&output.stderr);
821 anyhow::bail!("git log failed: {}", stderr);
822 }
823
824 let stdout = String::from_utf8_lossy(&output.stdout);
825 let mut commits = Vec::new();
826
827 for line in stdout.lines() {
828 if line.is_empty() {
829 continue;
830 }
831
832 let parts: Vec<&str> = line.splitn(4, '|').collect();
833 if parts.len() != 4 {
834 continue;
835 }
836
837 commits.push(CommitInfo {
838 hash: parts[0].to_string(),
839 author: parts[1].to_string(),
840 timestamp: parts[2].parse().unwrap_or(0),
841 message: parts[3].to_string(),
842 });
843 }
844
845 Ok(commits)
846}
847
848#[cfg(test)]
849mod tests {
850 use super::*;
851 use std::fs;
852 use tempfile::TempDir;
853
854 #[test]
855 fn test_get_current_branch_returns_string() {
856 let result = get_current_branch();
858 if let Ok(branch) = result {
860 assert!(!branch.is_empty());
862 }
863 }
864
865 fn setup_test_repo() -> Result<TempDir> {
867 let temp_dir = TempDir::new()?;
868 let repo_path = temp_dir.path();
869
870 Command::new("git")
872 .arg("init")
873 .current_dir(repo_path)
874 .output()?;
875
876 Command::new("git")
878 .args(["config", "user.email", "test@example.com"])
879 .current_dir(repo_path)
880 .output()?;
881
882 Command::new("git")
883 .args(["config", "user.name", "Test User"])
884 .current_dir(repo_path)
885 .output()?;
886
887 let file_path = repo_path.join("test.txt");
889 fs::write(&file_path, "test content")?;
890 Command::new("git")
891 .args(["add", "test.txt"])
892 .current_dir(repo_path)
893 .output()?;
894
895 Command::new("git")
896 .args(["commit", "-m", "Initial commit"])
897 .current_dir(repo_path)
898 .output()?;
899
900 Command::new("git")
902 .args(["branch", "main"])
903 .current_dir(repo_path)
904 .output()?;
905
906 Command::new("git")
907 .args(["checkout", "main"])
908 .current_dir(repo_path)
909 .output()?;
910
911 Ok(temp_dir)
912 }
913
914 #[test]
915 #[serial_test::serial]
916 fn test_merge_single_spec_successful_dry_run() -> Result<()> {
917 let temp_dir = setup_test_repo()?;
918 let repo_path = temp_dir.path();
919 let original_dir = std::env::current_dir()?;
920
921 std::env::set_current_dir(repo_path)?;
922
923 Command::new("git")
925 .args(["checkout", "-b", "spec-001"])
926 .output()?;
927
928 let file_path = repo_path.join("spec-file.txt");
930 fs::write(&file_path, "spec content")?;
931 Command::new("git")
932 .args(["add", "spec-file.txt"])
933 .output()?;
934 Command::new("git")
935 .args(["commit", "-m", "Add spec-file"])
936 .output()?;
937
938 Command::new("git").args(["checkout", "main"]).output()?;
940
941 let result = merge_single_spec("spec-001", "spec-001", "main", false, true)?;
943
944 assert!(result.success);
945 assert!(result.dry_run);
946 assert_eq!(result.spec_id, "spec-001");
947 assert_eq!(result.merged_to, "main");
948 assert_eq!(result.original_branch, "main");
949
950 let current = get_current_branch()?;
952 assert_eq!(current, "main");
953
954 assert!(branch_exists("spec-001")?);
956
957 std::env::set_current_dir(original_dir)?;
958 Ok(())
959 }
960
961 #[test]
962 #[serial_test::serial]
963 fn test_merge_single_spec_successful_with_delete() -> Result<()> {
964 let temp_dir = setup_test_repo()?;
965 let repo_path = temp_dir.path();
966 let original_dir = std::env::current_dir()?;
967
968 std::env::set_current_dir(repo_path)?;
969
970 Command::new("git")
972 .args(["checkout", "-b", "spec-002"])
973 .output()?;
974
975 let file_path = repo_path.join("spec-file2.txt");
977 fs::write(&file_path, "spec content 2")?;
978 Command::new("git")
979 .args(["add", "spec-file2.txt"])
980 .output()?;
981 Command::new("git")
982 .args(["commit", "-m", "Add spec-file2"])
983 .output()?;
984
985 Command::new("git").args(["checkout", "main"]).output()?;
987
988 let result = merge_single_spec("spec-002", "spec-002", "main", true, false)?;
990
991 assert!(result.success);
992 assert!(!result.dry_run);
993 assert!(result.branch_deleted);
994
995 assert!(!branch_exists("spec-002")?);
997
998 let current = get_current_branch()?;
1000 assert_eq!(current, "main");
1001
1002 std::env::set_current_dir(original_dir)?;
1003 Ok(())
1004 }
1005
1006 #[test]
1007 #[serial_test::serial]
1008 fn test_merge_single_spec_nonexistent_main_branch() -> Result<()> {
1009 let temp_dir = setup_test_repo()?;
1010 let repo_path = temp_dir.path();
1011 let original_dir = std::env::current_dir()?;
1012
1013 std::env::set_current_dir(repo_path)?;
1014
1015 Command::new("git")
1017 .args(["checkout", "-b", "spec-003"])
1018 .output()?;
1019
1020 let file_path = repo_path.join("spec-file3.txt");
1022 fs::write(&file_path, "spec content 3")?;
1023 Command::new("git")
1024 .args(["add", "spec-file3.txt"])
1025 .output()?;
1026 Command::new("git")
1027 .args(["commit", "-m", "Add spec-file3"])
1028 .output()?;
1029
1030 let result = merge_single_spec("spec-003", "spec-003", "nonexistent", false, false);
1032
1033 assert!(result.is_err());
1034 assert!(result.unwrap_err().to_string().contains("does not exist"));
1035
1036 let current = get_current_branch()?;
1038 assert_eq!(current, "spec-003");
1039
1040 std::env::set_current_dir(original_dir)?;
1041 Ok(())
1042 }
1043
1044 #[test]
1045 #[serial_test::serial]
1046 fn test_merge_single_spec_nonexistent_spec_branch() -> Result<()> {
1047 let temp_dir = setup_test_repo()?;
1048 let repo_path = temp_dir.path();
1049 let original_dir = std::env::current_dir()?;
1050
1051 std::env::set_current_dir(repo_path)?;
1052
1053 let result = merge_single_spec("nonexistent", "nonexistent", "main", false, false);
1055
1056 assert!(result.is_err());
1057 assert!(result.unwrap_err().to_string().contains("not found"));
1058
1059 let current = get_current_branch()?;
1061 assert_eq!(current, "main");
1062
1063 std::env::set_current_dir(original_dir)?;
1064 Ok(())
1065 }
1066
1067 #[test]
1068 fn test_format_merge_summary_success() {
1069 let result = MergeResult {
1070 spec_id: "spec-001".to_string(),
1071 success: true,
1072 original_branch: "main".to_string(),
1073 merged_to: "main".to_string(),
1074 branch_deleted: false,
1075 branch_delete_warning: None,
1076 dry_run: false,
1077 };
1078
1079 let summary = format_merge_summary(&result);
1080 assert!(summary.contains("✓"));
1081 assert!(summary.contains("spec-001"));
1082 assert!(summary.contains("Returned to branch: main"));
1083 }
1084
1085 #[test]
1086 fn test_format_merge_summary_with_delete() {
1087 let result = MergeResult {
1088 spec_id: "spec-002".to_string(),
1089 success: true,
1090 original_branch: "main".to_string(),
1091 merged_to: "main".to_string(),
1092 branch_deleted: true,
1093 branch_delete_warning: None,
1094 dry_run: false,
1095 };
1096
1097 let summary = format_merge_summary(&result);
1098 assert!(summary.contains("✓"));
1099 assert!(summary.contains("deleted branch spec-002"));
1100 }
1101
1102 #[test]
1103 fn test_format_merge_summary_dry_run() {
1104 let result = MergeResult {
1105 spec_id: "spec-003".to_string(),
1106 success: true,
1107 original_branch: "main".to_string(),
1108 merged_to: "main".to_string(),
1109 branch_deleted: false,
1110 branch_delete_warning: None,
1111 dry_run: true,
1112 };
1113
1114 let summary = format_merge_summary(&result);
1115 assert!(summary.contains("[DRY RUN]"));
1116 }
1117
1118 #[test]
1119 fn test_format_merge_summary_with_warning() {
1120 let result = MergeResult {
1121 spec_id: "spec-004".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: Some("Warning: Could not delete branch".to_string()),
1127 dry_run: false,
1128 };
1129
1130 let summary = format_merge_summary(&result);
1131 assert!(summary.contains("Warning"));
1132 }
1133
1134 #[test]
1135 fn test_format_merge_summary_failure() {
1136 let result = MergeResult {
1137 spec_id: "spec-005".to_string(),
1138 success: false,
1139 original_branch: "main".to_string(),
1140 merged_to: "main".to_string(),
1141 branch_deleted: false,
1142 branch_delete_warning: None,
1143 dry_run: false,
1144 };
1145
1146 let summary = format_merge_summary(&result);
1147 assert!(summary.contains("✗"));
1148 assert!(summary.contains("Failed to merge"));
1149 }
1150
1151 #[test]
1152 #[serial_test::serial]
1153 fn test_branches_have_diverged_no_divergence() -> Result<()> {
1154 let temp_dir = setup_test_repo()?;
1155 let repo_path = temp_dir.path();
1156 let original_dir = std::env::current_dir()?;
1157
1158 std::env::set_current_dir(repo_path)?;
1159
1160 Command::new("git")
1162 .args(["checkout", "-b", "spec-no-diverge"])
1163 .output()?;
1164
1165 let file_path = repo_path.join("diverge-test.txt");
1167 fs::write(&file_path, "spec content")?;
1168 Command::new("git")
1169 .args(["add", "diverge-test.txt"])
1170 .output()?;
1171 Command::new("git")
1172 .args(["commit", "-m", "Add diverge-test"])
1173 .output()?;
1174
1175 Command::new("git").args(["checkout", "main"]).output()?;
1177
1178 let diverged = branches_have_diverged("spec-no-diverge")?;
1180 assert!(!diverged, "Fast-forward merge should be possible");
1181
1182 std::env::set_current_dir(original_dir)?;
1183 Ok(())
1184 }
1185
1186 #[test]
1187 #[serial_test::serial]
1188 fn test_branches_have_diverged_with_divergence() -> Result<()> {
1189 let temp_dir = setup_test_repo()?;
1190 let repo_path = temp_dir.path();
1191 let original_dir = std::env::current_dir()?;
1192
1193 std::env::set_current_dir(repo_path)?;
1194
1195 Command::new("git")
1197 .args(["checkout", "-b", "spec-diverge"])
1198 .output()?;
1199
1200 let file_path = repo_path.join("spec-file.txt");
1202 fs::write(&file_path, "spec content")?;
1203 Command::new("git")
1204 .args(["add", "spec-file.txt"])
1205 .output()?;
1206 Command::new("git")
1207 .args(["commit", "-m", "Add spec-file"])
1208 .output()?;
1209
1210 Command::new("git").args(["checkout", "main"]).output()?;
1212 let main_file = repo_path.join("main-file.txt");
1213 fs::write(&main_file, "main content")?;
1214 Command::new("git")
1215 .args(["add", "main-file.txt"])
1216 .output()?;
1217 Command::new("git")
1218 .args(["commit", "-m", "Add main-file"])
1219 .output()?;
1220
1221 let diverged = branches_have_diverged("spec-diverge")?;
1223 assert!(diverged, "Branches should have diverged");
1224
1225 std::env::set_current_dir(original_dir)?;
1226 Ok(())
1227 }
1228
1229 #[test]
1230 #[serial_test::serial]
1231 fn test_merge_single_spec_with_diverged_branches() -> Result<()> {
1232 let temp_dir = setup_test_repo()?;
1233 let repo_path = temp_dir.path();
1234 let original_dir = std::env::current_dir()?;
1235
1236 std::env::set_current_dir(repo_path)?;
1237
1238 Command::new("git")
1240 .args(["checkout", "-b", "spec-diverged"])
1241 .output()?;
1242
1243 let file_path = repo_path.join("spec-change.txt");
1245 fs::write(&file_path, "spec content")?;
1246 Command::new("git")
1247 .args(["add", "spec-change.txt"])
1248 .output()?;
1249 Command::new("git")
1250 .args(["commit", "-m", "Add spec-change"])
1251 .output()?;
1252
1253 Command::new("git").args(["checkout", "main"]).output()?;
1255 let main_file = repo_path.join("main-change.txt");
1256 fs::write(&main_file, "main content")?;
1257 Command::new("git")
1258 .args(["add", "main-change.txt"])
1259 .output()?;
1260 Command::new("git")
1261 .args(["commit", "-m", "Add main-change"])
1262 .output()?;
1263
1264 let result = merge_single_spec("spec-diverged", "spec-diverged", "main", false, false)?;
1266
1267 assert!(result.success, "Merge should succeed with --no-ff");
1268 assert_eq!(result.spec_id, "spec-diverged");
1269 assert_eq!(result.merged_to, "main");
1270
1271 let current = get_current_branch()?;
1273 assert_eq!(current, "main");
1274
1275 std::env::set_current_dir(original_dir)?;
1276 Ok(())
1277 }
1278
1279 #[test]
1280 #[serial_test::serial]
1281 fn test_ensure_on_main_branch() -> Result<()> {
1282 let temp_dir = setup_test_repo()?;
1283 let repo_path = temp_dir.path();
1284 let original_dir = std::env::current_dir()?;
1285
1286 std::env::set_current_dir(repo_path)?;
1287
1288 Command::new("git")
1290 .args(["checkout", "-b", "spec-test"])
1291 .output()?;
1292
1293 let current = get_current_branch()?;
1295 assert_eq!(current, "spec-test");
1296
1297 ensure_on_main_branch("main")?;
1299
1300 let current = get_current_branch()?;
1302 assert_eq!(current, "main");
1303
1304 std::env::set_current_dir(original_dir)?;
1305 Ok(())
1306 }
1307
1308 #[test]
1309 #[serial_test::serial]
1310 fn test_ensure_on_main_branch_already_on_main() -> Result<()> {
1311 let temp_dir = setup_test_repo()?;
1312 let repo_path = temp_dir.path();
1313 let original_dir = std::env::current_dir()?;
1314
1315 std::env::set_current_dir(repo_path)?;
1316
1317 let current = get_current_branch()?;
1319 assert_eq!(current, "main");
1320
1321 ensure_on_main_branch("main")?;
1323
1324 let current = get_current_branch()?;
1326 assert_eq!(current, "main");
1327
1328 std::env::set_current_dir(original_dir)?;
1329 Ok(())
1330 }
1331
1332 #[test]
1333 #[serial_test::serial]
1334 fn test_get_commits_in_range() -> Result<()> {
1335 let temp_dir = setup_test_repo()?;
1336 let repo_path = temp_dir.path();
1337 let original_dir = std::env::current_dir()?;
1338
1339 std::env::set_current_dir(repo_path)?;
1340
1341 for i in 1..=5 {
1343 let file_path = repo_path.join(format!("test{}.txt", i));
1344 fs::write(&file_path, format!("content {}", i))?;
1345 Command::new("git").args(["add", "."]).output()?;
1346 Command::new("git")
1347 .args(["commit", "-m", &format!("Commit {}", i)])
1348 .output()?;
1349 }
1350
1351 let commits = get_commits_in_range("HEAD~5", "HEAD")?;
1353
1354 assert_eq!(commits.len(), 5);
1355 assert_eq!(commits[0].message, "Commit 1");
1356 assert_eq!(commits[4].message, "Commit 5");
1357
1358 std::env::set_current_dir(original_dir)?;
1359 Ok(())
1360 }
1361
1362 #[test]
1363 #[serial_test::serial]
1364 fn test_get_commits_in_range_invalid_refs() -> Result<()> {
1365 let temp_dir = setup_test_repo()?;
1366 let repo_path = temp_dir.path();
1367 let original_dir = std::env::current_dir()?;
1368
1369 std::env::set_current_dir(repo_path)?;
1370
1371 let result = get_commits_in_range("invalid", "HEAD");
1372 assert!(result.is_err());
1373
1374 std::env::set_current_dir(original_dir)?;
1375 Ok(())
1376 }
1377
1378 #[test]
1379 #[serial_test::serial]
1380 fn test_get_commits_in_range_empty() -> Result<()> {
1381 let temp_dir = setup_test_repo()?;
1382 let repo_path = temp_dir.path();
1383 let original_dir = std::env::current_dir()?;
1384
1385 std::env::set_current_dir(repo_path)?;
1386
1387 let commits = get_commits_in_range("HEAD", "HEAD")?;
1389 assert_eq!(commits.len(), 0);
1390
1391 std::env::set_current_dir(original_dir)?;
1392 Ok(())
1393 }
1394
1395 #[test]
1396 #[serial_test::serial]
1397 fn test_get_commit_changed_files() -> Result<()> {
1398 let temp_dir = setup_test_repo()?;
1399 let repo_path = temp_dir.path();
1400 let original_dir = std::env::current_dir()?;
1401
1402 std::env::set_current_dir(repo_path)?;
1403
1404 let file1 = repo_path.join("file1.txt");
1406 let file2 = repo_path.join("file2.txt");
1407 fs::write(&file1, "content1")?;
1408 fs::write(&file2, "content2")?;
1409 Command::new("git").args(["add", "."]).output()?;
1410 Command::new("git")
1411 .args(["commit", "-m", "Add files"])
1412 .output()?;
1413
1414 let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1415 let hash = String::from_utf8_lossy(&hash_output.stdout)
1416 .trim()
1417 .to_string();
1418
1419 let files = get_commit_changed_files(&hash)?;
1420 assert_eq!(files.len(), 2);
1421 assert!(files.contains(&"file1.txt".to_string()));
1422 assert!(files.contains(&"file2.txt".to_string()));
1423
1424 std::env::set_current_dir(original_dir)?;
1425 Ok(())
1426 }
1427
1428 #[test]
1429 #[serial_test::serial]
1430 fn test_get_commit_changed_files_invalid_hash() -> Result<()> {
1431 let temp_dir = setup_test_repo()?;
1432 let repo_path = temp_dir.path();
1433 let original_dir = std::env::current_dir()?;
1434
1435 std::env::set_current_dir(repo_path)?;
1436
1437 let result = get_commit_changed_files("invalid_hash");
1438 assert!(result.is_err());
1439
1440 std::env::set_current_dir(original_dir)?;
1441 Ok(())
1442 }
1443
1444 #[test]
1445 #[serial_test::serial]
1446 fn test_get_commit_changed_files_empty() -> Result<()> {
1447 let temp_dir = setup_test_repo()?;
1448 let repo_path = temp_dir.path();
1449 let original_dir = std::env::current_dir()?;
1450
1451 std::env::set_current_dir(repo_path)?;
1452
1453 Command::new("git")
1455 .args(["commit", "--allow-empty", "-m", "Empty commit"])
1456 .output()?;
1457
1458 let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1459 let hash = String::from_utf8_lossy(&hash_output.stdout)
1460 .trim()
1461 .to_string();
1462
1463 let files = get_commit_changed_files(&hash)?;
1464 assert_eq!(files.len(), 0);
1465
1466 std::env::set_current_dir(original_dir)?;
1467 Ok(())
1468 }
1469
1470 #[test]
1471 #[serial_test::serial]
1472 fn test_get_recent_commits() -> Result<()> {
1473 let temp_dir = setup_test_repo()?;
1474 let repo_path = temp_dir.path();
1475 let original_dir = std::env::current_dir()?;
1476
1477 std::env::set_current_dir(repo_path)?;
1478
1479 for i in 1..=5 {
1481 let file_path = repo_path.join(format!("test{}.txt", i));
1482 fs::write(&file_path, format!("content {}", i))?;
1483 Command::new("git").args(["add", "."]).output()?;
1484 Command::new("git")
1485 .args(["commit", "-m", &format!("Recent {}", i)])
1486 .output()?;
1487 }
1488
1489 let commits = get_recent_commits(3)?;
1491 assert_eq!(commits.len(), 3);
1492 assert_eq!(commits[0].message, "Recent 5");
1493 assert_eq!(commits[1].message, "Recent 4");
1494 assert_eq!(commits[2].message, "Recent 3");
1495
1496 std::env::set_current_dir(original_dir)?;
1497 Ok(())
1498 }
1499}