Skip to main content

autom8/
git.rs

1use crate::error::{Autom8Error, Result};
2use std::process::Command;
3
4/// Check if current directory is a git repository
5pub fn is_git_repo() -> bool {
6    Command::new("git")
7        .args(["rev-parse", "--git-dir"])
8        .output()
9        .map(|o| o.status.success())
10        .unwrap_or(false)
11}
12
13/// Get the current branch name
14pub fn current_branch() -> Result<String> {
15    let output = Command::new("git")
16        .args(["rev-parse", "--abbrev-ref", "HEAD"])
17        .output()?;
18
19    if !output.status.success() {
20        return Err(Autom8Error::GitError(
21            String::from_utf8_lossy(&output.stderr).to_string(),
22        ));
23    }
24
25    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
26}
27
28/// Check if a branch exists (locally or remotely)
29pub fn branch_exists(branch: &str) -> Result<bool> {
30    // Check local branches
31    let local = Command::new("git")
32        .args([
33            "show-ref",
34            "--verify",
35            "--quiet",
36            &format!("refs/heads/{}", branch),
37        ])
38        .output()?;
39
40    if local.status.success() {
41        return Ok(true);
42    }
43
44    // Check remote branches
45    let remote = Command::new("git")
46        .args([
47            "show-ref",
48            "--verify",
49            "--quiet",
50            &format!("refs/remotes/origin/{}", branch),
51        ])
52        .output()?;
53
54    Ok(remote.status.success())
55}
56
57/// Create and checkout a new branch, or checkout existing branch
58pub fn ensure_branch(branch: &str) -> Result<()> {
59    let current = current_branch()?;
60
61    if current == branch {
62        return Ok(());
63    }
64
65    if branch_exists(branch)? {
66        // Branch exists, checkout
67        checkout(branch)?;
68    } else {
69        // Create new branch
70        create_and_checkout(branch)?;
71    }
72
73    Ok(())
74}
75
76/// Checkout an existing branch
77pub fn checkout(branch: &str) -> Result<()> {
78    let output = Command::new("git").args(["checkout", branch]).output()?;
79
80    if !output.status.success() {
81        return Err(Autom8Error::GitError(format!(
82            "Failed to checkout branch '{}': {}",
83            branch,
84            String::from_utf8_lossy(&output.stderr)
85        )));
86    }
87
88    Ok(())
89}
90
91/// Create and checkout a new branch
92fn create_and_checkout(branch: &str) -> Result<()> {
93    let output = Command::new("git")
94        .args(["checkout", "-b", branch])
95        .output()?;
96
97    if !output.status.success() {
98        return Err(Autom8Error::GitError(format!(
99            "Failed to create branch '{}': {}",
100            branch,
101            String::from_utf8_lossy(&output.stderr)
102        )));
103    }
104
105    Ok(())
106}
107
108/// Check if working directory is clean (no uncommitted changes)
109pub fn is_clean() -> Result<bool> {
110    let output = Command::new("git")
111        .args(["status", "--porcelain"])
112        .output()?;
113
114    if !output.status.success() {
115        return Err(Autom8Error::GitError(
116            String::from_utf8_lossy(&output.stderr).to_string(),
117        ));
118    }
119
120    Ok(output.stdout.is_empty())
121}
122
123/// Get the short hash of the latest commit (HEAD)
124pub fn latest_commit_short() -> Result<String> {
125    let output = Command::new("git")
126        .args(["rev-parse", "--short", "HEAD"])
127        .output()?;
128
129    if !output.status.success() {
130        return Err(Autom8Error::GitError(
131            String::from_utf8_lossy(&output.stderr).to_string(),
132        ));
133    }
134
135    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
136}
137
138// ============================================================================
139// US-002: Git Diff Capture Functions
140// ============================================================================
141
142/// Status of a file in a git diff.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum DiffStatus {
145    /// File was newly created
146    Added,
147    /// File was modified
148    Modified,
149    /// File was deleted
150    Deleted,
151}
152
153/// A single entry from a git diff operation.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct DiffEntry {
156    /// Path to the changed file
157    pub path: std::path::PathBuf,
158    /// Number of lines added
159    pub additions: u32,
160    /// Number of lines deleted
161    pub deletions: u32,
162    /// The type of change (Added, Modified, Deleted)
163    pub status: DiffStatus,
164}
165
166impl DiffEntry {
167    /// Parse a single line of `git diff --numstat` output.
168    ///
169    /// Format: "additions\tdeletions\tfilepath"
170    /// Binary files show "-" for additions/deletions.
171    ///
172    /// Returns None if the line cannot be parsed.
173    pub fn from_numstat_line(line: &str) -> Option<Self> {
174        let parts: Vec<&str> = line.split('\t').collect();
175        if parts.len() != 3 {
176            return None;
177        }
178
179        let additions = parts[0].parse().unwrap_or(0);
180        let deletions = parts[1].parse().unwrap_or(0);
181        let path = std::path::PathBuf::from(parts[2]);
182
183        // Determine status based on additions/deletions
184        // This is a heuristic - for truly accurate status we'd need --name-status
185        let status = if deletions == 0 && additions > 0 {
186            // Could be new file or modification - we'll refine this with --name-status
187            DiffStatus::Modified
188        } else if additions == 0 && deletions > 0 {
189            // Could be deleted or just lines removed - we'll refine this
190            DiffStatus::Modified
191        } else {
192            DiffStatus::Modified
193        };
194
195        Some(DiffEntry {
196            path,
197            additions,
198            deletions,
199            status,
200        })
201    }
202
203    /// Parse a line from `git diff --name-status` output.
204    ///
205    /// Format: "status\tfilepath" where status is A, M, D, R, C, etc.
206    ///
207    /// Returns the path and status if parseable.
208    fn parse_name_status_line(line: &str) -> Option<(std::path::PathBuf, DiffStatus)> {
209        let parts: Vec<&str> = line.split('\t').collect();
210        if parts.is_empty() {
211            return None;
212        }
213
214        let status_char = parts[0].chars().next()?;
215        let status = match status_char {
216            'A' => DiffStatus::Added,
217            'D' => DiffStatus::Deleted,
218            'M' | 'R' | 'C' | 'T' => DiffStatus::Modified,
219            _ => DiffStatus::Modified,
220        };
221
222        // For rename/copy, the path is in parts[2], otherwise parts[1]
223        let path = if status_char == 'R' || status_char == 'C' {
224            parts.get(2).map(|p| std::path::PathBuf::from(*p))?
225        } else {
226            parts.get(1).map(|p| std::path::PathBuf::from(*p))?
227        };
228
229        Some((path, status))
230    }
231}
232
233/// Get the full commit hash of HEAD.
234///
235/// # Returns
236/// * `Ok(String)` - The full 40-character commit hash
237/// * `Err` - If the git command fails (e.g., not in a git repo)
238pub fn get_head_commit() -> Result<String> {
239    let output = Command::new("git").args(["rev-parse", "HEAD"]).output()?;
240
241    if !output.status.success() {
242        return Err(Autom8Error::GitError(
243            String::from_utf8_lossy(&output.stderr).trim().to_string(),
244        ));
245    }
246
247    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
248}
249
250/// Get file changes since a specific commit.
251///
252/// Uses `git diff --numstat` combined with `--name-status` to get accurate
253/// file change information including additions, deletions, and change type.
254///
255/// # Arguments
256/// * `base_commit` - The commit hash to compare against (e.g., "abc1234" or "HEAD~5")
257///
258/// # Returns
259/// * `Ok(Vec<DiffEntry>)` - List of file changes (empty if no changes or not a git repo)
260/// * `Err` - Only on IO errors, not on git command failures
261pub fn get_diff_since(base_commit: &str) -> Result<Vec<DiffEntry>> {
262    // First, check if we're in a git repo
263    if !is_git_repo() {
264        return Ok(Vec::new());
265    }
266
267    // Get numstat for additions/deletions
268    let numstat_output = Command::new("git")
269        .args(["diff", "--numstat", base_commit])
270        .output()?;
271
272    // Get name-status for accurate status info
273    let name_status_output = Command::new("git")
274        .args(["diff", "--name-status", base_commit])
275        .output()?;
276
277    // If either command fails, return empty (graceful degradation)
278    if !numstat_output.status.success() || !name_status_output.status.success() {
279        return Ok(Vec::new());
280    }
281
282    // Build a map of path -> status from name-status output
283    let name_status_stdout = String::from_utf8_lossy(&name_status_output.stdout);
284    let status_map: std::collections::HashMap<std::path::PathBuf, DiffStatus> = name_status_stdout
285        .lines()
286        .filter_map(DiffEntry::parse_name_status_line)
287        .collect();
288
289    // Parse numstat output and apply accurate status
290    let numstat_stdout = String::from_utf8_lossy(&numstat_output.stdout);
291    let entries: Vec<DiffEntry> = numstat_stdout
292        .lines()
293        .filter(|line| !line.is_empty())
294        .filter_map(|line| {
295            let mut entry = DiffEntry::from_numstat_line(line)?;
296            // Override status with accurate info from name-status
297            if let Some(status) = status_map.get(&entry.path) {
298                entry.status = status.clone();
299            }
300            Some(entry)
301        })
302        .collect();
303
304    Ok(entries)
305}
306
307/// Get uncommitted changes in the working directory.
308///
309/// This includes both staged and unstaged changes. Uses `git diff HEAD --numstat`
310/// to compare the working directory against HEAD.
311///
312/// # Returns
313/// * `Ok(Vec<DiffEntry>)` - List of uncommitted changes (empty if clean or not a git repo)
314/// * `Err` - Only on IO errors
315pub fn get_uncommitted_changes() -> Result<Vec<DiffEntry>> {
316    // First, check if we're in a git repo
317    if !is_git_repo() {
318        return Ok(Vec::new());
319    }
320
321    // Get numstat for additions/deletions (comparing HEAD to working directory)
322    let numstat_output = Command::new("git")
323        .args(["diff", "HEAD", "--numstat"])
324        .output()?;
325
326    // Get name-status for accurate status info
327    let name_status_output = Command::new("git")
328        .args(["diff", "HEAD", "--name-status"])
329        .output()?;
330
331    // If either command fails, return empty (graceful degradation)
332    if !numstat_output.status.success() || !name_status_output.status.success() {
333        return Ok(Vec::new());
334    }
335
336    // Build a map of path -> status from name-status output
337    let name_status_stdout = String::from_utf8_lossy(&name_status_output.stdout);
338    let status_map: std::collections::HashMap<std::path::PathBuf, DiffStatus> = name_status_stdout
339        .lines()
340        .filter_map(DiffEntry::parse_name_status_line)
341        .collect();
342
343    // Parse numstat output and apply accurate status
344    let numstat_stdout = String::from_utf8_lossy(&numstat_output.stdout);
345    let entries: Vec<DiffEntry> = numstat_stdout
346        .lines()
347        .filter(|line| !line.is_empty())
348        .filter_map(|line| {
349            let mut entry = DiffEntry::from_numstat_line(line)?;
350            if let Some(status) = status_map.get(&entry.path) {
351                entry.status = status.clone();
352            }
353            Some(entry)
354        })
355        .collect();
356
357    Ok(entries)
358}
359
360/// Get newly created files since a specific commit.
361///
362/// Returns only files that were added (not modified or deleted).
363///
364/// # Arguments
365/// * `base_commit` - The commit hash to compare against
366///
367/// # Returns
368/// * `Ok(Vec<PathBuf>)` - List of newly created file paths (empty if none or not a git repo)
369/// * `Err` - Only on IO errors
370pub fn get_new_files_since(base_commit: &str) -> Result<Vec<std::path::PathBuf>> {
371    // First, check if we're in a git repo
372    if !is_git_repo() {
373        return Ok(Vec::new());
374    }
375
376    // Get name-status with diff-filter=A (added files only)
377    let output = Command::new("git")
378        .args(["diff", "--name-only", "--diff-filter=A", base_commit])
379        .output()?;
380
381    // If command fails, return empty (graceful degradation)
382    if !output.status.success() {
383        return Ok(Vec::new());
384    }
385
386    let stdout = String::from_utf8_lossy(&output.stdout);
387    let files: Vec<std::path::PathBuf> = stdout
388        .lines()
389        .filter(|line| !line.is_empty())
390        .map(std::path::PathBuf::from)
391        .collect();
392
393    Ok(files)
394}
395
396// ============================================================================
397// US-003: Branch Commit Gathering
398// ============================================================================
399
400/// A single git commit
401#[derive(Debug, Clone, PartialEq)]
402pub struct CommitInfo {
403    /// Short commit hash (7 characters)
404    pub short_hash: String,
405    /// Full commit hash
406    pub full_hash: String,
407    /// Commit message (first line only)
408    pub message: String,
409    /// Author name
410    pub author: String,
411    /// Commit date in ISO format
412    pub date: String,
413}
414
415/// Get commits specific to the current branch (excluding merge commits).
416///
417/// Uses `git log` with `--no-merges` to exclude merge commits.
418/// Compares against the main branch (main or master) to get only
419/// commits specific to this branch.
420///
421/// # Arguments
422/// * `base_branch` - The base branch to compare against (e.g., "main" or "master")
423///
424/// # Returns
425/// * `Ok(Vec<CommitInfo>)` - List of commits on this branch (newest first)
426/// * `Err` - If the git command fails
427pub fn get_branch_commits(base_branch: &str) -> Result<Vec<CommitInfo>> {
428    // Get commits that are on HEAD but not on base_branch, excluding merges
429    // Format: hash|short_hash|message|author|date
430    let output = Command::new("git")
431        .args([
432            "log",
433            &format!("{}..HEAD", base_branch),
434            "--no-merges",
435            "--pretty=format:%H|%h|%s|%an|%ai",
436        ])
437        .output()?;
438
439    if !output.status.success() {
440        let stderr = String::from_utf8_lossy(&output.stderr);
441        return Err(Autom8Error::GitError(format!(
442            "Failed to get branch commits: {}",
443            stderr.trim()
444        )));
445    }
446
447    let stdout = String::from_utf8_lossy(&output.stdout);
448    let commits: Vec<CommitInfo> = stdout
449        .lines()
450        .filter(|line| !line.is_empty())
451        .filter_map(|line| {
452            let parts: Vec<&str> = line.splitn(5, '|').collect();
453            if parts.len() >= 5 {
454                Some(CommitInfo {
455                    full_hash: parts[0].to_string(),
456                    short_hash: parts[1].to_string(),
457                    message: parts[2].to_string(),
458                    author: parts[3].to_string(),
459                    date: parts[4].to_string(),
460                })
461            } else {
462                None
463            }
464        })
465        .collect();
466
467    Ok(commits)
468}
469
470/// Detect the default base branch for the repository (main or master).
471///
472/// Checks for existence of common default branch names.
473///
474/// # Returns
475/// * `Ok(String)` - The detected base branch name ("main" or "master")
476/// * `Err` - If neither branch exists
477pub fn detect_base_branch() -> Result<String> {
478    // Check if 'main' exists
479    if branch_exists("main")? {
480        return Ok("main".to_string());
481    }
482
483    // Check if 'master' exists
484    if branch_exists("master")? {
485        return Ok("master".to_string());
486    }
487
488    // Neither exists - try to get from remote
489    let output = Command::new("git")
490        .args(["remote", "show", "origin"])
491        .output();
492
493    if let Ok(out) = output {
494        if out.status.success() {
495            let stdout = String::from_utf8_lossy(&out.stdout);
496            // Look for "HEAD branch:" line
497            for line in stdout.lines() {
498                if line.contains("HEAD branch:") {
499                    if let Some(branch) = line.split(':').nth(1) {
500                        return Ok(branch.trim().to_string());
501                    }
502                }
503            }
504        }
505    }
506
507    // Default to "main" if we can't detect
508    Ok("main".to_string())
509}
510
511/// Get commits specific to the current branch, auto-detecting the base branch.
512///
513/// Convenience function that combines `detect_base_branch` and `get_branch_commits`.
514///
515/// # Returns
516/// * `Ok(Vec<CommitInfo>)` - List of commits on this branch (newest first)
517/// * `Err` - If the git command fails
518pub fn get_current_branch_commits() -> Result<Vec<CommitInfo>> {
519    let base_branch = detect_base_branch()?;
520    get_branch_commits(&base_branch)
521}
522
523/// Get the diff for a specific commit.
524///
525/// # Arguments
526/// * `commit_hash` - The commit hash to get the diff for
527///
528/// # Returns
529/// * `Ok(String)` - The diff output
530/// * `Err` - If the git command fails
531pub fn get_commit_diff(commit_hash: &str) -> Result<String> {
532    let output = Command::new("git")
533        .args(["show", "--format=", commit_hash])
534        .output()?;
535
536    if !output.status.success() {
537        let stderr = String::from_utf8_lossy(&output.stderr);
538        return Err(Autom8Error::GitError(format!(
539            "Failed to get commit diff: {}",
540            stderr.trim()
541        )));
542    }
543
544    Ok(String::from_utf8_lossy(&output.stdout).to_string())
545}
546
547/// Result type for push operations
548#[derive(Debug, Clone, PartialEq)]
549pub enum PushResult {
550    /// Push succeeded
551    Success,
552    /// Branch already up-to-date on remote
553    AlreadyUpToDate,
554    /// Push failed with error message
555    Error(String),
556}
557
558/// Result type for commit operations
559#[derive(Debug, Clone, PartialEq)]
560pub enum CommitResult {
561    /// Commit succeeded with commit hash
562    Success(String),
563    /// No changes to commit
564    NothingToCommit,
565    /// Commit failed with error message
566    Error(String),
567}
568
569// ============================================================================
570// US-006: Commit and Push for PR Review Fixes
571// ============================================================================
572
573/// Check if there are uncommitted changes (staged or unstaged)
574///
575/// Returns true if the working directory has changes that could be committed.
576pub fn has_uncommitted_changes() -> Result<bool> {
577    let output = Command::new("git")
578        .args(["status", "--porcelain"])
579        .output()?;
580
581    if !output.status.success() {
582        return Err(Autom8Error::GitError(
583            String::from_utf8_lossy(&output.stderr).to_string(),
584        ));
585    }
586
587    // If there's any output, there are changes
588    Ok(!output.stdout.is_empty())
589}
590
591/// Stage all changes (including new files, modifications, and deletions)
592///
593/// Uses `git add -A` to stage all changes in the working directory.
594pub fn stage_all_changes() -> Result<()> {
595    let output = Command::new("git").args(["add", "-A"]).output()?;
596
597    if !output.status.success() {
598        return Err(Autom8Error::GitError(format!(
599            "Failed to stage changes: {}",
600            String::from_utf8_lossy(&output.stderr)
601        )));
602    }
603
604    Ok(())
605}
606
607/// Create a git commit with the given message
608///
609/// # Arguments
610/// * `message` - The commit message
611///
612/// # Returns
613/// * `CommitResult::Success(hash)` - Commit created with short hash
614/// * `CommitResult::NothingToCommit` - No changes to commit
615/// * `CommitResult::Error(msg)` - Commit failed
616pub fn create_commit(message: &str) -> Result<CommitResult> {
617    let output = Command::new("git")
618        .args(["commit", "-m", message])
619        .output()?;
620
621    let stderr = String::from_utf8_lossy(&output.stderr);
622    let stdout = String::from_utf8_lossy(&output.stdout);
623
624    if output.status.success() {
625        // Get the commit hash
626        let hash = latest_commit_short().unwrap_or_else(|_| "unknown".to_string());
627        return Ok(CommitResult::Success(hash));
628    }
629
630    // Check for "nothing to commit" case
631    let combined = format!("{} {}", stdout, stderr);
632    if combined.to_lowercase().contains("nothing to commit")
633        || combined.to_lowercase().contains("no changes added")
634    {
635        return Ok(CommitResult::NothingToCommit);
636    }
637
638    Ok(CommitResult::Error(stderr.trim().to_string()))
639}
640
641/// Commit and optionally push PR review fixes
642///
643/// This function:
644/// 1. Checks if there are uncommitted changes
645/// 2. Stages all changes
646/// 3. Creates a commit with the given message
647/// 4. Optionally pushes to remote if `push_enabled` is true
648///
649/// # Arguments
650/// * `pr_number` - The PR number for the commit message
651/// * `commit_enabled` - Whether to create a commit (from config)
652/// * `push_enabled` - Whether to push after commit (from config)
653///
654/// # Returns
655/// Tuple of (commit_result, push_result) where push_result is None if push was skipped
656pub fn commit_and_push_pr_fixes(
657    pr_number: u32,
658    commit_enabled: bool,
659    push_enabled: bool,
660) -> Result<(Option<CommitResult>, Option<PushResult>)> {
661    // If commit is disabled, return early
662    if !commit_enabled {
663        return Ok((None, None));
664    }
665
666    // Check for uncommitted changes
667    if !has_uncommitted_changes()? {
668        return Ok((Some(CommitResult::NothingToCommit), None));
669    }
670
671    // Stage all changes
672    stage_all_changes()?;
673
674    // Create commit with descriptive message
675    let commit_message = format!(
676        "fix: address PR #{} review feedback\n\nApply fixes based on PR review comments.",
677        pr_number
678    );
679    let commit_result = create_commit(&commit_message)?;
680
681    // Only push if commit was successful and push is enabled
682    let push_result = match (&commit_result, push_enabled) {
683        (CommitResult::Success(_), true) => {
684            let branch = current_branch()?;
685            Some(push_branch(&branch)?)
686        }
687        _ => None,
688    };
689
690    Ok((Some(commit_result), push_result))
691}
692
693/// Push the current branch to origin with upstream tracking
694///
695/// Uses `git push --set-upstream origin <branch>` to push the branch
696/// and set up tracking. If the branch already exists on remote, it will
697/// still push any new commits.
698///
699/// # Arguments
700/// * `branch` - The branch name to push
701///
702/// # Returns
703/// * `PushResult::Success` - Push completed successfully
704/// * `PushResult::AlreadyUpToDate` - Branch is already up-to-date
705/// * `PushResult::Error(msg)` - Push failed with error message
706pub fn push_branch(branch: &str) -> Result<PushResult> {
707    let output = Command::new("git")
708        .args(["push", "--set-upstream", "origin", branch])
709        .output()?;
710
711    let stderr = String::from_utf8_lossy(&output.stderr);
712    let stdout = String::from_utf8_lossy(&output.stdout);
713
714    if output.status.success() {
715        // Check if already up-to-date (git push outputs this to stderr)
716        if stderr.contains("Everything up-to-date") {
717            return Ok(PushResult::AlreadyUpToDate);
718        }
719        return Ok(PushResult::Success);
720    }
721
722    // Handle specific error cases
723    let error_msg = if stderr.is_empty() {
724        stdout.trim().to_string()
725    } else {
726        stderr.trim().to_string()
727    };
728
729    // Check for non-fast-forward (branch exists but diverged)
730    if error_msg.contains("non-fast-forward")
731        || error_msg.contains("rejected")
732        || error_msg.contains("failed to push")
733    {
734        // Try with --force-with-lease for safe force push
735        let force_output = Command::new("git")
736            .args([
737                "push",
738                "--force-with-lease",
739                "--set-upstream",
740                "origin",
741                branch,
742            ])
743            .output()?;
744
745        if force_output.status.success() {
746            return Ok(PushResult::Success);
747        }
748
749        let force_stderr = String::from_utf8_lossy(&force_output.stderr);
750        return Ok(PushResult::Error(format!(
751            "Failed to push branch (even with --force-with-lease): {}",
752            force_stderr.trim()
753        )));
754    }
755
756    Ok(PushResult::Error(error_msg))
757}
758
759// ============================================================================
760// US-001: Merge Base Detection (improve command support)
761// ============================================================================
762
763/// Get the merge-base commit between the current branch and base branch.
764///
765/// The merge-base is the most recent common ancestor between two branches.
766/// This is useful for getting accurate diffs of what changed on a feature branch.
767///
768/// # Arguments
769/// * `base_branch` - The base branch to compare against (e.g., "main" or "master")
770///
771/// # Returns
772/// * `Ok(String)` - The full commit hash of the merge-base
773/// * `Err` - If the git command fails or branches don't share history
774pub fn get_merge_base(base_branch: &str) -> Result<String> {
775    let output = Command::new("git")
776        .args(["merge-base", base_branch, "HEAD"])
777        .output()?;
778
779    if !output.status.success() {
780        let stderr = String::from_utf8_lossy(&output.stderr);
781        return Err(Autom8Error::GitError(format!(
782            "Failed to find merge-base with '{}': {}",
783            base_branch,
784            stderr.trim()
785        )));
786    }
787
788    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
789}
790
791/// Get the merge-base commit, auto-detecting the base branch.
792///
793/// Convenience function that combines `detect_base_branch` and `get_merge_base`.
794///
795/// # Returns
796/// * `Ok(String)` - The full commit hash of the merge-base
797/// * `Err` - If the git command fails
798pub fn get_merge_base_auto() -> Result<String> {
799    let base_branch = detect_base_branch()?;
800    get_merge_base(&base_branch)
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806
807    // ========================================================================
808    // DiffEntry parsing tests - these test actual parsing logic
809    // ========================================================================
810
811    #[test]
812    fn test_diff_entry_from_numstat_line_basic() {
813        let line = "10\t5\tsrc/lib.rs";
814        let entry = DiffEntry::from_numstat_line(line);
815
816        assert!(entry.is_some());
817        let entry = entry.unwrap();
818        assert_eq!(entry.path, std::path::PathBuf::from("src/lib.rs"));
819        assert_eq!(entry.additions, 10);
820        assert_eq!(entry.deletions, 5);
821    }
822
823    #[test]
824    fn test_diff_entry_from_numstat_line_binary_file() {
825        // Binary files show "-" for additions/deletions
826        let line = "-\t-\timage.png";
827        let entry = DiffEntry::from_numstat_line(line).unwrap();
828
829        assert_eq!(entry.path, std::path::PathBuf::from("image.png"));
830        assert_eq!(entry.additions, 0);
831        assert_eq!(entry.deletions, 0);
832    }
833
834    #[test]
835    fn test_diff_entry_from_numstat_line_path_with_spaces() {
836        let line = "5\t3\tpath/to/my file.rs";
837        let entry = DiffEntry::from_numstat_line(line).unwrap();
838
839        assert_eq!(entry.path, std::path::PathBuf::from("path/to/my file.rs"));
840    }
841
842    #[test]
843    fn test_diff_entry_from_numstat_line_invalid() {
844        assert!(DiffEntry::from_numstat_line("10\t5").is_none());
845        assert!(DiffEntry::from_numstat_line("").is_none());
846    }
847
848    #[test]
849    fn test_diff_entry_parse_name_status_variants() {
850        // Added
851        let (path, status) = DiffEntry::parse_name_status_line("A\tsrc/new_file.rs").unwrap();
852        assert_eq!(path, std::path::PathBuf::from("src/new_file.rs"));
853        assert_eq!(status, DiffStatus::Added);
854
855        // Modified
856        let (path, status) = DiffEntry::parse_name_status_line("M\tsrc/changed.rs").unwrap();
857        assert_eq!(path, std::path::PathBuf::from("src/changed.rs"));
858        assert_eq!(status, DiffStatus::Modified);
859
860        // Deleted
861        let (path, status) = DiffEntry::parse_name_status_line("D\tsrc/removed.rs").unwrap();
862        assert_eq!(path, std::path::PathBuf::from("src/removed.rs"));
863        assert_eq!(status, DiffStatus::Deleted);
864
865        // Renamed (returns new path)
866        let (path, status) =
867            DiffEntry::parse_name_status_line("R100\told_name.rs\tnew_name.rs").unwrap();
868        assert_eq!(path, std::path::PathBuf::from("new_name.rs"));
869        assert_eq!(status, DiffStatus::Modified);
870
871        // Invalid
872        assert!(DiffEntry::parse_name_status_line("").is_none());
873    }
874
875    // ========================================================================
876    // Logic tests - test actual behavior without side effects
877    // ========================================================================
878
879    #[test]
880    fn test_commit_and_push_with_commit_disabled_returns_none() {
881        // When commit is disabled, should return (None, None) without doing anything
882        let result = commit_and_push_pr_fixes(123, false, false);
883        assert!(result.is_ok());
884        let (commit_result, push_result) = result.unwrap();
885        assert!(commit_result.is_none());
886        assert!(push_result.is_none());
887    }
888}