1use crate::infra::{CommandExecutor, RealCommandExecutor};
4use thiserror::Error;
5
6#[derive(Debug, Error)]
8pub enum GitError {
9 #[error("Git command failed: {0}")]
11 CommandFailed(String),
12
13 #[error("Not a git repository")]
15 NotARepository,
16
17 #[error("Invalid UTF-8 in git output")]
19 InvalidUtf8,
20
21 #[error("IO error: {0}")]
23 Io(#[from] std::io::Error),
24}
25
26pub struct GitRepository<CE: CommandExecutor = RealCommandExecutor> {
28 cmd_executor: CE,
29}
30
31impl GitRepository<RealCommandExecutor> {
32 pub fn new() -> Self {
34 Self {
35 cmd_executor: RealCommandExecutor,
36 }
37 }
38}
39
40impl Default for GitRepository<RealCommandExecutor> {
41 fn default() -> Self {
42 Self::new()
43 }
44}
45
46impl<CE: CommandExecutor> GitRepository<CE> {
47 pub fn with_executor(cmd_executor: CE) -> Self {
49 Self { cmd_executor }
50 }
51
52 pub fn get_commit_hash(&self) -> Result<Option<String>, GitError> {
58 let output = match self
59 .cmd_executor
60 .execute(|cmd| cmd.args(["rev-parse", "--short", "HEAD"]), "git")
61 {
62 Ok(output) => output,
63 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
64 return Ok(None);
66 }
67 Err(e) => return Err(GitError::Io(e)),
68 };
69
70 if !output.status.success() {
71 let stderr = String::from_utf8_lossy(&output.stderr);
73 if stderr.contains("not a git repository") {
74 return Ok(None);
75 }
76 return Err(GitError::CommandFailed(stderr.to_string()));
77 }
78
79 let hash = String::from_utf8(output.stdout)
80 .map_err(|_| GitError::InvalidUtf8)?
81 .trim()
82 .to_string();
83
84 Ok(Some(hash))
85 }
86
87 pub fn get_branch_name(&self) -> Result<Option<String>, GitError> {
93 let output = match self
94 .cmd_executor
95 .execute(|cmd| cmd.args(["rev-parse", "--abbrev-ref", "HEAD"]), "git")
96 {
97 Ok(output) => output,
98 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
99 return Ok(None);
101 }
102 Err(e) => return Err(GitError::Io(e)),
103 };
104
105 if !output.status.success() {
106 let stderr = String::from_utf8_lossy(&output.stderr);
108 if stderr.contains("not a git repository") {
109 return Ok(None);
110 }
111 return Err(GitError::CommandFailed(stderr.to_string()));
112 }
113
114 let branch = String::from_utf8(output.stdout)
115 .map_err(|_| GitError::InvalidUtf8)?
116 .trim()
117 .to_string();
118
119 Ok(Some(branch))
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::infra::CommandExecutor;
127 use std::process::{Command, ExitStatus, Output};
128
129 struct MockCommandExecutor {
131 stdout: Vec<u8>,
132 stderr: Vec<u8>,
133 success: bool,
134 }
135
136 impl CommandExecutor for MockCommandExecutor {
137 fn status(&self, _cmd: &mut Command) -> std::io::Result<ExitStatus> {
138 unimplemented!()
139 }
140
141 fn output(&self, _cmd: &mut Command) -> std::io::Result<Output> {
142 Ok(Output {
143 status: if self.success {
144 ExitStatus::default()
145 } else {
146 ExitStatus::default()
148 },
149 stdout: self.stdout.clone(),
150 stderr: self.stderr.clone(),
151 })
152 }
153 }
154
155 #[test]
156 fn test_get_commit_hash_success() {
157 let mock = MockCommandExecutor {
158 stdout: b"abc1234\n".to_vec(),
159 stderr: vec![],
160 success: true,
161 };
162 let repo = GitRepository::with_executor(mock);
163
164 let result = repo.get_commit_hash().unwrap();
165 assert_eq!(result, Some("abc1234".to_string()));
166 }
167
168 #[test]
169 fn test_get_branch_name_success() {
170 let mock = MockCommandExecutor {
171 stdout: b"main\n".to_vec(),
172 stderr: vec![],
173 success: true,
174 };
175 let repo = GitRepository::with_executor(mock);
176
177 let result = repo.get_branch_name().unwrap();
178 assert_eq!(result, Some("main".to_string()));
179 }
180
181 #[test]
183 fn test_get_commit_hash_returns_option() {
184 let repo = GitRepository::new();
185 let _ = repo.get_commit_hash();
186 }
187
188 #[test]
189 fn test_get_branch_name_returns_option() {
190 let repo = GitRepository::new();
191 let _ = repo.get_branch_name();
192 }
193
194 #[test]
195 fn test_get_commit_hash_handles_detached_head() {
196 let repo = GitRepository::new();
197 if let Ok(Some(hash)) = repo.get_commit_hash() {
198 assert!(!hash.is_empty(), "Commit hash should not be empty");
199 assert!(
200 hash.len() >= 7 && hash.len() <= 40,
201 "Hash should be 7-40 chars"
202 );
203 assert!(
204 hash.chars().all(|c| c.is_ascii_hexdigit()),
205 "Hash should be hex"
206 );
207 }
208 }
209
210 #[test]
211 fn test_get_branch_name_detached_head_returns_head() {
212 let repo = GitRepository::new();
213 if let Ok(Some(branch)) = repo.get_branch_name() {
214 assert!(!branch.is_empty(), "Branch name should not be empty");
215 }
216 }
217
218 #[test]
219 fn test_git_functions_outside_repository() {
220 use std::env;
221
222 let original_dir = env::current_dir().ok();
223
224 if let Ok(temp_dir) = tempfile::tempdir() {
225 if env::set_current_dir(temp_dir.path()).is_ok() {
226 let repo = GitRepository::new();
227 let hash = repo.get_commit_hash();
228 let branch = repo.get_branch_name();
229
230 if let Some(dir) = original_dir {
232 let _ = env::set_current_dir(dir);
233 }
234
235 assert!(hash.is_ok());
237 assert!(branch.is_ok());
238 }
239 }
240 }
241
242 #[test]
243 fn test_get_commit_hash_format_validation() {
244 let repo = GitRepository::new();
245 if let Ok(Some(hash)) = repo.get_commit_hash() {
246 assert!(hash.len() >= 7, "Hash too short: {}", hash.len());
247 assert!(hash.len() <= 40, "Hash too long: {}", hash.len());
248 assert!(
249 hash.chars().all(|c| c.is_ascii_hexdigit()),
250 "Hash contains non-hex characters: {}",
251 hash
252 );
253 assert!(
254 !hash.contains(char::is_whitespace),
255 "Hash contains whitespace"
256 );
257 }
258 }
259
260 #[test]
261 fn test_get_branch_name_format_validation() {
262 let repo = GitRepository::new();
263 if let Ok(Some(branch)) = repo.get_branch_name() {
264 assert!(!branch.is_empty(), "Branch name is empty");
265 assert!(
266 !branch.contains(char::is_whitespace),
267 "Branch name contains whitespace: '{}'",
268 branch
269 );
270 assert_eq!(branch, branch.trim(), "Branch name not trimmed");
271 }
272 }
273
274 #[test]
275 fn test_get_commit_hash_consistency() {
276 let repo = GitRepository::new();
277 let hash1 = repo.get_commit_hash();
278 let hash2 = repo.get_commit_hash();
279
280 if let (Ok(Some(h1)), Ok(Some(h2))) = (&hash1, &hash2) {
281 assert_eq!(h1, h2, "Hash changed between calls");
282 }
283 }
284
285 #[test]
286 fn test_get_branch_name_consistency() {
287 let repo = GitRepository::new();
288 let branch1 = repo.get_branch_name();
289 let branch2 = repo.get_branch_name();
290
291 if let (Ok(Some(b1)), Ok(Some(b2))) = (&branch1, &branch2) {
292 assert_eq!(b1, b2, "Branch changed between calls");
293 }
294 }
295
296 #[test]
297 fn test_get_branch_name_in_new_repo_no_commits() {
298 let mock_exec = MockCommandExecutor {
299 stdout: vec![],
300 stderr: b"fatal: ref HEAD is not a symbolic ref".to_vec(),
301 success: false,
302 };
303 let repo = GitRepository::with_executor(mock_exec);
304
305 let result = repo.get_branch_name();
306 assert!(result.is_ok() || result.is_err());
308 }
309
310 #[test]
311 fn test_get_commit_hash_with_short_hash() {
312 let mock_exec = MockCommandExecutor {
313 stdout: b"abc123\n".to_vec(),
314 stderr: vec![],
315 success: true,
316 };
317 let repo = GitRepository::with_executor(mock_exec);
318
319 let hash = repo.get_commit_hash().unwrap();
320 assert_eq!(hash, Some("abc123".to_string()));
321 }
322
323 #[test]
324 fn test_get_branch_name_with_special_characters() {
325 let mock_exec = MockCommandExecutor {
326 stdout: b"feature/issue-123\n".to_vec(),
327 stderr: vec![],
328 success: true,
329 };
330 let repo = GitRepository::with_executor(mock_exec);
331
332 let branch = repo.get_branch_name().unwrap();
333 assert_eq!(branch, Some("feature/issue-123".to_string()));
334 }
335
336 #[test]
337 fn test_get_commit_hash_with_full_hash() {
338 let mock_exec = MockCommandExecutor {
339 stdout: b"a1b2c3d4e5f6\n".to_vec(),
340 stderr: vec![],
341 success: true,
342 };
343 let repo = GitRepository::with_executor(mock_exec);
344
345 let hash = repo.get_commit_hash().unwrap();
346 assert_eq!(hash, Some("a1b2c3d4e5f6".to_string()));
347 }
348}