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 if merge_success && !dry_run {
521 use crate::worktree::git_ops::{get_active_worktree, remove_worktree};
522
523 if let Ok(config) = crate::config::Config::load() {
525 let project_name = Some(config.project.name.as_str());
526 if let Some(worktree_path) = get_active_worktree(spec_id, project_name) {
527 if let Err(e) = remove_worktree(&worktree_path) {
528 eprintln!(
530 "Warning: Failed to clean up worktree at {:?}: {}",
531 worktree_path, e
532 );
533 }
534 }
535 }
536 }
537
538 let should_checkout_original = original_branch != main_branch
542 && !(branch_actually_deleted && original_branch == spec_branch);
543
544 if should_checkout_original {
545 if let Err(e) = checkout_branch(&original_branch, false) {
546 eprintln!(
549 "Warning: Could not return to original branch '{}': {}. Staying on {}.",
550 original_branch, e, main_branch
551 );
552 }
553 }
554
555 Ok(MergeResult {
556 spec_id: spec_id.to_string(),
557 success: merge_success,
558 original_branch,
559 merged_to: main_branch.to_string(),
560 branch_deleted: should_delete_branch && merge_success,
561 branch_delete_warning,
562 dry_run,
563 })
564}
565
566#[derive(Debug, Clone)]
568pub struct MergeResult {
569 pub spec_id: String,
570 pub success: bool,
571 pub original_branch: String,
572 pub merged_to: String,
573 pub branch_deleted: bool,
574 pub branch_delete_warning: Option<String>,
575 pub dry_run: bool,
576}
577
578pub fn format_merge_summary(result: &MergeResult) -> String {
580 let mut output = String::new();
581
582 if result.dry_run {
583 output.push_str("[DRY RUN] ");
584 }
585
586 if result.success {
587 output.push_str(&format!(
588 "✓ Successfully merged {} to {}",
589 result.spec_id, result.merged_to
590 ));
591 if result.branch_deleted {
592 output.push_str(&format!(" and deleted branch {}", result.spec_id));
593 }
594 } else {
595 output.push_str(&format!(
596 "✗ Failed to merge {} to {}",
597 result.spec_id, result.merged_to
598 ));
599 }
600
601 if let Some(warning) = &result.branch_delete_warning {
602 output.push_str(&format!("\n {}", warning));
603 }
604
605 output.push_str(&format!("\nReturned to branch: {}", result.original_branch));
606
607 output
608}
609
610pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
613 let output = Command::new("git")
615 .args(["merge-base", target, branch])
616 .output()
617 .context("Failed to find merge base")?;
618
619 if !output.status.success() {
620 return Ok(false);
621 }
622
623 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
624
625 let output = Command::new("git")
627 .args(["rev-parse", target])
628 .output()
629 .context("Failed to get target commit")?;
630
631 if !output.status.success() {
632 return Ok(false);
633 }
634
635 let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
636
637 Ok(merge_base == target_commit)
639}
640
641pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
644 let output = Command::new("git")
646 .args(["merge-base", branch, target])
647 .output()
648 .context("Failed to find merge base")?;
649
650 if !output.status.success() {
651 return Ok(false);
652 }
653
654 let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
655
656 let output = Command::new("git")
658 .args(["rev-parse", branch])
659 .output()
660 .context("Failed to get branch commit")?;
661
662 if !output.status.success() {
663 return Ok(false);
664 }
665
666 let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
667
668 Ok(merge_base == branch_commit)
670}
671
672pub fn count_commits(branch: &str) -> Result<usize> {
674 let output = Command::new("git")
675 .args(["rev-list", "--count", branch])
676 .output()
677 .context("Failed to count commits")?;
678
679 if !output.status.success() {
680 return Ok(0);
681 }
682
683 let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
684 Ok(count_str.parse().unwrap_or(0))
685}
686
687#[derive(Debug, Clone)]
689pub struct CommitInfo {
690 pub hash: String,
691 pub message: String,
692 pub author: String,
693 pub timestamp: i64,
694}
695
696pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
704 let range = format!("{}..{}", from_ref, to_ref);
705
706 let output = Command::new("git")
707 .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
708 .output()
709 .context("Failed to execute git log")?;
710
711 if !output.status.success() {
712 let stderr = String::from_utf8_lossy(&output.stderr);
713 anyhow::bail!("Invalid git refs {}: {}", range, stderr);
714 }
715
716 let stdout = String::from_utf8_lossy(&output.stdout);
717 let mut commits = Vec::new();
718
719 for line in stdout.lines() {
720 if line.is_empty() {
721 continue;
722 }
723
724 let parts: Vec<&str> = line.splitn(4, '|').collect();
725 if parts.len() != 4 {
726 continue;
727 }
728
729 commits.push(CommitInfo {
730 hash: parts[0].to_string(),
731 author: parts[1].to_string(),
732 timestamp: parts[2].parse().unwrap_or(0),
733 message: parts[3].to_string(),
734 });
735 }
736
737 Ok(commits)
738}
739
740pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
747 let output = Command::new("git")
748 .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
749 .output()
750 .context("Failed to execute git diff-tree")?;
751
752 if !output.status.success() {
753 let stderr = String::from_utf8_lossy(&output.stderr);
754 anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
755 }
756
757 let stdout = String::from_utf8_lossy(&output.stdout);
758 let files: Vec<String> = stdout
759 .lines()
760 .filter(|line| !line.is_empty())
761 .map(|line| line.to_string())
762 .collect();
763
764 Ok(files)
765}
766
767pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
774 let output = Command::new("git")
775 .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
776 .output()
777 .context("Failed to execute git diff-tree")?;
778
779 if !output.status.success() {
780 return Ok(Vec::new());
781 }
782
783 let stdout = String::from_utf8_lossy(&output.stdout);
784 let mut files = Vec::new();
785
786 for line in stdout.lines() {
787 let parts: Vec<&str> = line.split('\t').collect();
788 if parts.len() >= 2 {
789 files.push(format!("{}:{}", parts[0], parts[1]));
791 }
792 }
793
794 Ok(files)
795}
796
797pub fn get_file_at_commit(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 commit")?;
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_file_at_parent(commit: &str, file: &str) -> Result<String> {
823 let output = Command::new("git")
824 .args(["show", &format!("{}^:{}", commit, file)])
825 .output()
826 .context("Failed to get file at parent")?;
827
828 if !output.status.success() {
829 return Ok(String::new());
830 }
831
832 Ok(String::from_utf8_lossy(&output.stdout).to_string())
833}
834
835pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
840 let count_str = count.to_string();
841
842 let output = Command::new("git")
843 .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
844 .output()
845 .context("Failed to execute git log")?;
846
847 if !output.status.success() {
848 let stderr = String::from_utf8_lossy(&output.stderr);
849 anyhow::bail!("Failed to get recent commits: {}", stderr);
850 }
851
852 let stdout = String::from_utf8_lossy(&output.stdout);
853 let mut commits = Vec::new();
854
855 for line in stdout.lines() {
856 if line.is_empty() {
857 continue;
858 }
859
860 let parts: Vec<&str> = line.splitn(4, '|').collect();
861 if parts.len() != 4 {
862 continue;
863 }
864
865 commits.push(CommitInfo {
866 hash: parts[0].to_string(),
867 author: parts[1].to_string(),
868 timestamp: parts[2].parse().unwrap_or(0),
869 message: parts[3].to_string(),
870 });
871 }
872
873 Ok(commits)
874}
875
876pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
884 let output = Command::new("git")
885 .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
886 .output()
887 .context("Failed to execute git log")?;
888
889 if !output.status.success() {
890 let stderr = String::from_utf8_lossy(&output.stderr);
891 anyhow::bail!("git log failed: {}", stderr);
892 }
893
894 let stdout = String::from_utf8_lossy(&output.stdout);
895 let mut commits = Vec::new();
896
897 for line in stdout.lines() {
898 if line.is_empty() {
899 continue;
900 }
901
902 let parts: Vec<&str> = line.splitn(4, '|').collect();
903 if parts.len() != 4 {
904 continue;
905 }
906
907 commits.push(CommitInfo {
908 hash: parts[0].to_string(),
909 author: parts[1].to_string(),
910 timestamp: parts[2].parse().unwrap_or(0),
911 message: parts[3].to_string(),
912 });
913 }
914
915 Ok(commits)
916}
917
918#[cfg(test)]
919mod tests {
920 use super::*;
921 use std::fs;
922 use tempfile::TempDir;
923
924 #[test]
925 fn test_get_current_branch_returns_string() {
926 let result = get_current_branch();
928 if let Ok(branch) = result {
930 assert!(!branch.is_empty());
932 }
933 }
934
935 fn setup_test_repo() -> Result<TempDir> {
937 let temp_dir = TempDir::new()?;
938 let repo_path = temp_dir.path();
939
940 Command::new("git")
942 .arg("init")
943 .current_dir(repo_path)
944 .output()?;
945
946 Command::new("git")
948 .args(["config", "user.email", "test@example.com"])
949 .current_dir(repo_path)
950 .output()?;
951
952 Command::new("git")
953 .args(["config", "user.name", "Test User"])
954 .current_dir(repo_path)
955 .output()?;
956
957 let file_path = repo_path.join("test.txt");
959 fs::write(&file_path, "test content")?;
960 Command::new("git")
961 .args(["add", "test.txt"])
962 .current_dir(repo_path)
963 .output()?;
964
965 Command::new("git")
966 .args(["commit", "-m", "Initial commit"])
967 .current_dir(repo_path)
968 .output()?;
969
970 Command::new("git")
972 .args(["branch", "main"])
973 .current_dir(repo_path)
974 .output()?;
975
976 Command::new("git")
977 .args(["checkout", "main"])
978 .current_dir(repo_path)
979 .output()?;
980
981 Ok(temp_dir)
982 }
983
984 #[test]
985 #[serial_test::serial]
986 fn test_merge_single_spec_successful_dry_run() -> Result<()> {
987 let temp_dir = setup_test_repo()?;
988 let repo_path = temp_dir.path();
989 let original_dir = std::env::current_dir()?;
990
991 std::env::set_current_dir(repo_path)?;
992
993 Command::new("git")
995 .args(["checkout", "-b", "spec-001"])
996 .output()?;
997
998 let file_path = repo_path.join("spec-file.txt");
1000 fs::write(&file_path, "spec content")?;
1001 Command::new("git")
1002 .args(["add", "spec-file.txt"])
1003 .output()?;
1004 Command::new("git")
1005 .args(["commit", "-m", "Add spec-file"])
1006 .output()?;
1007
1008 Command::new("git").args(["checkout", "main"]).output()?;
1010
1011 let result = merge_single_spec("spec-001", "spec-001", "main", false, true)?;
1013
1014 assert!(result.success);
1015 assert!(result.dry_run);
1016 assert_eq!(result.spec_id, "spec-001");
1017 assert_eq!(result.merged_to, "main");
1018 assert_eq!(result.original_branch, "main");
1019
1020 let current = get_current_branch()?;
1022 assert_eq!(current, "main");
1023
1024 assert!(branch_exists("spec-001")?);
1026
1027 std::env::set_current_dir(original_dir)?;
1028 Ok(())
1029 }
1030
1031 #[test]
1032 #[serial_test::serial]
1033 fn test_merge_single_spec_successful_with_delete() -> Result<()> {
1034 let temp_dir = setup_test_repo()?;
1035 let repo_path = temp_dir.path();
1036 let original_dir = std::env::current_dir()?;
1037
1038 std::env::set_current_dir(repo_path)?;
1039
1040 Command::new("git")
1042 .args(["checkout", "-b", "spec-002"])
1043 .output()?;
1044
1045 let file_path = repo_path.join("spec-file2.txt");
1047 fs::write(&file_path, "spec content 2")?;
1048 Command::new("git")
1049 .args(["add", "spec-file2.txt"])
1050 .output()?;
1051 Command::new("git")
1052 .args(["commit", "-m", "Add spec-file2"])
1053 .output()?;
1054
1055 Command::new("git").args(["checkout", "main"]).output()?;
1057
1058 let result = merge_single_spec("spec-002", "spec-002", "main", true, false)?;
1060
1061 assert!(result.success);
1062 assert!(!result.dry_run);
1063 assert!(result.branch_deleted);
1064
1065 assert!(!branch_exists("spec-002")?);
1067
1068 let current = get_current_branch()?;
1070 assert_eq!(current, "main");
1071
1072 std::env::set_current_dir(original_dir)?;
1073 Ok(())
1074 }
1075
1076 #[test]
1077 #[serial_test::serial]
1078 fn test_merge_single_spec_nonexistent_main_branch() -> Result<()> {
1079 let temp_dir = setup_test_repo()?;
1080 let repo_path = temp_dir.path();
1081 let original_dir = std::env::current_dir()?;
1082
1083 std::env::set_current_dir(repo_path)?;
1084
1085 Command::new("git")
1087 .args(["checkout", "-b", "spec-003"])
1088 .output()?;
1089
1090 let file_path = repo_path.join("spec-file3.txt");
1092 fs::write(&file_path, "spec content 3")?;
1093 Command::new("git")
1094 .args(["add", "spec-file3.txt"])
1095 .output()?;
1096 Command::new("git")
1097 .args(["commit", "-m", "Add spec-file3"])
1098 .output()?;
1099
1100 let result = merge_single_spec("spec-003", "spec-003", "nonexistent", false, false);
1102
1103 assert!(result.is_err());
1104 assert!(result.unwrap_err().to_string().contains("does not exist"));
1105
1106 let current = get_current_branch()?;
1108 assert_eq!(current, "spec-003");
1109
1110 std::env::set_current_dir(original_dir)?;
1111 Ok(())
1112 }
1113
1114 #[test]
1115 #[serial_test::serial]
1116 fn test_merge_single_spec_nonexistent_spec_branch() -> Result<()> {
1117 let temp_dir = setup_test_repo()?;
1118 let repo_path = temp_dir.path();
1119 let original_dir = std::env::current_dir()?;
1120
1121 std::env::set_current_dir(repo_path)?;
1122
1123 let result = merge_single_spec("nonexistent", "nonexistent", "main", false, false);
1125
1126 assert!(result.is_err());
1127 assert!(result.unwrap_err().to_string().contains("not found"));
1128
1129 let current = get_current_branch()?;
1131 assert_eq!(current, "main");
1132
1133 std::env::set_current_dir(original_dir)?;
1134 Ok(())
1135 }
1136
1137 #[test]
1138 fn test_format_merge_summary_success() {
1139 let result = MergeResult {
1140 spec_id: "spec-001".to_string(),
1141 success: true,
1142 original_branch: "main".to_string(),
1143 merged_to: "main".to_string(),
1144 branch_deleted: false,
1145 branch_delete_warning: None,
1146 dry_run: false,
1147 };
1148
1149 let summary = format_merge_summary(&result);
1150 assert!(summary.contains("✓"));
1151 assert!(summary.contains("spec-001"));
1152 assert!(summary.contains("Returned to branch: main"));
1153 }
1154
1155 #[test]
1156 fn test_format_merge_summary_with_delete() {
1157 let result = MergeResult {
1158 spec_id: "spec-002".to_string(),
1159 success: true,
1160 original_branch: "main".to_string(),
1161 merged_to: "main".to_string(),
1162 branch_deleted: true,
1163 branch_delete_warning: None,
1164 dry_run: false,
1165 };
1166
1167 let summary = format_merge_summary(&result);
1168 assert!(summary.contains("✓"));
1169 assert!(summary.contains("deleted branch spec-002"));
1170 }
1171
1172 #[test]
1173 fn test_format_merge_summary_dry_run() {
1174 let result = MergeResult {
1175 spec_id: "spec-003".to_string(),
1176 success: true,
1177 original_branch: "main".to_string(),
1178 merged_to: "main".to_string(),
1179 branch_deleted: false,
1180 branch_delete_warning: None,
1181 dry_run: true,
1182 };
1183
1184 let summary = format_merge_summary(&result);
1185 assert!(summary.contains("[DRY RUN]"));
1186 }
1187
1188 #[test]
1189 fn test_format_merge_summary_with_warning() {
1190 let result = MergeResult {
1191 spec_id: "spec-004".to_string(),
1192 success: true,
1193 original_branch: "main".to_string(),
1194 merged_to: "main".to_string(),
1195 branch_deleted: false,
1196 branch_delete_warning: Some("Warning: Could not delete branch".to_string()),
1197 dry_run: false,
1198 };
1199
1200 let summary = format_merge_summary(&result);
1201 assert!(summary.contains("Warning"));
1202 }
1203
1204 #[test]
1205 fn test_format_merge_summary_failure() {
1206 let result = MergeResult {
1207 spec_id: "spec-005".to_string(),
1208 success: false,
1209 original_branch: "main".to_string(),
1210 merged_to: "main".to_string(),
1211 branch_deleted: false,
1212 branch_delete_warning: None,
1213 dry_run: false,
1214 };
1215
1216 let summary = format_merge_summary(&result);
1217 assert!(summary.contains("✗"));
1218 assert!(summary.contains("Failed to merge"));
1219 }
1220
1221 #[test]
1222 #[serial_test::serial]
1223 fn test_branches_have_diverged_no_divergence() -> Result<()> {
1224 let temp_dir = setup_test_repo()?;
1225 let repo_path = temp_dir.path();
1226 let original_dir = std::env::current_dir()?;
1227
1228 std::env::set_current_dir(repo_path)?;
1229
1230 Command::new("git")
1232 .args(["checkout", "-b", "spec-no-diverge"])
1233 .output()?;
1234
1235 let file_path = repo_path.join("diverge-test.txt");
1237 fs::write(&file_path, "spec content")?;
1238 Command::new("git")
1239 .args(["add", "diverge-test.txt"])
1240 .output()?;
1241 Command::new("git")
1242 .args(["commit", "-m", "Add diverge-test"])
1243 .output()?;
1244
1245 Command::new("git").args(["checkout", "main"]).output()?;
1247
1248 let diverged = branches_have_diverged("spec-no-diverge")?;
1250 assert!(!diverged, "Fast-forward merge should be possible");
1251
1252 std::env::set_current_dir(original_dir)?;
1253 Ok(())
1254 }
1255
1256 #[test]
1257 #[serial_test::serial]
1258 fn test_branches_have_diverged_with_divergence() -> Result<()> {
1259 let temp_dir = setup_test_repo()?;
1260 let repo_path = temp_dir.path();
1261 let original_dir = std::env::current_dir()?;
1262
1263 std::env::set_current_dir(repo_path)?;
1264
1265 Command::new("git")
1267 .args(["checkout", "-b", "spec-diverge"])
1268 .output()?;
1269
1270 let file_path = repo_path.join("spec-file.txt");
1272 fs::write(&file_path, "spec content")?;
1273 Command::new("git")
1274 .args(["add", "spec-file.txt"])
1275 .output()?;
1276 Command::new("git")
1277 .args(["commit", "-m", "Add spec-file"])
1278 .output()?;
1279
1280 Command::new("git").args(["checkout", "main"]).output()?;
1282 let main_file = repo_path.join("main-file.txt");
1283 fs::write(&main_file, "main content")?;
1284 Command::new("git")
1285 .args(["add", "main-file.txt"])
1286 .output()?;
1287 Command::new("git")
1288 .args(["commit", "-m", "Add main-file"])
1289 .output()?;
1290
1291 let diverged = branches_have_diverged("spec-diverge")?;
1293 assert!(diverged, "Branches should have diverged");
1294
1295 std::env::set_current_dir(original_dir)?;
1296 Ok(())
1297 }
1298
1299 #[test]
1300 #[serial_test::serial]
1301 fn test_merge_single_spec_with_diverged_branches() -> Result<()> {
1302 let temp_dir = setup_test_repo()?;
1303 let repo_path = temp_dir.path();
1304 let original_dir = std::env::current_dir()?;
1305
1306 std::env::set_current_dir(repo_path)?;
1307
1308 Command::new("git")
1310 .args(["checkout", "-b", "spec-diverged"])
1311 .output()?;
1312
1313 let file_path = repo_path.join("spec-change.txt");
1315 fs::write(&file_path, "spec content")?;
1316 Command::new("git")
1317 .args(["add", "spec-change.txt"])
1318 .output()?;
1319 Command::new("git")
1320 .args(["commit", "-m", "Add spec-change"])
1321 .output()?;
1322
1323 Command::new("git").args(["checkout", "main"]).output()?;
1325 let main_file = repo_path.join("main-change.txt");
1326 fs::write(&main_file, "main content")?;
1327 Command::new("git")
1328 .args(["add", "main-change.txt"])
1329 .output()?;
1330 Command::new("git")
1331 .args(["commit", "-m", "Add main-change"])
1332 .output()?;
1333
1334 let result = merge_single_spec("spec-diverged", "spec-diverged", "main", false, false)?;
1336
1337 assert!(result.success, "Merge should succeed with --no-ff");
1338 assert_eq!(result.spec_id, "spec-diverged");
1339 assert_eq!(result.merged_to, "main");
1340
1341 let current = get_current_branch()?;
1343 assert_eq!(current, "main");
1344
1345 std::env::set_current_dir(original_dir)?;
1346 Ok(())
1347 }
1348
1349 #[test]
1350 #[serial_test::serial]
1351 fn test_ensure_on_main_branch() -> Result<()> {
1352 let temp_dir = setup_test_repo()?;
1353 let repo_path = temp_dir.path();
1354 let original_dir = std::env::current_dir()?;
1355
1356 std::env::set_current_dir(repo_path)?;
1357
1358 Command::new("git")
1360 .args(["checkout", "-b", "spec-test"])
1361 .output()?;
1362
1363 let current = get_current_branch()?;
1365 assert_eq!(current, "spec-test");
1366
1367 ensure_on_main_branch("main")?;
1369
1370 let current = get_current_branch()?;
1372 assert_eq!(current, "main");
1373
1374 std::env::set_current_dir(original_dir)?;
1375 Ok(())
1376 }
1377
1378 #[test]
1379 #[serial_test::serial]
1380 fn test_ensure_on_main_branch_already_on_main() -> 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 current = get_current_branch()?;
1389 assert_eq!(current, "main");
1390
1391 ensure_on_main_branch("main")?;
1393
1394 let current = get_current_branch()?;
1396 assert_eq!(current, "main");
1397
1398 std::env::set_current_dir(original_dir)?;
1399 Ok(())
1400 }
1401
1402 #[test]
1403 #[serial_test::serial]
1404 fn test_get_commits_in_range() -> Result<()> {
1405 let temp_dir = setup_test_repo()?;
1406 let repo_path = temp_dir.path();
1407 let original_dir = std::env::current_dir()?;
1408
1409 std::env::set_current_dir(repo_path)?;
1410
1411 for i in 1..=5 {
1413 let file_path = repo_path.join(format!("test{}.txt", i));
1414 fs::write(&file_path, format!("content {}", i))?;
1415 Command::new("git").args(["add", "."]).output()?;
1416 Command::new("git")
1417 .args(["commit", "-m", &format!("Commit {}", i)])
1418 .output()?;
1419 }
1420
1421 let commits = get_commits_in_range("HEAD~5", "HEAD")?;
1423
1424 assert_eq!(commits.len(), 5);
1425 assert_eq!(commits[0].message, "Commit 1");
1426 assert_eq!(commits[4].message, "Commit 5");
1427
1428 std::env::set_current_dir(original_dir)?;
1429 Ok(())
1430 }
1431
1432 #[test]
1433 #[serial_test::serial]
1434 fn test_get_commits_in_range_invalid_refs() -> Result<()> {
1435 let temp_dir = setup_test_repo()?;
1436 let repo_path = temp_dir.path();
1437 let original_dir = std::env::current_dir()?;
1438
1439 std::env::set_current_dir(repo_path)?;
1440
1441 let result = get_commits_in_range("invalid", "HEAD");
1442 assert!(result.is_err());
1443
1444 std::env::set_current_dir(original_dir)?;
1445 Ok(())
1446 }
1447
1448 #[test]
1449 #[serial_test::serial]
1450 fn test_get_commits_in_range_empty() -> Result<()> {
1451 let temp_dir = setup_test_repo()?;
1452 let repo_path = temp_dir.path();
1453 let original_dir = std::env::current_dir()?;
1454
1455 std::env::set_current_dir(repo_path)?;
1456
1457 let commits = get_commits_in_range("HEAD", "HEAD")?;
1459 assert_eq!(commits.len(), 0);
1460
1461 std::env::set_current_dir(original_dir)?;
1462 Ok(())
1463 }
1464
1465 #[test]
1466 #[serial_test::serial]
1467 fn test_get_commit_changed_files() -> Result<()> {
1468 let temp_dir = setup_test_repo()?;
1469 let repo_path = temp_dir.path();
1470 let original_dir = std::env::current_dir()?;
1471
1472 std::env::set_current_dir(repo_path)?;
1473
1474 let file1 = repo_path.join("file1.txt");
1476 let file2 = repo_path.join("file2.txt");
1477 fs::write(&file1, "content1")?;
1478 fs::write(&file2, "content2")?;
1479 Command::new("git").args(["add", "."]).output()?;
1480 Command::new("git")
1481 .args(["commit", "-m", "Add files"])
1482 .output()?;
1483
1484 let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1485 let hash = String::from_utf8_lossy(&hash_output.stdout)
1486 .trim()
1487 .to_string();
1488
1489 let files = get_commit_changed_files(&hash)?;
1490 assert_eq!(files.len(), 2);
1491 assert!(files.contains(&"file1.txt".to_string()));
1492 assert!(files.contains(&"file2.txt".to_string()));
1493
1494 std::env::set_current_dir(original_dir)?;
1495 Ok(())
1496 }
1497
1498 #[test]
1499 #[serial_test::serial]
1500 fn test_get_commit_changed_files_invalid_hash() -> Result<()> {
1501 let temp_dir = setup_test_repo()?;
1502 let repo_path = temp_dir.path();
1503 let original_dir = std::env::current_dir()?;
1504
1505 std::env::set_current_dir(repo_path)?;
1506
1507 let result = get_commit_changed_files("invalid_hash");
1508 assert!(result.is_err());
1509
1510 std::env::set_current_dir(original_dir)?;
1511 Ok(())
1512 }
1513
1514 #[test]
1515 #[serial_test::serial]
1516 fn test_get_commit_changed_files_empty() -> Result<()> {
1517 let temp_dir = setup_test_repo()?;
1518 let repo_path = temp_dir.path();
1519 let original_dir = std::env::current_dir()?;
1520
1521 std::env::set_current_dir(repo_path)?;
1522
1523 Command::new("git")
1525 .args(["commit", "--allow-empty", "-m", "Empty commit"])
1526 .output()?;
1527
1528 let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1529 let hash = String::from_utf8_lossy(&hash_output.stdout)
1530 .trim()
1531 .to_string();
1532
1533 let files = get_commit_changed_files(&hash)?;
1534 assert_eq!(files.len(), 0);
1535
1536 std::env::set_current_dir(original_dir)?;
1537 Ok(())
1538 }
1539
1540 #[test]
1541 #[serial_test::serial]
1542 fn test_get_recent_commits() -> Result<()> {
1543 let temp_dir = setup_test_repo()?;
1544 let repo_path = temp_dir.path();
1545 let original_dir = std::env::current_dir()?;
1546
1547 std::env::set_current_dir(repo_path)?;
1548
1549 for i in 1..=5 {
1551 let file_path = repo_path.join(format!("test{}.txt", i));
1552 fs::write(&file_path, format!("content {}", i))?;
1553 Command::new("git").args(["add", "."]).output()?;
1554 Command::new("git")
1555 .args(["commit", "-m", &format!("Recent {}", i)])
1556 .output()?;
1557 }
1558
1559 let commits = get_recent_commits(3)?;
1561 assert_eq!(commits.len(), 3);
1562 assert_eq!(commits[0].message, "Recent 5");
1563 assert_eq!(commits[1].message, "Recent 4");
1564 assert_eq!(commits[2].message, "Recent 3");
1565
1566 std::env::set_current_dir(original_dir)?;
1567 Ok(())
1568 }
1569}