Skip to main content

chant/
git.rs

1//! Git operations for branch management and merging.
2//!
3//! # Doc Audit
4//! - audited: 2026-01-25
5//! - docs: reference/git.md
6//! - ignore: false
7
8use anyhow::{Context, Result};
9use std::process::Command;
10
11/// Get a git config value by key.
12///
13/// Returns `Some(value)` if the config key exists and has a non-empty value,
14/// `None` otherwise.
15pub 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
30/// Get git user name and email from config.
31///
32/// Returns a tuple of (user.name, user.email), where each is `Some` if configured.
33pub fn get_git_user_info() -> (Option<String>, Option<String>) {
34    (get_git_config("user.name"), get_git_config("user.email"))
35}
36
37/// Get the current branch name.
38/// Returns the branch name for the current HEAD, including "HEAD" for detached HEAD state.
39pub 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
53/// Ensure the main repo is on the main branch.
54///
55/// Call this at command boundaries to prevent branch drift.
56/// Uses config's main_branch setting (defaults to "main").
57///
58/// Warns but does not fail if checkout fails (e.g., dirty worktree).
59pub 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            // Don't fail hard - just warn
71            eprintln!("Warning: Could not return to {}: {}", main_branch, stderr);
72        }
73    }
74
75    Ok(())
76}
77
78/// Check if a branch exists in the repository.
79pub 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
93/// Check if a branch has been merged into a target branch.
94///
95/// # Arguments
96/// * `branch_name` - The branch to check
97/// * `target_branch` - The target branch to check against (e.g., "main")
98///
99/// # Returns
100/// * `Ok(true)` if the branch has been merged into the target
101/// * `Ok(false)` if the branch exists but hasn't been merged
102/// * `Err(_)` if git operations fail
103pub fn is_branch_merged(branch_name: &str, target_branch: &str) -> Result<bool> {
104    // Use git branch --merged to check if the branch is in the list of merged branches
105    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
118/// Checkout a specific branch or commit.
119/// If branch is "HEAD", it's a detached HEAD checkout.
120fn 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
138/// Check if branches have diverged (i.e., fast-forward is not possible).
139///
140/// Returns true if branches have diverged (fast-forward not possible).
141/// Returns false if a fast-forward merge is possible.
142///
143/// Fast-forward is possible when HEAD is an ancestor of or equal to spec_branch.
144/// Branches have diverged when HEAD has commits not in spec_branch.
145fn 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    // merge-base --is-ancestor returns 0 if HEAD is ancestor of spec_branch (fast-forward possible)
152    // Returns non-zero if HEAD is not ancestor of spec_branch (branches have diverged)
153    Ok(!output.status.success())
154}
155
156/// Result of a merge attempt with conflict details.
157#[derive(Debug)]
158pub struct MergeAttemptResult {
159    /// Whether merge succeeded
160    pub success: bool,
161    /// Type of conflict if any
162    pub conflict_type: Option<crate::merge_errors::ConflictType>,
163    /// Files with conflicts if any
164    pub conflicting_files: Vec<String>,
165    /// Git stderr output
166    pub stderr: String,
167}
168
169/// Merge a branch using appropriate strategy based on divergence.
170///
171/// Strategy:
172/// 1. Check if branches have diverged
173/// 2. If diverged: Use --no-ff to create a merge commit
174/// 3. If clean fast-forward possible: Use fast-forward merge
175/// 4. If conflicts exist: Return details about the conflict
176///
177/// Returns MergeAttemptResult with success status and conflict details.
178fn 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    // Check if branches have diverged
189    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        // Branches have diverged - use --no-ff to create a merge commit
196        cmd.args(["merge", "--no-ff", spec_branch, "-m", &merge_message]);
197    } else {
198        // Can do fast-forward merge
199        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        // Get git status to classify conflict and find files
208        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        // Abort the merge to restore clean state
223        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
241/// Delete a branch.
242/// Returns Ok(()) on success, or an error if deletion fails.
243pub 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/// Result of a rebase operation
262#[derive(Debug)]
263pub struct RebaseResult {
264    /// Whether rebase succeeded
265    pub success: bool,
266    /// Files with conflicts (if any)
267    pub conflicting_files: Vec<String>,
268}
269
270/// Rebase a branch onto another branch.
271/// Returns RebaseResult with success status and any conflicting files.
272pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
273    // First checkout the spec branch
274    checkout_branch(spec_branch, false)?;
275
276    // Attempt rebase
277    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    // Rebase failed - check for conflicts
290    let stderr = String::from_utf8_lossy(&output.stderr);
291    if stderr.contains("CONFLICT") || stderr.contains("conflict") {
292        // Get list of conflicting files
293        let conflicting_files = get_conflicting_files()?;
294
295        // Abort rebase to restore clean state
296        let _ = Command::new("git").args(["rebase", "--abort"]).output();
297
298        return Ok(RebaseResult {
299            success: false,
300            conflicting_files,
301        });
302    }
303
304    // Other rebase error
305    let _ = Command::new("git").args(["rebase", "--abort"]).output();
306    anyhow::bail!("Rebase failed: {}", stderr);
307}
308
309/// Get list of files with conflicts from git status
310pub 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        // Conflict markers: UU, AA, DD, AU, UD, UA, DU
321        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
333/// Continue a rebase after conflicts have been resolved
334pub fn rebase_continue() -> Result<bool> {
335    let output = Command::new("git")
336        .args(["rebase", "--continue"])
337        .env("GIT_EDITOR", "true") // Skip editor for commit message
338        .output()
339        .context("Failed to run git rebase --continue")?;
340
341    Ok(output.status.success())
342}
343
344/// Abort an in-progress rebase
345pub fn rebase_abort() -> Result<()> {
346    let _ = Command::new("git").args(["rebase", "--abort"]).output();
347    Ok(())
348}
349
350/// Stage a file for commit
351pub 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
365/// Merge a single spec's branch into the main branch.
366///
367/// This function:
368/// 1. Saves the current branch
369/// 2. Checks if main branch exists
370/// 3. Checks out main branch
371/// 4. Merges spec branch with fast-forward only
372/// 5. Optionally deletes spec branch if requested
373/// 6. Returns to original branch
374///
375/// In dry-run mode, no actual git commands are executed.
376pub 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    // In dry_run mode, try to get current branch but don't fail if we're not in a repo
384    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    // Save current branch
398    let original_branch = get_current_branch()?;
399
400    // Check if main branch exists
401    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    // Check if spec branch exists
409    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    // Checkout main branch
417    if let Err(e) = checkout_branch(main_branch, dry_run) {
418        // Try to return to original branch before failing
419        let _ = checkout_branch(&original_branch, false);
420        return Err(e);
421    }
422
423    // Perform merge
424    let merge_result = match merge_branch_ff_only(spec_branch, dry_run) {
425        Ok(result) => result,
426        Err(e) => {
427            // Try to return to original branch before failing
428            let _ = checkout_branch(&original_branch, false);
429            return Err(e);
430        }
431    };
432
433    if !merge_result.success && !dry_run {
434        // Merge had conflicts - return to original branch
435        let _ = checkout_branch(&original_branch, false);
436
437        // Use detailed error message with conflict type and file list
438        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    // Delete branch if requested and merge was successful
457    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            // Log warning but don't fail overall
462            branch_delete_warning = Some(format!("Warning: Failed to delete branch: {}", e));
463        } else {
464            branch_actually_deleted = true;
465        }
466    }
467
468    // Return to original branch, BUT not if:
469    // 1. We're already on main (no need to switch)
470    // 2. The original branch was the spec branch that we just deleted
471    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            // If we can't checkout the original branch, stay on main
477            // This can happen if the original branch was deleted elsewhere
478            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/// Result of a merge operation.
497#[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
508/// Format the merge result as a human-readable summary.
509pub 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
540/// Check if branch can be fast-forward merged into target branch.
541/// Returns true if the merge can be done as a fast-forward (no divergence).
542pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
543    // Get merge base between branch and target
544    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    // Get the commit hash of target
556    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    // If merge base equals target, then branch is ahead and can ff-merge
568    Ok(merge_base == target_commit)
569}
570
571/// Check if branch is behind target branch.
572/// Returns true if target has commits that branch doesn't have.
573pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
574    // Get merge base
575    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    // Get branch commit
587    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    // If merge base equals branch commit, then branch is behind target
599    Ok(merge_base == branch_commit)
600}
601
602/// Count number of commits in branch.
603pub 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/// Information about a single git commit.
618#[derive(Debug, Clone)]
619pub struct CommitInfo {
620    pub hash: String,
621    pub message: String,
622    pub author: String,
623    pub timestamp: i64,
624}
625
626/// Get commits in a range between two refs.
627///
628/// Returns commits between `from_ref` and `to_ref` (inclusive of `to_ref`, exclusive of `from_ref`).
629/// Uses `git log from_ref..to_ref` format.
630///
631/// # Errors
632/// Returns error if refs are invalid or git command fails.
633pub 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
670/// Get files changed in a specific commit.
671///
672/// Returns a list of file paths that were modified in the commit.
673///
674/// # Errors
675/// Returns error if commit hash is invalid or git command fails.
676pub 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
697/// Get files changed in a commit with their status (A/M/D).
698///
699/// Returns a list of strings in the format "STATUS:filename" (e.g., "A:file.txt", "M:file.txt").
700///
701/// # Errors
702/// Returns error if commit hash is invalid or git command fails.
703pub 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            // parts[0] is status (A, M, D), parts[1] is filename
720            files.push(format!("{}:{}", parts[0], parts[1]));
721        }
722    }
723
724    Ok(files)
725}
726
727/// Get file content at a specific commit.
728///
729/// Returns the file content as a string, or an empty string if the file doesn't exist at that commit.
730///
731/// # Errors
732/// Returns error if git command fails.
733pub 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
746/// Get file content at parent commit.
747///
748/// Returns the file content as a string, or an empty string if the file doesn't exist at parent commit.
749///
750/// # Errors
751/// Returns error if git command fails.
752pub 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
765/// Get the N most recent commits.
766///
767/// # Errors
768/// Returns error if git command fails.
769pub 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
806/// Get commits that modified a specific path.
807///
808/// # Arguments
809/// * `path` - File or directory path to filter by
810///
811/// # Errors
812/// Returns error if git command fails.
813pub 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        // This should work in any git repo - gets the current branch
857        let result = get_current_branch();
858        // In a properly initialized git repo, this should succeed
859        if let Ok(branch) = result {
860            // Should have a branch name (not empty)
861            assert!(!branch.is_empty());
862        }
863    }
864
865    // Helper function to initialize a mock git repo for testing
866    fn setup_test_repo() -> Result<TempDir> {
867        let temp_dir = TempDir::new()?;
868        let repo_path = temp_dir.path();
869
870        // Initialize git repo
871        Command::new("git")
872            .arg("init")
873            .current_dir(repo_path)
874            .output()?;
875
876        // Configure git
877        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        // Create initial commit
888        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        // Create and checkout main branch
901        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        // Create a spec branch
924        Command::new("git")
925            .args(["checkout", "-b", "spec-001"])
926            .output()?;
927
928        // Make a change on spec branch
929        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        // Go back to main
939        Command::new("git").args(["checkout", "main"]).output()?;
940
941        // Test merge with dry-run
942        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        // Verify we're still on main
951        let current = get_current_branch()?;
952        assert_eq!(current, "main");
953
954        // Verify spec branch still exists (because of dry-run)
955        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        // Create a spec branch
971        Command::new("git")
972            .args(["checkout", "-b", "spec-002"])
973            .output()?;
974
975        // Make a change on spec branch
976        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        // Go back to main
986        Command::new("git").args(["checkout", "main"]).output()?;
987
988        // Test merge with delete
989        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        // Verify branch was deleted
996        assert!(!branch_exists("spec-002")?);
997
998        // Verify we're back on main
999        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        // Create a spec branch
1016        Command::new("git")
1017            .args(["checkout", "-b", "spec-003"])
1018            .output()?;
1019
1020        // Make a change on spec branch
1021        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        // Test merge with nonexistent main branch
1031        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        // Verify we're still on spec-003
1037        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        // Test merge with nonexistent spec branch
1054        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        // Verify we're still on main
1060        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        // Create a spec branch that's ahead of main
1161        Command::new("git")
1162            .args(["checkout", "-b", "spec-no-diverge"])
1163            .output()?;
1164
1165        // Make a change on spec branch
1166        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        // Go back to main
1176        Command::new("git").args(["checkout", "main"]).output()?;
1177
1178        // Test divergence check - spec branch is ancestor of main, so no divergence
1179        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        // Create a spec branch from main
1196        Command::new("git")
1197            .args(["checkout", "-b", "spec-diverge"])
1198            .output()?;
1199
1200        // Make a change on spec branch
1201        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        // Go back to main and make a different change
1211        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        // Test divergence check - branches have diverged
1222        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        // Create a spec branch from main
1239        Command::new("git")
1240            .args(["checkout", "-b", "spec-diverged"])
1241            .output()?;
1242
1243        // Make a change on spec branch
1244        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        // Go back to main and make a different change
1254        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        // Merge with diverged branches - should use --no-ff automatically
1265        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        // Verify we're back on main
1272        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        // Create a spec branch
1289        Command::new("git")
1290            .args(["checkout", "-b", "spec-test"])
1291            .output()?;
1292
1293        // Verify we're on spec-test
1294        let current = get_current_branch()?;
1295        assert_eq!(current, "spec-test");
1296
1297        // Call ensure_on_main_branch - should switch back to main
1298        ensure_on_main_branch("main")?;
1299
1300        // Verify we're back on main
1301        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        // Verify we're on main
1318        let current = get_current_branch()?;
1319        assert_eq!(current, "main");
1320
1321        // Call ensure_on_main_branch - should be a no-op
1322        ensure_on_main_branch("main")?;
1323
1324        // Verify we're still on main
1325        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        // Create additional commits
1342        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        // Get commits in range
1352        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        // Same ref should return empty
1388        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        // Create a commit with multiple files
1405        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        // Create an empty commit
1454        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        // Create additional commits
1480        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        // Get 3 recent commits
1490        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}