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