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(|| match Command::new("git").arg("--version").output() {
37        Ok(_) => true,
38        Err(e) => e.kind() != std::io::ErrorKind::NotFound,
39    })
40}
41
42/// Get the current git branch name
43///
44/// Returns the branch name (e.g., "main", "feature-x") or "HEAD" if in detached HEAD state.
45pub fn get_current_branch(root: impl AsRef<Path>) -> Result<String> {
46    let output = Command::new("git")
47        .arg("-C")
48        .arg(root.as_ref())
49        .args(["rev-parse", "--abbrev-ref", "HEAD"])
50        .output()
51        .context("Failed to execute git rev-parse")?;
52
53    if !output.status.success() {
54        anyhow::bail!(
55            "git rev-parse failed: {}",
56            String::from_utf8_lossy(&output.stderr)
57        );
58    }
59
60    let branch = String::from_utf8(output.stdout)
61        .context("Invalid UTF-8 in branch name")?
62        .trim()
63        .to_string();
64
65    Ok(branch)
66}
67
68/// Get the current commit SHA
69///
70/// Returns the full 40-character commit hash for HEAD.
71pub fn get_current_commit(root: impl AsRef<Path>) -> Result<String> {
72    let output = Command::new("git")
73        .arg("-C")
74        .arg(root.as_ref())
75        .args(["rev-parse", "HEAD"])
76        .output()
77        .context("Failed to execute git rev-parse HEAD")?;
78
79    if !output.status.success() {
80        anyhow::bail!(
81            "git rev-parse HEAD failed: {}",
82            String::from_utf8_lossy(&output.stderr)
83        );
84    }
85
86    let commit = String::from_utf8(output.stdout)
87        .context("Invalid UTF-8 in commit SHA")?
88        .trim()
89        .to_string();
90
91    Ok(commit)
92}
93
94/// Check if there are uncommitted changes in the working tree
95///
96/// Returns true if there are any modified, added, or deleted files.
97/// Uses `git status --porcelain` which is designed for scripting.
98pub fn has_uncommitted_changes(root: impl AsRef<Path>) -> Result<bool> {
99    let output = Command::new("git")
100        .arg("-C")
101        .arg(root.as_ref())
102        .args(["status", "--porcelain"])
103        .output()
104        .context("Failed to execute git status")?;
105
106    if !output.status.success() {
107        anyhow::bail!(
108            "git status failed: {}",
109            String::from_utf8_lossy(&output.stderr)
110        );
111    }
112
113    // If output is empty, working tree is clean
114    // If output has any content, there are uncommitted changes
115    let has_changes = !output.stdout.is_empty();
116
117    Ok(has_changes)
118}
119
120/// Get complete git state for the current repository
121///
122/// This is a convenience function that captures branch, commit, and dirty state
123/// in one call, which is more efficient than calling each function separately.
124pub fn get_git_state(root: impl AsRef<Path>) -> Result<GitState> {
125    let root = root.as_ref();
126
127    if !is_git_repo(root) {
128        anyhow::bail!("Not a git repository");
129    }
130
131    let branch = get_current_branch(root)?;
132    let commit = get_current_commit(root)?;
133    let dirty = has_uncommitted_changes(root)?;
134
135    Ok(GitState {
136        branch,
137        commit,
138        dirty,
139    })
140}
141
142/// Get git state, or return None if not in a git repository
143///
144/// This is useful for indexing non-git projects where we fall back to a default branch.
145pub fn get_git_state_optional(root: impl AsRef<Path>) -> Result<Option<GitState>> {
146    if !is_git_repo(&root) {
147        return Ok(None);
148    }
149
150    match get_git_state(root) {
151        Ok(state) => Ok(Some(state)),
152        Err(e) => {
153            log::warn!("Failed to get git state: {}", e);
154            Ok(None)
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_is_git_repo() {
165        // This test project should be a git repo
166        assert!(is_git_repo("."));
167
168        // /tmp should not be a git repo
169        assert!(!is_git_repo("/tmp"));
170    }
171
172    #[test]
173    fn test_get_current_branch() {
174        // Should return a branch name (or HEAD if detached)
175        let branch = get_current_branch(".").unwrap();
176        assert!(!branch.is_empty());
177        log::info!("Current branch: {}", branch);
178    }
179
180    #[test]
181    fn test_get_current_commit() {
182        // Should return a 40-character SHA
183        let commit = get_current_commit(".").unwrap();
184        assert_eq!(commit.len(), 40);
185        assert!(commit.chars().all(|c| c.is_ascii_hexdigit()));
186        log::info!("Current commit: {}", commit);
187    }
188
189    #[test]
190    fn test_has_uncommitted_changes() {
191        // Can't predict if there are changes, but function should not error
192        let has_changes = has_uncommitted_changes(".").unwrap();
193        log::info!("Has uncommitted changes: {}", has_changes);
194    }
195
196    #[test]
197    fn test_get_git_state() {
198        let state = get_git_state(".").unwrap();
199        assert!(!state.branch.is_empty());
200        assert_eq!(state.commit.len(), 40);
201        log::info!("Git state: {:?}", state);
202    }
203}