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(|| match Command::new("git").arg("--version").output() {
37 Ok(_) => true,
38 Err(e) => e.kind() != std::io::ErrorKind::NotFound,
39 })
40}
41
42pub 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
68pub 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
94pub 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 let has_changes = !output.stdout.is_empty();
116
117 Ok(has_changes)
118}
119
120pub 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
142pub 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 assert!(is_git_repo("."));
167
168 assert!(!is_git_repo("/tmp"));
170 }
171
172 #[test]
173 fn test_get_current_branch() {
174 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 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 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}