Skip to main content

humanize_cli_core/
git.rs

1//! Git operations for Humanize.
2//!
3//! This module provides git command wrappers for branch detection,
4//! commit SHA retrieval, and repository status checks.
5
6use std::path::Path;
7use std::process::{Command, Stdio};
8use std::time::Duration;
9use wait_timeout::ChildExt;
10
11/// Default timeout for git commands (in seconds).
12const DEFAULT_GIT_TIMEOUT_SECS: u64 = 30;
13
14/// Errors that can occur during git operations.
15#[derive(Debug, thiserror::Error)]
16pub enum GitError {
17    #[error("Git command failed: {0}")]
18    CommandFailed(String),
19
20    #[error("Git command timed out")]
21    Timeout,
22
23    #[error("Not a git repository")]
24    NotAGitRepository,
25
26    #[error("Invalid output from git: {0}")]
27    InvalidOutput(String),
28
29    #[error("IO error: {0}")]
30    IoError(#[from] std::io::Error),
31}
32
33/// Git repository information.
34#[derive(Debug, Clone)]
35pub struct GitInfo {
36    /// Current branch name.
37    pub current_branch: String,
38    /// Whether the working tree is clean.
39    pub is_clean: bool,
40    /// Current HEAD commit SHA.
41    pub head_sha: String,
42    /// Number of commits ahead of upstream.
43    pub ahead_count: u32,
44}
45
46/// Get git repository information.
47pub fn get_git_info<P: AsRef<Path>>(repo_path: P) -> Result<GitInfo, GitError> {
48    let repo_path = repo_path.as_ref();
49
50    // Get current branch
51    let current_branch = get_current_branch(repo_path)?;
52
53    // Check if working tree is clean
54    let is_clean = is_working_tree_clean(repo_path)?;
55
56    // Get HEAD SHA
57    let head_sha = get_head_sha(repo_path)?;
58
59    // Get ahead count
60    let ahead_count = get_ahead_count(repo_path)?;
61
62    Ok(GitInfo {
63        current_branch,
64        is_clean,
65        head_sha,
66        ahead_count,
67    })
68}
69
70/// Get the current branch name.
71pub fn get_current_branch<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
72    let output = run_git_command(
73        repo_path.as_ref(),
74        &["branch", "--show-current"],
75        DEFAULT_GIT_TIMEOUT_SECS,
76    )?;
77
78    let branch = output.trim().to_string();
79    if branch.is_empty() {
80        return Err(GitError::InvalidOutput("Empty branch name".to_string()));
81    }
82
83    Ok(branch)
84}
85
86/// Check if the working tree is clean (no uncommitted changes).
87pub fn is_working_tree_clean<P: AsRef<Path>>(repo_path: P) -> Result<bool, GitError> {
88    let output = run_git_command(
89        repo_path.as_ref(),
90        &["status", "--porcelain"],
91        DEFAULT_GIT_TIMEOUT_SECS,
92    )?;
93
94    Ok(output.trim().is_empty())
95}
96
97/// Get the HEAD commit SHA.
98pub fn get_head_sha<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
99    let output = run_git_command(
100        repo_path.as_ref(),
101        &["rev-parse", "HEAD"],
102        DEFAULT_GIT_TIMEOUT_SECS,
103    )?;
104
105    let sha = output.trim().to_string();
106    if sha.len() < 7 {
107        return Err(GitError::InvalidOutput("Invalid SHA format".to_string()));
108    }
109
110    Ok(sha)
111}
112
113/// Get the short HEAD commit SHA (7 characters).
114pub fn get_head_sha_short<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
115    let sha = get_head_sha(repo_path)?;
116    Ok(sha.chars().take(7).collect())
117}
118
119/// Get the number of commits ahead of upstream.
120pub fn get_ahead_count<P: AsRef<Path>>(repo_path: P) -> Result<u32, GitError> {
121    let output = run_git_command(
122        repo_path.as_ref(),
123        &["rev-list", "--count", "@{upstream}..HEAD"],
124        DEFAULT_GIT_TIMEOUT_SECS,
125    )?;
126
127    let count: u32 = output.trim().parse().unwrap_or(0);
128    Ok(count)
129}
130
131/// Check if one commit is an ancestor of another.
132pub fn is_ancestor<P: AsRef<Path>>(
133    repo_path: P,
134    ancestor: &str,
135    descendant: &str,
136) -> Result<bool, GitError> {
137    let output = run_git_command(
138        repo_path.as_ref(),
139        &["merge-base", "--is-ancestor", ancestor, descendant],
140        DEFAULT_GIT_TIMEOUT_SECS,
141    );
142
143    match output {
144        Ok(_) => Ok(true),
145        Err(GitError::CommandFailed(_)) => Ok(false),
146        Err(e) => Err(e),
147    }
148}
149
150/// Parse git status output to get file counts.
151#[derive(Debug, Clone, Default)]
152pub struct GitStatus {
153    pub modified: u32,
154    pub added: u32,
155    pub deleted: u32,
156    pub untracked: u32,
157}
158
159/// Get git status file counts.
160pub fn get_git_status<P: AsRef<Path>>(repo_path: P) -> Result<GitStatus, GitError> {
161    let output = run_git_command(
162        repo_path.as_ref(),
163        &["status", "--porcelain"],
164        DEFAULT_GIT_TIMEOUT_SECS,
165    )?;
166
167    let mut status = GitStatus::default();
168
169    for line in output.lines() {
170        if line.len() < 2 {
171            continue;
172        }
173
174        let code = &line[..2];
175        match code {
176            " M" | "M " | "MM" => status.modified += 1,
177            "A " | "AM" => status.added += 1,
178            " D" | "D " => status.deleted += 1,
179            "??" => status.untracked += 1,
180            _ => {}
181        }
182    }
183
184    Ok(status)
185}
186
187/// Run a git command with timeout.
188fn run_git_command(repo_path: &Path, args: &[&str], timeout_secs: u64) -> Result<String, GitError> {
189    let mut cmd = Command::new("git");
190    cmd.args(["-C", repo_path.to_str().unwrap_or(".")])
191        .args(args)
192        .stdout(Stdio::piped())
193        .stderr(Stdio::piped());
194
195    let mut child = cmd.spawn()?;
196    let timeout = Duration::from_secs(timeout_secs);
197    let status = match child.wait_timeout(timeout)? {
198        Some(status) => status,
199        None => {
200            let _ = child.kill();
201            let _ = child.wait();
202            return Err(GitError::Timeout);
203        }
204    };
205    let output = child.wait_with_output()?;
206
207    if status.success() {
208        String::from_utf8(output.stdout).map_err(|e| GitError::InvalidOutput(e.to_string()))
209    } else {
210        let stderr = String::from_utf8_lossy(&output.stderr);
211        Err(GitError::CommandFailed(stderr.to_string()))
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn test_git_status_parsing() {
221        // This test would require a git repository setup
222        // For now, we just test the struct exists
223        let status = GitStatus {
224            modified: 1,
225            added: 2,
226            deleted: 0,
227            untracked: 3,
228        };
229        assert_eq!(status.modified, 1);
230        assert_eq!(status.added, 2);
231    }
232}