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