1use std::path::Path;
7use std::process::{Command, Stdio};
8use std::time::Duration;
9use wait_timeout::ChildExt;
10
11const DEFAULT_GIT_TIMEOUT_SECS: u64 = 30;
13
14#[derive(Debug, thiserror::Error)]
16pub enum GitError {
17 #[error("Git command failed: {0}")]
18 CommandFailed(String),
19
20 #[error("Git command timed out")]
21 Timeout,
22
23 #[error("Not a git repository")]
24 NotAGitRepository,
25
26 #[error("Invalid output from git: {0}")]
27 InvalidOutput(String),
28
29 #[error("IO error: {0}")]
30 IoError(#[from] std::io::Error),
31}
32
33#[derive(Debug, Clone)]
35pub struct GitInfo {
36 pub current_branch: String,
38 pub is_clean: bool,
40 pub head_sha: String,
42 pub ahead_count: u32,
44}
45
46pub fn get_git_info<P: AsRef<Path>>(repo_path: P) -> Result<GitInfo, GitError> {
48 let repo_path = repo_path.as_ref();
49
50 let current_branch = get_current_branch(repo_path)?;
52
53 let is_clean = is_working_tree_clean(repo_path)?;
55
56 let head_sha = get_head_sha(repo_path)?;
58
59 let ahead_count = get_ahead_count(repo_path)?;
61
62 Ok(GitInfo {
63 current_branch,
64 is_clean,
65 head_sha,
66 ahead_count,
67 })
68}
69
70pub fn get_current_branch<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
72 let output = run_git_command(
73 repo_path.as_ref(),
74 &["branch", "--show-current"],
75 DEFAULT_GIT_TIMEOUT_SECS,
76 )?;
77
78 let branch = output.trim().to_string();
79 if branch.is_empty() {
80 return Err(GitError::InvalidOutput(
81 "Empty branch name".to_string(),
82 ));
83 }
84
85 Ok(branch)
86}
87
88pub fn is_working_tree_clean<P: AsRef<Path>>(repo_path: P) -> Result<bool, GitError> {
90 let output = run_git_command(
91 repo_path.as_ref(),
92 &["status", "--porcelain"],
93 DEFAULT_GIT_TIMEOUT_SECS,
94 )?;
95
96 Ok(output.trim().is_empty())
97}
98
99pub fn get_head_sha<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
101 let output = run_git_command(
102 repo_path.as_ref(),
103 &["rev-parse", "HEAD"],
104 DEFAULT_GIT_TIMEOUT_SECS,
105 )?;
106
107 let sha = output.trim().to_string();
108 if sha.len() < 7 {
109 return Err(GitError::InvalidOutput("Invalid SHA format".to_string()));
110 }
111
112 Ok(sha)
113}
114
115pub fn get_head_sha_short<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
117 let sha = get_head_sha(repo_path)?;
118 Ok(sha.chars().take(7).collect())
119}
120
121pub fn get_ahead_count<P: AsRef<Path>>(repo_path: P) -> Result<u32, GitError> {
123 let output = run_git_command(
124 repo_path.as_ref(),
125 &["rev-list", "--count", "@{upstream}..HEAD"],
126 DEFAULT_GIT_TIMEOUT_SECS,
127 )?;
128
129 let count: u32 = output.trim().parse().unwrap_or(0);
130 Ok(count)
131}
132
133pub fn is_ancestor<P: AsRef<Path>>(
135 repo_path: P,
136 ancestor: &str,
137 descendant: &str,
138) -> Result<bool, GitError> {
139 let output = run_git_command(
140 repo_path.as_ref(),
141 &["merge-base", "--is-ancestor", ancestor, descendant],
142 DEFAULT_GIT_TIMEOUT_SECS,
143 );
144
145 match output {
146 Ok(_) => Ok(true),
147 Err(GitError::CommandFailed(_)) => Ok(false),
148 Err(e) => Err(e),
149 }
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct GitStatus {
155 pub modified: u32,
156 pub added: u32,
157 pub deleted: u32,
158 pub untracked: u32,
159}
160
161pub fn get_git_status<P: AsRef<Path>>(repo_path: P) -> Result<GitStatus, GitError> {
163 let output = run_git_command(
164 repo_path.as_ref(),
165 &["status", "--porcelain"],
166 DEFAULT_GIT_TIMEOUT_SECS,
167 )?;
168
169 let mut status = GitStatus::default();
170
171 for line in output.lines() {
172 if line.len() < 2 {
173 continue;
174 }
175
176 let code = &line[..2];
177 match code {
178 " M" | "M " | "MM" => status.modified += 1,
179 "A " | "AM" => status.added += 1,
180 " D" | "D " => status.deleted += 1,
181 "??" => status.untracked += 1,
182 _ => {}
183 }
184 }
185
186 Ok(status)
187}
188
189fn run_git_command(
191 repo_path: &Path,
192 args: &[&str],
193 timeout_secs: u64,
194) -> Result<String, GitError> {
195 let mut cmd = Command::new("git");
196 cmd.args(["-C", repo_path.to_str().unwrap_or(".")])
197 .args(args)
198 .stdout(Stdio::piped())
199 .stderr(Stdio::piped());
200
201 let mut child = cmd.spawn()?;
202 let timeout = Duration::from_secs(timeout_secs);
203 let status = match child.wait_timeout(timeout)? {
204 Some(status) => status,
205 None => {
206 let _ = child.kill();
207 let _ = child.wait();
208 return Err(GitError::Timeout);
209 }
210 };
211 let output = child.wait_with_output()?;
212
213 if status.success() {
214 String::from_utf8(output.stdout)
215 .map_err(|e| GitError::InvalidOutput(e.to_string()))
216 } else {
217 let stderr = String::from_utf8_lossy(&output.stderr);
218 Err(GitError::CommandFailed(stderr.to_string()))
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn test_git_status_parsing() {
228 let status = GitStatus {
231 modified: 1,
232 added: 2,
233 deleted: 0,
234 untracked: 3,
235 };
236 assert_eq!(status.modified, 1);
237 assert_eq!(status.added, 2);
238 }
239}