1use anyhow::{Context, Result};
8use std::path::Path;
9use std::process::Command;
10use std::sync::OnceLock;
11
12#[derive(Debug, Clone)]
14pub struct GitState {
15 pub branch: String,
17 pub commit: String,
19 pub dirty: bool,
21}
22
23pub fn is_git_repo(root: impl AsRef<Path>) -> bool {
25 root.as_ref().join(".git").exists()
26}
27
28pub 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
44pub 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
70pub 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
96pub 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 let has_changes = !output.stdout.is_empty();
118
119 Ok(has_changes)
120}
121
122pub 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
144pub 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 assert!(is_git_repo("."));
169
170 assert!(!is_git_repo("/tmp"));
172 }
173
174 #[test]
175 fn test_get_current_branch() {
176 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 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 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}