Skip to main content

wasm_slim/
git.rs

1//! Git metadata utilities for build tracking
2
3use crate::infra::{CommandExecutor, RealCommandExecutor};
4use thiserror::Error;
5
6/// Git operation errors
7#[derive(Debug, Error)]
8pub enum GitError {
9    /// Git command failed with an error message
10    #[error("Git command failed: {0}")]
11    CommandFailed(String),
12
13    /// The current directory is not a git repository
14    #[error("Not a git repository")]
15    NotARepository,
16
17    /// Git output contained invalid UTF-8
18    #[error("Invalid UTF-8 in git output")]
19    InvalidUtf8,
20
21    /// IO error occurred while executing git command
22    #[error("IO error: {0}")]
23    Io(#[from] std::io::Error),
24}
25
26/// Git repository interface with dependency injection for testability
27pub struct GitRepository<CE: CommandExecutor = RealCommandExecutor> {
28    cmd_executor: CE,
29}
30
31impl GitRepository<RealCommandExecutor> {
32    /// Create a new GitRepository with real command execution
33    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    /// Create a GitRepository with a custom command executor (for testing)
48    pub fn with_executor(cmd_executor: CE) -> Self {
49        Self { cmd_executor }
50    }
51
52    /// Get current git commit hash (short form)
53    ///
54    /// Returns `Ok(Some(hash))` if in a git repository,
55    /// `Ok(None)` if not in a git repository,
56    /// `Err(GitError)` if git command fails unexpectedly.
57    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                // Git command not found
65                return Ok(None);
66            }
67            Err(e) => return Err(GitError::Io(e)),
68        };
69
70        if !output.status.success() {
71            // Check if it's a "not a git repository" error
72            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    /// Get current git branch name
88    ///
89    /// Returns `Ok(Some(branch))` if in a git repository,
90    /// `Ok(None)` if not in a git repository,
91    /// `Err(GitError)` if git command fails unexpectedly.
92    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                // Git command not found
100                return Ok(None);
101            }
102            Err(e) => return Err(GitError::Io(e)),
103        };
104
105        if !output.status.success() {
106            // Check if it's a "not a git repository" error
107            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    // Mock CommandExecutor for testing
130    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                    // This is a bit hacky but works for tests
147                    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    // Integration tests with real git
182    #[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                // Restore original directory
231                if let Some(dir) = original_dir {
232                    let _ = env::set_current_dir(dir);
233                }
234
235                // Should return Ok(None) when not in git repo
236                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        // New repo with no commits may return None or error
307        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}