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("Empty branch name".to_string()));
81 }
82
83 Ok(branch)
84}
85
86pub fn is_working_tree_clean<P: AsRef<Path>>(repo_path: P) -> Result<bool, GitError> {
88 let output = run_git_command(
89 repo_path.as_ref(),
90 &["status", "--porcelain"],
91 DEFAULT_GIT_TIMEOUT_SECS,
92 )?;
93
94 Ok(output.trim().is_empty())
95}
96
97pub fn get_head_sha<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
99 let output = run_git_command(
100 repo_path.as_ref(),
101 &["rev-parse", "HEAD"],
102 DEFAULT_GIT_TIMEOUT_SECS,
103 )?;
104
105 let sha = output.trim().to_string();
106 if sha.len() < 7 {
107 return Err(GitError::InvalidOutput("Invalid SHA format".to_string()));
108 }
109
110 Ok(sha)
111}
112
113pub fn get_head_sha_short<P: AsRef<Path>>(repo_path: P) -> Result<String, GitError> {
115 let sha = get_head_sha(repo_path)?;
116 Ok(sha.chars().take(7).collect())
117}
118
119pub fn get_ahead_count<P: AsRef<Path>>(repo_path: P) -> Result<u32, GitError> {
121 let output = run_git_command(
122 repo_path.as_ref(),
123 &["rev-list", "--count", "@{upstream}..HEAD"],
124 DEFAULT_GIT_TIMEOUT_SECS,
125 )?;
126
127 let count: u32 = output.trim().parse().unwrap_or(0);
128 Ok(count)
129}
130
131pub fn is_ancestor<P: AsRef<Path>>(
133 repo_path: P,
134 ancestor: &str,
135 descendant: &str,
136) -> Result<bool, GitError> {
137 let output = run_git_command(
138 repo_path.as_ref(),
139 &["merge-base", "--is-ancestor", ancestor, descendant],
140 DEFAULT_GIT_TIMEOUT_SECS,
141 );
142
143 match output {
144 Ok(_) => Ok(true),
145 Err(GitError::CommandFailed(_)) => Ok(false),
146 Err(e) => Err(e),
147 }
148}
149
150#[derive(Debug, Clone, Default)]
152pub struct GitStatus {
153 pub modified: u32,
154 pub added: u32,
155 pub deleted: u32,
156 pub untracked: u32,
157}
158
159pub fn get_git_status<P: AsRef<Path>>(repo_path: P) -> Result<GitStatus, GitError> {
161 let output = run_git_command(
162 repo_path.as_ref(),
163 &["status", "--porcelain"],
164 DEFAULT_GIT_TIMEOUT_SECS,
165 )?;
166
167 let mut status = GitStatus::default();
168
169 for line in output.lines() {
170 if line.len() < 2 {
171 continue;
172 }
173
174 let code = &line[..2];
175 match code {
176 " M" | "M " | "MM" => status.modified += 1,
177 "A " | "AM" => status.added += 1,
178 " D" | "D " => status.deleted += 1,
179 "??" => status.untracked += 1,
180 _ => {}
181 }
182 }
183
184 Ok(status)
185}
186
187fn run_git_command(repo_path: &Path, args: &[&str], timeout_secs: u64) -> Result<String, GitError> {
189 let mut cmd = Command::new("git");
190 cmd.args(["-C", repo_path.to_str().unwrap_or(".")])
191 .args(args)
192 .stdout(Stdio::piped())
193 .stderr(Stdio::piped());
194
195 let mut child = cmd.spawn()?;
196 let timeout = Duration::from_secs(timeout_secs);
197 let status = match child.wait_timeout(timeout)? {
198 Some(status) => status,
199 None => {
200 let _ = child.kill();
201 let _ = child.wait();
202 return Err(GitError::Timeout);
203 }
204 };
205 let output = child.wait_with_output()?;
206
207 if status.success() {
208 String::from_utf8(output.stdout).map_err(|e| GitError::InvalidOutput(e.to_string()))
209 } else {
210 let stderr = String::from_utf8_lossy(&output.stderr);
211 Err(GitError::CommandFailed(stderr.to_string()))
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_git_status_parsing() {
221 let status = GitStatus {
224 modified: 1,
225 added: 2,
226 deleted: 0,
227 untracked: 3,
228 };
229 assert_eq!(status.modified, 1);
230 assert_eq!(status.added, 2);
231 }
232}