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, removing associated worktrees first.
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    // Remove any worktrees associated with this branch before deleting it
249    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
264/// Remove all worktrees associated with a branch.
265/// This is idempotent and won't fail if no worktrees exist.
266fn remove_worktrees_for_branch(branch_name: &str) -> Result<()> {
267    // List all worktrees
268    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        // If git worktree list fails, just continue (maybe not a worktree-enabled repo)
275        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    // Parse the porcelain output to find worktrees for this branch
283    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    // Remove each worktree associated with this branch
299    for path in worktrees_to_remove {
300        // Try with --force to handle any uncommitted changes
301        let _ = Command::new("git")
302            .args(["worktree", "remove", &path, "--force"])
303            .output();
304
305        // Also try to remove the directory if it still exists (in case git worktree remove failed)
306        let _ = std::fs::remove_dir_all(&path);
307    }
308
309    Ok(())
310}
311
312/// Result of a rebase operation
313#[derive(Debug)]
314pub struct RebaseResult {
315    /// Whether rebase succeeded
316    pub success: bool,
317    /// Files with conflicts (if any)
318    pub conflicting_files: Vec<String>,
319}
320
321/// Rebase a branch onto another branch.
322/// Returns RebaseResult with success status and any conflicting files.
323pub fn rebase_branch(spec_branch: &str, onto_branch: &str) -> Result<RebaseResult> {
324    // First checkout the spec branch
325    checkout_branch(spec_branch, false)?;
326
327    // Attempt rebase
328    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    // Rebase failed - check for conflicts
341    let stderr = String::from_utf8_lossy(&output.stderr);
342    if stderr.contains("CONFLICT") || stderr.contains("conflict") {
343        // Get list of conflicting files
344        let conflicting_files = get_conflicting_files()?;
345
346        // Abort rebase to restore clean state
347        let _ = Command::new("git").args(["rebase", "--abort"]).output();
348
349        return Ok(RebaseResult {
350            success: false,
351            conflicting_files,
352        });
353    }
354
355    // Other rebase error
356    let _ = Command::new("git").args(["rebase", "--abort"]).output();
357    anyhow::bail!("Rebase failed: {}", stderr);
358}
359
360/// Get list of files with conflicts from git status
361pub 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        // Conflict markers: UU, AA, DD, AU, UD, UA, DU
372        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
384/// Continue a rebase after conflicts have been resolved
385pub fn rebase_continue() -> Result<bool> {
386    let output = Command::new("git")
387        .args(["rebase", "--continue"])
388        .env("GIT_EDITOR", "true") // Skip editor for commit message
389        .output()
390        .context("Failed to run git rebase --continue")?;
391
392    Ok(output.status.success())
393}
394
395/// Abort an in-progress rebase
396pub fn rebase_abort() -> Result<()> {
397    let _ = Command::new("git").args(["rebase", "--abort"]).output();
398    Ok(())
399}
400
401/// Stage a file for commit
402pub 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
416/// Merge a single spec's branch into the main branch.
417///
418/// This function:
419/// 1. Saves the current branch
420/// 2. Checks if main branch exists
421/// 3. Checks out main branch
422/// 4. Merges spec branch with fast-forward only
423/// 5. Optionally deletes spec branch if requested
424/// 6. Returns to original branch
425///
426/// In dry-run mode, no actual git commands are executed.
427pub 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    // In dry_run mode, try to get current branch but don't fail if we're not in a repo
435    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    // Save current branch
449    let original_branch = get_current_branch()?;
450
451    // Check if main branch exists
452    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    // Check if spec branch exists
460    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    // Checkout main branch
468    if let Err(e) = checkout_branch(main_branch, dry_run) {
469        // Try to return to original branch before failing
470        let _ = checkout_branch(&original_branch, false);
471        return Err(e);
472    }
473
474    // Perform merge
475    let merge_result = match merge_branch_ff_only(spec_branch, dry_run) {
476        Ok(result) => result,
477        Err(e) => {
478            // Try to return to original branch before failing
479            let _ = checkout_branch(&original_branch, false);
480            return Err(e);
481        }
482    };
483
484    if !merge_result.success && !dry_run {
485        // Merge had conflicts - return to original branch
486        let _ = checkout_branch(&original_branch, false);
487
488        // Use detailed error message with conflict type and file list
489        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    // Delete branch if requested and merge was successful
508    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            // Log warning but don't fail overall
513            branch_delete_warning = Some(format!("Warning: Failed to delete branch: {}", e));
514        } else {
515            branch_actually_deleted = true;
516        }
517    }
518
519    // Return to original branch, BUT not if:
520    // 1. We're already on main (no need to switch)
521    // 2. The original branch was the spec branch that we just deleted
522    let should_checkout_original = original_branch != main_branch
523        && !(branch_actually_deleted && original_branch == spec_branch);
524
525    if should_checkout_original {
526        if let Err(e) = checkout_branch(&original_branch, false) {
527            // If we can't checkout the original branch, stay on main
528            // This can happen if the original branch was deleted elsewhere
529            eprintln!(
530                "Warning: Could not return to original branch '{}': {}. Staying on {}.",
531                original_branch, e, main_branch
532            );
533        }
534    }
535
536    Ok(MergeResult {
537        spec_id: spec_id.to_string(),
538        success: merge_success,
539        original_branch,
540        merged_to: main_branch.to_string(),
541        branch_deleted: should_delete_branch && merge_success,
542        branch_delete_warning,
543        dry_run,
544    })
545}
546
547/// Result of a merge operation.
548#[derive(Debug, Clone)]
549pub struct MergeResult {
550    pub spec_id: String,
551    pub success: bool,
552    pub original_branch: String,
553    pub merged_to: String,
554    pub branch_deleted: bool,
555    pub branch_delete_warning: Option<String>,
556    pub dry_run: bool,
557}
558
559/// Format the merge result as a human-readable summary.
560pub fn format_merge_summary(result: &MergeResult) -> String {
561    let mut output = String::new();
562
563    if result.dry_run {
564        output.push_str("[DRY RUN] ");
565    }
566
567    if result.success {
568        output.push_str(&format!(
569            "✓ Successfully merged {} to {}",
570            result.spec_id, result.merged_to
571        ));
572        if result.branch_deleted {
573            output.push_str(&format!(" and deleted branch {}", result.spec_id));
574        }
575    } else {
576        output.push_str(&format!(
577            "✗ Failed to merge {} to {}",
578            result.spec_id, result.merged_to
579        ));
580    }
581
582    if let Some(warning) = &result.branch_delete_warning {
583        output.push_str(&format!("\n  {}", warning));
584    }
585
586    output.push_str(&format!("\nReturned to branch: {}", result.original_branch));
587
588    output
589}
590
591/// Check if branch can be fast-forward merged into target branch.
592/// Returns true if the merge can be done as a fast-forward (no divergence).
593pub fn can_fast_forward_merge(branch: &str, target: &str) -> Result<bool> {
594    // Get merge base between branch and target
595    let output = Command::new("git")
596        .args(["merge-base", target, branch])
597        .output()
598        .context("Failed to find merge base")?;
599
600    if !output.status.success() {
601        return Ok(false);
602    }
603
604    let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
605
606    // Get the commit hash of target
607    let output = Command::new("git")
608        .args(["rev-parse", target])
609        .output()
610        .context("Failed to get target commit")?;
611
612    if !output.status.success() {
613        return Ok(false);
614    }
615
616    let target_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
617
618    // If merge base equals target, then branch is ahead and can ff-merge
619    Ok(merge_base == target_commit)
620}
621
622/// Check if branch is behind target branch.
623/// Returns true if target has commits that branch doesn't have.
624pub fn is_branch_behind(branch: &str, target: &str) -> Result<bool> {
625    // Get merge base
626    let output = Command::new("git")
627        .args(["merge-base", branch, target])
628        .output()
629        .context("Failed to find merge base")?;
630
631    if !output.status.success() {
632        return Ok(false);
633    }
634
635    let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string();
636
637    // Get branch commit
638    let output = Command::new("git")
639        .args(["rev-parse", branch])
640        .output()
641        .context("Failed to get branch commit")?;
642
643    if !output.status.success() {
644        return Ok(false);
645    }
646
647    let branch_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
648
649    // If merge base equals branch commit, then branch is behind target
650    Ok(merge_base == branch_commit)
651}
652
653/// Count number of commits in branch.
654pub fn count_commits(branch: &str) -> Result<usize> {
655    let output = Command::new("git")
656        .args(["rev-list", "--count", branch])
657        .output()
658        .context("Failed to count commits")?;
659
660    if !output.status.success() {
661        return Ok(0);
662    }
663
664    let count_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
665    Ok(count_str.parse().unwrap_or(0))
666}
667
668/// Information about a single git commit.
669#[derive(Debug, Clone)]
670pub struct CommitInfo {
671    pub hash: String,
672    pub message: String,
673    pub author: String,
674    pub timestamp: i64,
675}
676
677/// Get commits in a range between two refs.
678///
679/// Returns commits between `from_ref` and `to_ref` (inclusive of `to_ref`, exclusive of `from_ref`).
680/// Uses `git log from_ref..to_ref` format.
681///
682/// # Errors
683/// Returns error if refs are invalid or git command fails.
684pub fn get_commits_in_range(from_ref: &str, to_ref: &str) -> Result<Vec<CommitInfo>> {
685    let range = format!("{}..{}", from_ref, to_ref);
686
687    let output = Command::new("git")
688        .args(["log", &range, "--format=%H|%an|%at|%s", "--reverse"])
689        .output()
690        .context("Failed to execute git log")?;
691
692    if !output.status.success() {
693        let stderr = String::from_utf8_lossy(&output.stderr);
694        anyhow::bail!("Invalid git refs {}: {}", range, stderr);
695    }
696
697    let stdout = String::from_utf8_lossy(&output.stdout);
698    let mut commits = Vec::new();
699
700    for line in stdout.lines() {
701        if line.is_empty() {
702            continue;
703        }
704
705        let parts: Vec<&str> = line.splitn(4, '|').collect();
706        if parts.len() != 4 {
707            continue;
708        }
709
710        commits.push(CommitInfo {
711            hash: parts[0].to_string(),
712            author: parts[1].to_string(),
713            timestamp: parts[2].parse().unwrap_or(0),
714            message: parts[3].to_string(),
715        });
716    }
717
718    Ok(commits)
719}
720
721/// Get files changed in a specific commit.
722///
723/// Returns a list of file paths that were modified in the commit.
724///
725/// # Errors
726/// Returns error if commit hash is invalid or git command fails.
727pub fn get_commit_changed_files(hash: &str) -> Result<Vec<String>> {
728    let output = Command::new("git")
729        .args(["diff-tree", "--no-commit-id", "--name-only", "-r", hash])
730        .output()
731        .context("Failed to execute git diff-tree")?;
732
733    if !output.status.success() {
734        let stderr = String::from_utf8_lossy(&output.stderr);
735        anyhow::bail!("Invalid commit hash {}: {}", hash, stderr);
736    }
737
738    let stdout = String::from_utf8_lossy(&output.stdout);
739    let files: Vec<String> = stdout
740        .lines()
741        .filter(|line| !line.is_empty())
742        .map(|line| line.to_string())
743        .collect();
744
745    Ok(files)
746}
747
748/// Get files changed in a commit with their status (A/M/D).
749///
750/// Returns a list of strings in the format "STATUS:filename" (e.g., "A:file.txt", "M:file.txt").
751///
752/// # Errors
753/// Returns error if commit hash is invalid or git command fails.
754pub fn get_commit_files_with_status(hash: &str) -> Result<Vec<String>> {
755    let output = Command::new("git")
756        .args(["diff-tree", "--no-commit-id", "--name-status", "-r", hash])
757        .output()
758        .context("Failed to execute git diff-tree")?;
759
760    if !output.status.success() {
761        return Ok(Vec::new());
762    }
763
764    let stdout = String::from_utf8_lossy(&output.stdout);
765    let mut files = Vec::new();
766
767    for line in stdout.lines() {
768        let parts: Vec<&str> = line.split('\t').collect();
769        if parts.len() >= 2 {
770            // parts[0] is status (A, M, D), parts[1] is filename
771            files.push(format!("{}:{}", parts[0], parts[1]));
772        }
773    }
774
775    Ok(files)
776}
777
778/// Get file content at a specific commit.
779///
780/// Returns the file content as a string, or an empty string if the file doesn't exist at that commit.
781///
782/// # Errors
783/// Returns error if git command fails.
784pub fn get_file_at_commit(commit: &str, file: &str) -> Result<String> {
785    let output = Command::new("git")
786        .args(["show", &format!("{}:{}", commit, file)])
787        .output()
788        .context("Failed to get file at commit")?;
789
790    if !output.status.success() {
791        return Ok(String::new());
792    }
793
794    Ok(String::from_utf8_lossy(&output.stdout).to_string())
795}
796
797/// Get file content at parent commit.
798///
799/// Returns the file content as a string, or an empty string if the file doesn't exist at parent commit.
800///
801/// # Errors
802/// Returns error if git command fails.
803pub fn get_file_at_parent(commit: &str, file: &str) -> Result<String> {
804    let output = Command::new("git")
805        .args(["show", &format!("{}^:{}", commit, file)])
806        .output()
807        .context("Failed to get file at parent")?;
808
809    if !output.status.success() {
810        return Ok(String::new());
811    }
812
813    Ok(String::from_utf8_lossy(&output.stdout).to_string())
814}
815
816/// Get the N most recent commits.
817///
818/// # Errors
819/// Returns error if git command fails.
820pub fn get_recent_commits(count: usize) -> Result<Vec<CommitInfo>> {
821    let count_str = count.to_string();
822
823    let output = Command::new("git")
824        .args(["log", "-n", &count_str, "--format=%H|%an|%at|%s"])
825        .output()
826        .context("Failed to execute git log")?;
827
828    if !output.status.success() {
829        let stderr = String::from_utf8_lossy(&output.stderr);
830        anyhow::bail!("Failed to get recent commits: {}", stderr);
831    }
832
833    let stdout = String::from_utf8_lossy(&output.stdout);
834    let mut commits = Vec::new();
835
836    for line in stdout.lines() {
837        if line.is_empty() {
838            continue;
839        }
840
841        let parts: Vec<&str> = line.splitn(4, '|').collect();
842        if parts.len() != 4 {
843            continue;
844        }
845
846        commits.push(CommitInfo {
847            hash: parts[0].to_string(),
848            author: parts[1].to_string(),
849            timestamp: parts[2].parse().unwrap_or(0),
850            message: parts[3].to_string(),
851        });
852    }
853
854    Ok(commits)
855}
856
857/// Get commits that modified a specific path.
858///
859/// # Arguments
860/// * `path` - File or directory path to filter by
861///
862/// # Errors
863/// Returns error if git command fails.
864pub fn get_commits_for_path(path: &str) -> Result<Vec<CommitInfo>> {
865    let output = Command::new("git")
866        .args(["log", "--all", "--format=%H|%an|%at|%s", "--", path])
867        .output()
868        .context("Failed to execute git log")?;
869
870    if !output.status.success() {
871        let stderr = String::from_utf8_lossy(&output.stderr);
872        anyhow::bail!("git log failed: {}", stderr);
873    }
874
875    let stdout = String::from_utf8_lossy(&output.stdout);
876    let mut commits = Vec::new();
877
878    for line in stdout.lines() {
879        if line.is_empty() {
880            continue;
881        }
882
883        let parts: Vec<&str> = line.splitn(4, '|').collect();
884        if parts.len() != 4 {
885            continue;
886        }
887
888        commits.push(CommitInfo {
889            hash: parts[0].to_string(),
890            author: parts[1].to_string(),
891            timestamp: parts[2].parse().unwrap_or(0),
892            message: parts[3].to_string(),
893        });
894    }
895
896    Ok(commits)
897}
898
899#[cfg(test)]
900mod tests {
901    use super::*;
902    use std::fs;
903    use tempfile::TempDir;
904
905    #[test]
906    fn test_get_current_branch_returns_string() {
907        // This should work in any git repo - gets the current branch
908        let result = get_current_branch();
909        // In a properly initialized git repo, this should succeed
910        if let Ok(branch) = result {
911            // Should have a branch name (not empty)
912            assert!(!branch.is_empty());
913        }
914    }
915
916    // Helper function to initialize a mock git repo for testing
917    fn setup_test_repo() -> Result<TempDir> {
918        let temp_dir = TempDir::new()?;
919        let repo_path = temp_dir.path();
920
921        // Initialize git repo
922        Command::new("git")
923            .arg("init")
924            .current_dir(repo_path)
925            .output()?;
926
927        // Configure git
928        Command::new("git")
929            .args(["config", "user.email", "test@example.com"])
930            .current_dir(repo_path)
931            .output()?;
932
933        Command::new("git")
934            .args(["config", "user.name", "Test User"])
935            .current_dir(repo_path)
936            .output()?;
937
938        // Create initial commit
939        let file_path = repo_path.join("test.txt");
940        fs::write(&file_path, "test content")?;
941        Command::new("git")
942            .args(["add", "test.txt"])
943            .current_dir(repo_path)
944            .output()?;
945
946        Command::new("git")
947            .args(["commit", "-m", "Initial commit"])
948            .current_dir(repo_path)
949            .output()?;
950
951        // Create and checkout main branch
952        Command::new("git")
953            .args(["branch", "main"])
954            .current_dir(repo_path)
955            .output()?;
956
957        Command::new("git")
958            .args(["checkout", "main"])
959            .current_dir(repo_path)
960            .output()?;
961
962        Ok(temp_dir)
963    }
964
965    #[test]
966    #[serial_test::serial]
967    fn test_merge_single_spec_successful_dry_run() -> Result<()> {
968        let temp_dir = setup_test_repo()?;
969        let repo_path = temp_dir.path();
970        let original_dir = std::env::current_dir()?;
971
972        std::env::set_current_dir(repo_path)?;
973
974        // Create a spec branch
975        Command::new("git")
976            .args(["checkout", "-b", "spec-001"])
977            .output()?;
978
979        // Make a change on spec branch
980        let file_path = repo_path.join("spec-file.txt");
981        fs::write(&file_path, "spec content")?;
982        Command::new("git")
983            .args(["add", "spec-file.txt"])
984            .output()?;
985        Command::new("git")
986            .args(["commit", "-m", "Add spec-file"])
987            .output()?;
988
989        // Go back to main
990        Command::new("git").args(["checkout", "main"]).output()?;
991
992        // Test merge with dry-run
993        let result = merge_single_spec("spec-001", "spec-001", "main", false, true)?;
994
995        assert!(result.success);
996        assert!(result.dry_run);
997        assert_eq!(result.spec_id, "spec-001");
998        assert_eq!(result.merged_to, "main");
999        assert_eq!(result.original_branch, "main");
1000
1001        // Verify we're still on main
1002        let current = get_current_branch()?;
1003        assert_eq!(current, "main");
1004
1005        // Verify spec branch still exists (because of dry-run)
1006        assert!(branch_exists("spec-001")?);
1007
1008        std::env::set_current_dir(original_dir)?;
1009        Ok(())
1010    }
1011
1012    #[test]
1013    #[serial_test::serial]
1014    fn test_merge_single_spec_successful_with_delete() -> Result<()> {
1015        let temp_dir = setup_test_repo()?;
1016        let repo_path = temp_dir.path();
1017        let original_dir = std::env::current_dir()?;
1018
1019        std::env::set_current_dir(repo_path)?;
1020
1021        // Create a spec branch
1022        Command::new("git")
1023            .args(["checkout", "-b", "spec-002"])
1024            .output()?;
1025
1026        // Make a change on spec branch
1027        let file_path = repo_path.join("spec-file2.txt");
1028        fs::write(&file_path, "spec content 2")?;
1029        Command::new("git")
1030            .args(["add", "spec-file2.txt"])
1031            .output()?;
1032        Command::new("git")
1033            .args(["commit", "-m", "Add spec-file2"])
1034            .output()?;
1035
1036        // Go back to main
1037        Command::new("git").args(["checkout", "main"]).output()?;
1038
1039        // Test merge with delete
1040        let result = merge_single_spec("spec-002", "spec-002", "main", true, false)?;
1041
1042        assert!(result.success);
1043        assert!(!result.dry_run);
1044        assert!(result.branch_deleted);
1045
1046        // Verify branch was deleted
1047        assert!(!branch_exists("spec-002")?);
1048
1049        // Verify we're back on main
1050        let current = get_current_branch()?;
1051        assert_eq!(current, "main");
1052
1053        std::env::set_current_dir(original_dir)?;
1054        Ok(())
1055    }
1056
1057    #[test]
1058    #[serial_test::serial]
1059    fn test_merge_single_spec_nonexistent_main_branch() -> Result<()> {
1060        let temp_dir = setup_test_repo()?;
1061        let repo_path = temp_dir.path();
1062        let original_dir = std::env::current_dir()?;
1063
1064        std::env::set_current_dir(repo_path)?;
1065
1066        // Create a spec branch
1067        Command::new("git")
1068            .args(["checkout", "-b", "spec-003"])
1069            .output()?;
1070
1071        // Make a change on spec branch
1072        let file_path = repo_path.join("spec-file3.txt");
1073        fs::write(&file_path, "spec content 3")?;
1074        Command::new("git")
1075            .args(["add", "spec-file3.txt"])
1076            .output()?;
1077        Command::new("git")
1078            .args(["commit", "-m", "Add spec-file3"])
1079            .output()?;
1080
1081        // Test merge with nonexistent main branch
1082        let result = merge_single_spec("spec-003", "spec-003", "nonexistent", false, false);
1083
1084        assert!(result.is_err());
1085        assert!(result.unwrap_err().to_string().contains("does not exist"));
1086
1087        // Verify we're still on spec-003
1088        let current = get_current_branch()?;
1089        assert_eq!(current, "spec-003");
1090
1091        std::env::set_current_dir(original_dir)?;
1092        Ok(())
1093    }
1094
1095    #[test]
1096    #[serial_test::serial]
1097    fn test_merge_single_spec_nonexistent_spec_branch() -> Result<()> {
1098        let temp_dir = setup_test_repo()?;
1099        let repo_path = temp_dir.path();
1100        let original_dir = std::env::current_dir()?;
1101
1102        std::env::set_current_dir(repo_path)?;
1103
1104        // Test merge with nonexistent spec branch
1105        let result = merge_single_spec("nonexistent", "nonexistent", "main", false, false);
1106
1107        assert!(result.is_err());
1108        assert!(result.unwrap_err().to_string().contains("not found"));
1109
1110        // Verify we're still on main
1111        let current = get_current_branch()?;
1112        assert_eq!(current, "main");
1113
1114        std::env::set_current_dir(original_dir)?;
1115        Ok(())
1116    }
1117
1118    #[test]
1119    fn test_format_merge_summary_success() {
1120        let result = MergeResult {
1121            spec_id: "spec-001".to_string(),
1122            success: true,
1123            original_branch: "main".to_string(),
1124            merged_to: "main".to_string(),
1125            branch_deleted: false,
1126            branch_delete_warning: None,
1127            dry_run: false,
1128        };
1129
1130        let summary = format_merge_summary(&result);
1131        assert!(summary.contains("✓"));
1132        assert!(summary.contains("spec-001"));
1133        assert!(summary.contains("Returned to branch: main"));
1134    }
1135
1136    #[test]
1137    fn test_format_merge_summary_with_delete() {
1138        let result = MergeResult {
1139            spec_id: "spec-002".to_string(),
1140            success: true,
1141            original_branch: "main".to_string(),
1142            merged_to: "main".to_string(),
1143            branch_deleted: true,
1144            branch_delete_warning: None,
1145            dry_run: false,
1146        };
1147
1148        let summary = format_merge_summary(&result);
1149        assert!(summary.contains("✓"));
1150        assert!(summary.contains("deleted branch spec-002"));
1151    }
1152
1153    #[test]
1154    fn test_format_merge_summary_dry_run() {
1155        let result = MergeResult {
1156            spec_id: "spec-003".to_string(),
1157            success: true,
1158            original_branch: "main".to_string(),
1159            merged_to: "main".to_string(),
1160            branch_deleted: false,
1161            branch_delete_warning: None,
1162            dry_run: true,
1163        };
1164
1165        let summary = format_merge_summary(&result);
1166        assert!(summary.contains("[DRY RUN]"));
1167    }
1168
1169    #[test]
1170    fn test_format_merge_summary_with_warning() {
1171        let result = MergeResult {
1172            spec_id: "spec-004".to_string(),
1173            success: true,
1174            original_branch: "main".to_string(),
1175            merged_to: "main".to_string(),
1176            branch_deleted: false,
1177            branch_delete_warning: Some("Warning: Could not delete branch".to_string()),
1178            dry_run: false,
1179        };
1180
1181        let summary = format_merge_summary(&result);
1182        assert!(summary.contains("Warning"));
1183    }
1184
1185    #[test]
1186    fn test_format_merge_summary_failure() {
1187        let result = MergeResult {
1188            spec_id: "spec-005".to_string(),
1189            success: false,
1190            original_branch: "main".to_string(),
1191            merged_to: "main".to_string(),
1192            branch_deleted: false,
1193            branch_delete_warning: None,
1194            dry_run: false,
1195        };
1196
1197        let summary = format_merge_summary(&result);
1198        assert!(summary.contains("✗"));
1199        assert!(summary.contains("Failed to merge"));
1200    }
1201
1202    #[test]
1203    #[serial_test::serial]
1204    fn test_branches_have_diverged_no_divergence() -> Result<()> {
1205        let temp_dir = setup_test_repo()?;
1206        let repo_path = temp_dir.path();
1207        let original_dir = std::env::current_dir()?;
1208
1209        std::env::set_current_dir(repo_path)?;
1210
1211        // Create a spec branch that's ahead of main
1212        Command::new("git")
1213            .args(["checkout", "-b", "spec-no-diverge"])
1214            .output()?;
1215
1216        // Make a change on spec branch
1217        let file_path = repo_path.join("diverge-test.txt");
1218        fs::write(&file_path, "spec content")?;
1219        Command::new("git")
1220            .args(["add", "diverge-test.txt"])
1221            .output()?;
1222        Command::new("git")
1223            .args(["commit", "-m", "Add diverge-test"])
1224            .output()?;
1225
1226        // Go back to main
1227        Command::new("git").args(["checkout", "main"]).output()?;
1228
1229        // Test divergence check - spec branch is ancestor of main, so no divergence
1230        let diverged = branches_have_diverged("spec-no-diverge")?;
1231        assert!(!diverged, "Fast-forward merge should be possible");
1232
1233        std::env::set_current_dir(original_dir)?;
1234        Ok(())
1235    }
1236
1237    #[test]
1238    #[serial_test::serial]
1239    fn test_branches_have_diverged_with_divergence() -> Result<()> {
1240        let temp_dir = setup_test_repo()?;
1241        let repo_path = temp_dir.path();
1242        let original_dir = std::env::current_dir()?;
1243
1244        std::env::set_current_dir(repo_path)?;
1245
1246        // Create a spec branch from main
1247        Command::new("git")
1248            .args(["checkout", "-b", "spec-diverge"])
1249            .output()?;
1250
1251        // Make a change on spec branch
1252        let file_path = repo_path.join("spec-file.txt");
1253        fs::write(&file_path, "spec content")?;
1254        Command::new("git")
1255            .args(["add", "spec-file.txt"])
1256            .output()?;
1257        Command::new("git")
1258            .args(["commit", "-m", "Add spec-file"])
1259            .output()?;
1260
1261        // Go back to main and make a different change
1262        Command::new("git").args(["checkout", "main"]).output()?;
1263        let main_file = repo_path.join("main-file.txt");
1264        fs::write(&main_file, "main content")?;
1265        Command::new("git")
1266            .args(["add", "main-file.txt"])
1267            .output()?;
1268        Command::new("git")
1269            .args(["commit", "-m", "Add main-file"])
1270            .output()?;
1271
1272        // Test divergence check - branches have diverged
1273        let diverged = branches_have_diverged("spec-diverge")?;
1274        assert!(diverged, "Branches should have diverged");
1275
1276        std::env::set_current_dir(original_dir)?;
1277        Ok(())
1278    }
1279
1280    #[test]
1281    #[serial_test::serial]
1282    fn test_merge_single_spec_with_diverged_branches() -> Result<()> {
1283        let temp_dir = setup_test_repo()?;
1284        let repo_path = temp_dir.path();
1285        let original_dir = std::env::current_dir()?;
1286
1287        std::env::set_current_dir(repo_path)?;
1288
1289        // Create a spec branch from main
1290        Command::new("git")
1291            .args(["checkout", "-b", "spec-diverged"])
1292            .output()?;
1293
1294        // Make a change on spec branch
1295        let file_path = repo_path.join("spec-change.txt");
1296        fs::write(&file_path, "spec content")?;
1297        Command::new("git")
1298            .args(["add", "spec-change.txt"])
1299            .output()?;
1300        Command::new("git")
1301            .args(["commit", "-m", "Add spec-change"])
1302            .output()?;
1303
1304        // Go back to main and make a different change
1305        Command::new("git").args(["checkout", "main"]).output()?;
1306        let main_file = repo_path.join("main-change.txt");
1307        fs::write(&main_file, "main content")?;
1308        Command::new("git")
1309            .args(["add", "main-change.txt"])
1310            .output()?;
1311        Command::new("git")
1312            .args(["commit", "-m", "Add main-change"])
1313            .output()?;
1314
1315        // Merge with diverged branches - should use --no-ff automatically
1316        let result = merge_single_spec("spec-diverged", "spec-diverged", "main", false, false)?;
1317
1318        assert!(result.success, "Merge should succeed with --no-ff");
1319        assert_eq!(result.spec_id, "spec-diverged");
1320        assert_eq!(result.merged_to, "main");
1321
1322        // Verify we're back on main
1323        let current = get_current_branch()?;
1324        assert_eq!(current, "main");
1325
1326        std::env::set_current_dir(original_dir)?;
1327        Ok(())
1328    }
1329
1330    #[test]
1331    #[serial_test::serial]
1332    fn test_ensure_on_main_branch() -> Result<()> {
1333        let temp_dir = setup_test_repo()?;
1334        let repo_path = temp_dir.path();
1335        let original_dir = std::env::current_dir()?;
1336
1337        std::env::set_current_dir(repo_path)?;
1338
1339        // Create a spec branch
1340        Command::new("git")
1341            .args(["checkout", "-b", "spec-test"])
1342            .output()?;
1343
1344        // Verify we're on spec-test
1345        let current = get_current_branch()?;
1346        assert_eq!(current, "spec-test");
1347
1348        // Call ensure_on_main_branch - should switch back to main
1349        ensure_on_main_branch("main")?;
1350
1351        // Verify we're back on main
1352        let current = get_current_branch()?;
1353        assert_eq!(current, "main");
1354
1355        std::env::set_current_dir(original_dir)?;
1356        Ok(())
1357    }
1358
1359    #[test]
1360    #[serial_test::serial]
1361    fn test_ensure_on_main_branch_already_on_main() -> Result<()> {
1362        let temp_dir = setup_test_repo()?;
1363        let repo_path = temp_dir.path();
1364        let original_dir = std::env::current_dir()?;
1365
1366        std::env::set_current_dir(repo_path)?;
1367
1368        // Verify we're on main
1369        let current = get_current_branch()?;
1370        assert_eq!(current, "main");
1371
1372        // Call ensure_on_main_branch - should be a no-op
1373        ensure_on_main_branch("main")?;
1374
1375        // Verify we're still on main
1376        let current = get_current_branch()?;
1377        assert_eq!(current, "main");
1378
1379        std::env::set_current_dir(original_dir)?;
1380        Ok(())
1381    }
1382
1383    #[test]
1384    #[serial_test::serial]
1385    fn test_get_commits_in_range() -> Result<()> {
1386        let temp_dir = setup_test_repo()?;
1387        let repo_path = temp_dir.path();
1388        let original_dir = std::env::current_dir()?;
1389
1390        std::env::set_current_dir(repo_path)?;
1391
1392        // Create additional commits
1393        for i in 1..=5 {
1394            let file_path = repo_path.join(format!("test{}.txt", i));
1395            fs::write(&file_path, format!("content {}", i))?;
1396            Command::new("git").args(["add", "."]).output()?;
1397            Command::new("git")
1398                .args(["commit", "-m", &format!("Commit {}", i)])
1399                .output()?;
1400        }
1401
1402        // Get commits in range
1403        let commits = get_commits_in_range("HEAD~5", "HEAD")?;
1404
1405        assert_eq!(commits.len(), 5);
1406        assert_eq!(commits[0].message, "Commit 1");
1407        assert_eq!(commits[4].message, "Commit 5");
1408
1409        std::env::set_current_dir(original_dir)?;
1410        Ok(())
1411    }
1412
1413    #[test]
1414    #[serial_test::serial]
1415    fn test_get_commits_in_range_invalid_refs() -> Result<()> {
1416        let temp_dir = setup_test_repo()?;
1417        let repo_path = temp_dir.path();
1418        let original_dir = std::env::current_dir()?;
1419
1420        std::env::set_current_dir(repo_path)?;
1421
1422        let result = get_commits_in_range("invalid", "HEAD");
1423        assert!(result.is_err());
1424
1425        std::env::set_current_dir(original_dir)?;
1426        Ok(())
1427    }
1428
1429    #[test]
1430    #[serial_test::serial]
1431    fn test_get_commits_in_range_empty() -> Result<()> {
1432        let temp_dir = setup_test_repo()?;
1433        let repo_path = temp_dir.path();
1434        let original_dir = std::env::current_dir()?;
1435
1436        std::env::set_current_dir(repo_path)?;
1437
1438        // Same ref should return empty
1439        let commits = get_commits_in_range("HEAD", "HEAD")?;
1440        assert_eq!(commits.len(), 0);
1441
1442        std::env::set_current_dir(original_dir)?;
1443        Ok(())
1444    }
1445
1446    #[test]
1447    #[serial_test::serial]
1448    fn test_get_commit_changed_files() -> Result<()> {
1449        let temp_dir = setup_test_repo()?;
1450        let repo_path = temp_dir.path();
1451        let original_dir = std::env::current_dir()?;
1452
1453        std::env::set_current_dir(repo_path)?;
1454
1455        // Create a commit with multiple files
1456        let file1 = repo_path.join("file1.txt");
1457        let file2 = repo_path.join("file2.txt");
1458        fs::write(&file1, "content1")?;
1459        fs::write(&file2, "content2")?;
1460        Command::new("git").args(["add", "."]).output()?;
1461        Command::new("git")
1462            .args(["commit", "-m", "Add files"])
1463            .output()?;
1464
1465        let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1466        let hash = String::from_utf8_lossy(&hash_output.stdout)
1467            .trim()
1468            .to_string();
1469
1470        let files = get_commit_changed_files(&hash)?;
1471        assert_eq!(files.len(), 2);
1472        assert!(files.contains(&"file1.txt".to_string()));
1473        assert!(files.contains(&"file2.txt".to_string()));
1474
1475        std::env::set_current_dir(original_dir)?;
1476        Ok(())
1477    }
1478
1479    #[test]
1480    #[serial_test::serial]
1481    fn test_get_commit_changed_files_invalid_hash() -> Result<()> {
1482        let temp_dir = setup_test_repo()?;
1483        let repo_path = temp_dir.path();
1484        let original_dir = std::env::current_dir()?;
1485
1486        std::env::set_current_dir(repo_path)?;
1487
1488        let result = get_commit_changed_files("invalid_hash");
1489        assert!(result.is_err());
1490
1491        std::env::set_current_dir(original_dir)?;
1492        Ok(())
1493    }
1494
1495    #[test]
1496    #[serial_test::serial]
1497    fn test_get_commit_changed_files_empty() -> Result<()> {
1498        let temp_dir = setup_test_repo()?;
1499        let repo_path = temp_dir.path();
1500        let original_dir = std::env::current_dir()?;
1501
1502        std::env::set_current_dir(repo_path)?;
1503
1504        // Create an empty commit
1505        Command::new("git")
1506            .args(["commit", "--allow-empty", "-m", "Empty commit"])
1507            .output()?;
1508
1509        let hash_output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
1510        let hash = String::from_utf8_lossy(&hash_output.stdout)
1511            .trim()
1512            .to_string();
1513
1514        let files = get_commit_changed_files(&hash)?;
1515        assert_eq!(files.len(), 0);
1516
1517        std::env::set_current_dir(original_dir)?;
1518        Ok(())
1519    }
1520
1521    #[test]
1522    #[serial_test::serial]
1523    fn test_get_recent_commits() -> Result<()> {
1524        let temp_dir = setup_test_repo()?;
1525        let repo_path = temp_dir.path();
1526        let original_dir = std::env::current_dir()?;
1527
1528        std::env::set_current_dir(repo_path)?;
1529
1530        // Create additional commits
1531        for i in 1..=5 {
1532            let file_path = repo_path.join(format!("test{}.txt", i));
1533            fs::write(&file_path, format!("content {}", i))?;
1534            Command::new("git").args(["add", "."]).output()?;
1535            Command::new("git")
1536                .args(["commit", "-m", &format!("Recent {}", i)])
1537                .output()?;
1538        }
1539
1540        // Get 3 recent commits
1541        let commits = get_recent_commits(3)?;
1542        assert_eq!(commits.len(), 3);
1543        assert_eq!(commits[0].message, "Recent 5");
1544        assert_eq!(commits[1].message, "Recent 4");
1545        assert_eq!(commits[2].message, "Recent 3");
1546
1547        std::env::set_current_dir(original_dir)?;
1548        Ok(())
1549    }
1550}