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(
81            "Empty branch name".to_string(),
82        ));
83    }
84
85    Ok(branch)
86}
87
88/// Check if the working tree is clean (no uncommitted changes).
89pub fn is_working_tree_clean<P: AsRef<Path>>(repo_path: P) -> Result<bool, GitError> {
90    let output = run_git_command(
91        repo_path.as_ref(),
92        &["status", "--porcelain"],
93        DEFAULT_GIT_TIMEOUT_SECS,
94    )?;
95
96    Ok(output.trim().is_empty())
97}
98
99/// Get the HEAD commit SHA.
100pub fn get_head_sha<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
101    let output = run_git_command(
102        repo_path.as_ref(),
103        &["rev-parse", "HEAD"],
104        DEFAULT_GIT_TIMEOUT_SECS,
105    )?;
106
107    let sha = output.trim().to_string();
108    if sha.len() < 7 {
109        return Err(GitError::InvalidOutput("Invalid SHA format".to_string()));
110    }
111
112    Ok(sha)
113}
114
115/// Get the short HEAD commit SHA (7 characters).
116pub fn get_head_sha_short<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
117    let sha = get_head_sha(repo_path)?;
118    Ok(sha.chars().take(7).collect())
119}
120
121/// Get the number of commits ahead of upstream.
122pub fn get_ahead_count<P: AsRef<Path>>(repo_path: P) -> Result<u32, GitError> {
123    let output = run_git_command(
124        repo_path.as_ref(),
125        &["rev-list", "--count", "@{upstream}..HEAD"],
126        DEFAULT_GIT_TIMEOUT_SECS,
127    )?;
128
129    let count: u32 = output.trim().parse().unwrap_or(0);
130    Ok(count)
131}
132
133/// Check if one commit is an ancestor of another.
134pub fn is_ancestor<P: AsRef<Path>>(
135    repo_path: P,
136    ancestor: &str,
137    descendant: &str,
138) -> Result<bool, GitError> {
139    let output = run_git_command(
140        repo_path.as_ref(),
141        &["merge-base", "--is-ancestor", ancestor, descendant],
142        DEFAULT_GIT_TIMEOUT_SECS,
143    );
144
145    match output {
146        Ok(_) => Ok(true),
147        Err(GitError::CommandFailed(_)) => Ok(false),
148        Err(e) => Err(e),
149    }
150}
151
152/// Parse git status output to get file counts.
153#[derive(Debug, Clone, Default)]
154pub struct GitStatus {
155    pub modified: u32,
156    pub added: u32,
157    pub deleted: u32,
158    pub untracked: u32,
159}
160
161/// Get git status file counts.
162pub fn get_git_status<P: AsRef<Path>>(repo_path: P) -> Result<GitStatus, GitError> {
163    let output = run_git_command(
164        repo_path.as_ref(),
165        &["status", "--porcelain"],
166        DEFAULT_GIT_TIMEOUT_SECS,
167    )?;
168
169    let mut status = GitStatus::default();
170
171    for line in output.lines() {
172        if line.len() < 2 {
173            continue;
174        }
175
176        let code = &line[..2];
177        match code {
178            " M" | "M " | "MM" => status.modified += 1,
179            "A " | "AM" => status.added += 1,
180            " D" | "D " => status.deleted += 1,
181            "??" => status.untracked += 1,
182            _ => {}
183        }
184    }
185
186    Ok(status)
187}
188
189/// Run a git command with timeout.
190fn run_git_command(
191    repo_path: &Path,
192    args: &[&str],
193    timeout_secs: u64,
194) -> Result<String, GitError> {
195    let mut cmd = Command::new("git");
196    cmd.args(["-C", repo_path.to_str().unwrap_or(".")])
197        .args(args)
198        .stdout(Stdio::piped())
199        .stderr(Stdio::piped());
200
201    let mut child = cmd.spawn()?;
202    let timeout = Duration::from_secs(timeout_secs);
203    let status = match child.wait_timeout(timeout)? {
204        Some(status) => status,
205        None => {
206            let _ = child.kill();
207            let _ = child.wait();
208            return Err(GitError::Timeout);
209        }
210    };
211    let output = child.wait_with_output()?;
212
213    if status.success() {
214        String::from_utf8(output.stdout)
215            .map_err(|e| GitError::InvalidOutput(e.to_string()))
216    } else {
217        let stderr = String::from_utf8_lossy(&output.stderr);
218        Err(GitError::CommandFailed(stderr.to_string()))
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_git_status_parsing() {
228        // This test would require a git repository setup
229        // For now, we just test the struct exists
230        let status = GitStatus {
231            modified: 1,
232            added: 2,
233            deleted: 0,
234            untracked: 3,
235        };
236        assert_eq!(status.modified, 1);
237        assert_eq!(status.added, 2);
238    }
239}