Skip to main content

reflex/
git.rs

1//! Git repository utilities for branch tracking
2//!
3//! This module provides helper functions for interacting with git repositories
4//! to track branch state, detect uncommitted changes, and capture git metadata
5//! for branch-aware indexing.
6
7use anyhow::{Context, Result};
8use std::path::Path;
9use std::process::Command;
10use std::sync::OnceLock;
11
12/// Git repository state
13#[derive(Debug, Clone)]
14pub struct GitState {
15    /// Current branch name (e.g., "main", "feature-x")
16    pub branch: String,
17    /// Current commit SHA (full 40-character hash)
18    pub commit: String,
19    /// Whether there are uncommitted changes (modified/added/deleted files)
20    pub dirty: bool,
21}
22
23/// Check if the current directory is inside a git repository
24pub fn is_git_repo(root: impl AsRef<Path>) -> bool {
25    root.as_ref().join(".git").exists()
26}
27
28/// Check whether the `git` binary is available on PATH.
29///
30/// Probes `git --version` once per process and caches the result.
31/// Returns `false` only when the OS reports the binary doesn't exist
32/// (`io::ErrorKind::NotFound`). A `git` that spawns but exits non-zero
33/// still counts as "available" so legitimate git errors propagate normally.
34pub fn is_git_available() -> bool {
35    static AVAILABLE: OnceLock<bool> = OnceLock::new();
36    *AVAILABLE.get_or_init(|| {
37        match Command::new("git").arg("--version").output() {
38            Ok(_) => true,
39            Err(e) => e.kind() != std::io::ErrorKind::NotFound,
40        }
41    })
42}
43
44/// Get the current git branch name
45///
46/// Returns the branch name (e.g., "main", "feature-x") or "HEAD" if in detached HEAD state.
47pub fn get_current_branch(root: impl AsRef<Path>) -> Result<String> {
48    let output = Command::new("git")
49        .arg("-C")
50        .arg(root.as_ref())
51        .args(["rev-parse", "--abbrev-ref", "HEAD"])
52        .output()
53        .context("Failed to execute git rev-parse")?;
54
55    if !output.status.success() {
56        anyhow::bail!(
57            "git rev-parse failed: {}",
58            String::from_utf8_lossy(&output.stderr)
59        );
60    }
61
62    let branch = String::from_utf8(output.stdout)
63        .context("Invalid UTF-8 in branch name")?
64        .trim()
65        .to_string();
66
67    Ok(branch)
68}
69
70/// Get the current commit SHA
71///
72/// Returns the full 40-character commit hash for HEAD.
73pub fn get_current_commit(root: impl AsRef<Path>) -> Result<String> {
74    let output = Command::new("git")
75        .arg("-C")
76        .arg(root.as_ref())
77        .args(["rev-parse", "HEAD"])
78        .output()
79        .context("Failed to execute git rev-parse HEAD")?;
80
81    if !output.status.success() {
82        anyhow::bail!(
83            "git rev-parse HEAD failed: {}",
84            String::from_utf8_lossy(&output.stderr)
85        );
86    }
87
88    let commit = String::from_utf8(output.stdout)
89        .context("Invalid UTF-8 in commit SHA")?
90        .trim()
91        .to_string();
92
93    Ok(commit)
94}
95
96/// Check if there are uncommitted changes in the working tree
97///
98/// Returns true if there are any modified, added, or deleted files.
99/// Uses `git status --porcelain` which is designed for scripting.
100pub fn has_uncommitted_changes(root: impl AsRef<Path>) -> Result<bool> {
101    let output = Command::new("git")
102        .arg("-C")
103        .arg(root.as_ref())
104        .args(["status", "--porcelain"])
105        .output()
106        .context("Failed to execute git status")?;
107
108    if !output.status.success() {
109        anyhow::bail!(
110            "git status failed: {}",
111            String::from_utf8_lossy(&output.stderr)
112        );
113    }
114
115    // If output is empty, working tree is clean
116    // If output has any content, there are uncommitted changes
117    let has_changes = !output.stdout.is_empty();
118
119    Ok(has_changes)
120}
121
122/// Get complete git state for the current repository
123///
124/// This is a convenience function that captures branch, commit, and dirty state
125/// in one call, which is more efficient than calling each function separately.
126pub fn get_git_state(root: impl AsRef<Path>) -> Result<GitState> {
127    let root = root.as_ref();
128
129    if !is_git_repo(root) {
130        anyhow::bail!("Not a git repository");
131    }
132
133    let branch = get_current_branch(root)?;
134    let commit = get_current_commit(root)?;
135    let dirty = has_uncommitted_changes(root)?;
136
137    Ok(GitState {
138        branch,
139        commit,
140        dirty,
141    })
142}
143
144/// Get git state, or return None if not in a git repository
145///
146/// This is useful for indexing non-git projects where we fall back to a default branch.
147pub fn get_git_state_optional(root: impl AsRef<Path>) -> Result<Option<GitState>> {
148    if !is_git_repo(&root) {
149        return Ok(None);
150    }
151
152    match get_git_state(root) {
153        Ok(state) => Ok(Some(state)),
154        Err(e) => {
155            log::warn!("Failed to get git state: {}", e);
156            Ok(None)
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_is_git_repo() {
167        // This test project should be a git repo
168        assert!(is_git_repo("."));
169
170        // /tmp should not be a git repo
171        assert!(!is_git_repo("/tmp"));
172    }
173
174    #[test]
175    fn test_get_current_branch() {
176        // Should return a branch name (or HEAD if detached)
177        let branch = get_current_branch(".").unwrap();
178        assert!(!branch.is_empty());
179        log::info!("Current branch: {}", branch);
180    }
181
182    #[test]
183    fn test_get_current_commit() {
184        // Should return a 40-character SHA
185        let commit = get_current_commit(".").unwrap();
186        assert_eq!(commit.len(), 40);
187        assert!(commit.chars().all(|c| c.is_ascii_hexdigit()));
188        log::info!("Current commit: {}", commit);
189    }
190
191    #[test]
192    fn test_has_uncommitted_changes() {
193        // Can't predict if there are changes, but function should not error
194        let has_changes = has_uncommitted_changes(".").unwrap();
195        log::info!("Has uncommitted changes: {}", has_changes);
196    }
197
198    #[test]
199    fn test_get_git_state() {
200        let state = get_git_state(".").unwrap();
201        assert!(!state.branch.is_empty());
202        assert_eq!(state.commit.len(), 40);
203        log::info!("Git state: {:?}", state);
204    }
205}