git_adr/core/
git.rs

1//! Low-level git operations.
2//!
3//! This module provides a wrapper around git subprocess calls,
4//! handling command execution, error parsing, and output processing.
5
6use std::path::{Path, PathBuf};
7use std::process::{Command, Output};
8
9use crate::Error;
10
11/// Git subprocess wrapper.
12#[derive(Debug, Clone)]
13pub struct Git {
14    /// Working directory for git commands.
15    work_dir: PathBuf,
16    /// Path to git executable.
17    git_path: PathBuf,
18}
19
20impl Default for Git {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl Git {
27    /// Create a new Git instance using the current directory.
28    #[must_use]
29    pub fn new() -> Self {
30        Self {
31            work_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
32            git_path: PathBuf::from("git"),
33        }
34    }
35
36    /// Create a new Git instance with a specific working directory.
37    #[must_use]
38    pub fn with_work_dir<P: AsRef<Path>>(path: P) -> Self {
39        Self {
40            work_dir: path.as_ref().to_path_buf(),
41            git_path: PathBuf::from("git"),
42        }
43    }
44
45    /// Get the working directory.
46    #[must_use]
47    pub fn work_dir(&self) -> &Path {
48        &self.work_dir
49    }
50
51    /// Check if we're in a git repository.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if git is not found or we're not in a repository.
56    pub fn check_repository(&self) -> Result<(), Error> {
57        let output = self.run(&["rev-parse", "--git-dir"])?;
58        if !output.status.success() {
59            return Err(Error::NotARepository {
60                path: Some(self.work_dir.display().to_string()),
61            });
62        }
63        Ok(())
64    }
65
66    /// Get the repository root directory.
67    ///
68    /// # Errors
69    ///
70    /// Returns an error if not in a git repository.
71    pub fn repo_root(&self) -> Result<PathBuf, Error> {
72        self.check_repository()?;
73        let output = self.run_output(&["rev-parse", "--show-toplevel"])?;
74        Ok(PathBuf::from(output.trim()))
75    }
76
77    /// Run a git command and return the raw output.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the command fails to execute.
82    pub fn run(&self, args: &[&str]) -> Result<Output, Error> {
83        Command::new(&self.git_path)
84            .current_dir(&self.work_dir)
85            .args(args)
86            .output()
87            .map_err(|e| {
88                if e.kind() == std::io::ErrorKind::NotFound {
89                    Error::GitNotFound
90                } else {
91                    Error::Git {
92                        message: e.to_string(),
93                        command: args.iter().map(|s| (*s).to_string()).collect(),
94                        exit_code: -1,
95                        stderr: String::new(),
96                    }
97                }
98            })
99    }
100
101    /// Run a git command and return stdout as a string.
102    ///
103    /// # Errors
104    ///
105    /// Returns an error if the command fails or returns non-zero exit code.
106    pub fn run_output(&self, args: &[&str]) -> Result<String, Error> {
107        let output = self.run(args)?;
108
109        if !output.status.success() {
110            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
111            return Err(Error::Git {
112                message: format!("git command failed: git {}", args.join(" ")),
113                command: args.iter().map(|s| (*s).to_string()).collect(),
114                exit_code: output.status.code().unwrap_or(-1),
115                stderr,
116            });
117        }
118
119        Ok(String::from_utf8_lossy(&output.stdout).to_string())
120    }
121
122    /// Run a git command silently, only checking for success.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the command fails.
127    pub fn run_silent(&self, args: &[&str]) -> Result<(), Error> {
128        let output = self.run(args)?;
129
130        if !output.status.success() {
131            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
132            return Err(Error::Git {
133                message: format!("git command failed: git {}", args.join(" ")),
134                command: args.iter().map(|s| (*s).to_string()).collect(),
135                exit_code: output.status.code().unwrap_or(-1),
136                stderr,
137            });
138        }
139
140        Ok(())
141    }
142
143    /// Get the current HEAD commit hash.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if HEAD cannot be resolved.
148    pub fn head(&self) -> Result<String, Error> {
149        let output = self.run_output(&["rev-parse", "HEAD"])?;
150        Ok(output.trim().to_string())
151    }
152
153    /// Get a short commit hash.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the commit cannot be resolved.
158    pub fn short_hash(&self, commit: &str) -> Result<String, Error> {
159        let output = self.run_output(&["rev-parse", "--short", commit])?;
160        Ok(output.trim().to_string())
161    }
162
163    /// Get a git config value.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if the config key doesn't exist.
168    pub fn config_get(&self, key: &str) -> Result<Option<String>, Error> {
169        let output = self.run(&["config", "--get", key])?;
170
171        if output.status.success() {
172            Ok(Some(
173                String::from_utf8_lossy(&output.stdout).trim().to_string(),
174            ))
175        } else {
176            Ok(None)
177        }
178    }
179
180    /// Set a git config value.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the config cannot be set.
185    pub fn config_set(&self, key: &str, value: &str) -> Result<(), Error> {
186        self.run_silent(&["config", key, value])
187    }
188
189    /// Unset a git config value.
190    ///
191    /// If `all` is true, removes all values for multi-valued keys.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the config cannot be unset.
196    pub fn config_unset(&self, key: &str, all: bool) -> Result<(), Error> {
197        let args: Vec<&str> = if all {
198            vec!["config", "--unset-all", key]
199        } else {
200            vec!["config", "--unset", key]
201        };
202
203        // Ignore error if the key doesn't exist (exit code 5)
204        let output = self.run(&args)?;
205        if output.status.success() || output.status.code() == Some(5) {
206            Ok(())
207        } else {
208            Err(Error::Git {
209                message: format!(
210                    "Failed to unset config {key}: {}",
211                    String::from_utf8_lossy(&output.stderr)
212                ),
213                command: args.iter().map(|s| (*s).to_string()).collect(),
214                exit_code: output.status.code().unwrap_or(-1),
215                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
216            })
217        }
218    }
219
220    /// Get notes content for a commit.
221    ///
222    /// # Errors
223    ///
224    /// Returns an error if the notes cannot be read.
225    pub fn notes_show(&self, notes_ref: &str, commit: &str) -> Result<Option<String>, Error> {
226        let output = self.run(&["notes", "--ref", notes_ref, "show", commit])?;
227
228        if output.status.success() {
229            Ok(Some(String::from_utf8_lossy(&output.stdout).to_string()))
230        } else {
231            Ok(None)
232        }
233    }
234
235    /// Add or update notes for a commit.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if the notes cannot be added.
240    pub fn notes_add(&self, notes_ref: &str, commit: &str, content: &str) -> Result<(), Error> {
241        self.run_silent(&[
242            "notes", "--ref", notes_ref, "add", "-f", "-m", content, commit,
243        ])
244    }
245
246    /// Remove notes for a commit.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the notes cannot be removed.
251    pub fn notes_remove(&self, notes_ref: &str, commit: &str) -> Result<(), Error> {
252        self.run_silent(&["notes", "--ref", notes_ref, "remove", commit])
253    }
254
255    /// List all notes in a ref.
256    ///
257    /// # Errors
258    ///
259    /// Returns an error if notes cannot be listed.
260    pub fn notes_list(&self, notes_ref: &str) -> Result<Vec<(String, String)>, Error> {
261        let output = self.run(&["notes", "--ref", notes_ref, "list"])?;
262
263        if !output.status.success() {
264            // No notes ref yet is not an error
265            return Ok(Vec::new());
266        }
267
268        let stdout = String::from_utf8_lossy(&output.stdout);
269        let mut results = Vec::new();
270
271        for line in stdout.lines() {
272            let parts: Vec<&str> = line.split_whitespace().collect();
273            if parts.len() >= 2 {
274                results.push((parts[0].to_string(), parts[1].to_string()));
275            }
276        }
277
278        Ok(results)
279    }
280
281    /// Push notes to a remote.
282    ///
283    /// # Errors
284    ///
285    /// Returns an error if push fails.
286    pub fn notes_push(&self, remote: &str, notes_ref: &str) -> Result<(), Error> {
287        self.run_silent(&[
288            "push",
289            remote,
290            &format!("refs/notes/{notes_ref}:refs/notes/{notes_ref}"),
291        ])
292    }
293
294    /// Fetch notes from a remote.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if fetch fails.
299    pub fn notes_fetch(&self, remote: &str, notes_ref: &str) -> Result<(), Error> {
300        self.run_silent(&[
301            "fetch",
302            remote,
303            &format!("refs/notes/{notes_ref}:refs/notes/{notes_ref}"),
304        ])
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use tempfile::TempDir;
312
313    #[test]
314    fn test_git_new() {
315        let git = Git::new();
316        assert!(git.work_dir().exists() || git.work_dir() == Path::new("."));
317    }
318
319    #[test]
320    fn test_git_default() {
321        let git = Git::default();
322        assert!(git.work_dir().exists() || git.work_dir() == Path::new("."));
323    }
324
325    #[test]
326    fn test_git_with_work_dir() {
327        let temp_dir = TempDir::new().unwrap();
328        let git = Git::with_work_dir(temp_dir.path());
329        assert_eq!(git.work_dir(), temp_dir.path());
330    }
331
332    #[test]
333    fn test_git_work_dir() {
334        let git = Git::new();
335        let _ = git.work_dir();
336    }
337
338    #[test]
339    fn test_check_repository_not_a_repo() {
340        let temp_dir = TempDir::new().unwrap();
341        let git = Git::with_work_dir(temp_dir.path());
342        let result = git.check_repository();
343        assert!(result.is_err());
344    }
345
346    #[test]
347    fn test_run_success() {
348        let git = Git::new();
349        let output = git.run(&["--version"]).expect("git --version should work");
350        assert!(output.status.success());
351    }
352
353    #[test]
354    fn test_run_output_success() {
355        let git = Git::new();
356        let output = git
357            .run_output(&["--version"])
358            .expect("git --version should work");
359        assert!(output.contains("git version"));
360    }
361
362    #[test]
363    fn test_run_output_failure() {
364        let temp_dir = TempDir::new().unwrap();
365        let git = Git::with_work_dir(temp_dir.path());
366        // rev-parse HEAD fails outside a git repo
367        let result = git.run_output(&["rev-parse", "HEAD"]);
368        assert!(result.is_err());
369    }
370
371    #[test]
372    fn test_run_silent_failure() {
373        let temp_dir = TempDir::new().unwrap();
374        let git = Git::with_work_dir(temp_dir.path());
375        // This will fail because we're not in a git repo
376        let result = git.run_silent(&["status"]);
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn test_notes_list_empty() {
382        let temp_dir = TempDir::new().unwrap();
383        let git = Git::with_work_dir(temp_dir.path());
384        // Initialize git repo but no notes
385        Command::new("git")
386            .current_dir(temp_dir.path())
387            .args(["init"])
388            .output()
389            .unwrap();
390        let result = git.notes_list("adr");
391        assert!(result.is_ok());
392        assert!(result.unwrap().is_empty());
393    }
394
395    #[test]
396    fn test_config_get_nonexistent() {
397        let temp_dir = TempDir::new().unwrap();
398        Command::new("git")
399            .current_dir(temp_dir.path())
400            .args(["init"])
401            .output()
402            .unwrap();
403        let git = Git::with_work_dir(temp_dir.path());
404        let result = git.config_get("nonexistent.key");
405        assert!(result.is_ok());
406        assert!(result.unwrap().is_none());
407    }
408
409    #[test]
410    fn test_config_set_and_get() {
411        let temp_dir = TempDir::new().unwrap();
412        Command::new("git")
413            .current_dir(temp_dir.path())
414            .args(["init"])
415            .output()
416            .unwrap();
417        let git = Git::with_work_dir(temp_dir.path());
418        git.config_set("test.key", "test_value").unwrap();
419        let result = git.config_get("test.key").unwrap();
420        assert_eq!(result, Some("test_value".to_string()));
421    }
422
423    #[test]
424    fn test_config_unset() {
425        let temp_dir = TempDir::new().unwrap();
426        Command::new("git")
427            .current_dir(temp_dir.path())
428            .args(["init"])
429            .output()
430            .unwrap();
431        let git = Git::with_work_dir(temp_dir.path());
432        git.config_set("test.key", "value").unwrap();
433        git.config_unset("test.key", false).unwrap();
434        let result = git.config_get("test.key").unwrap();
435        assert!(result.is_none());
436    }
437
438    #[test]
439    fn test_config_unset_nonexistent() {
440        let temp_dir = TempDir::new().unwrap();
441        Command::new("git")
442            .current_dir(temp_dir.path())
443            .args(["init"])
444            .output()
445            .unwrap();
446        let git = Git::with_work_dir(temp_dir.path());
447        // Unsetting a key that doesn't exist should not error (exit code 5)
448        let result = git.config_unset("nonexistent.key", false);
449        assert!(result.is_ok());
450    }
451
452    #[test]
453    fn test_notes_show_nonexistent() {
454        let temp_dir = TempDir::new().unwrap();
455        Command::new("git")
456            .current_dir(temp_dir.path())
457            .args(["init"])
458            .output()
459            .unwrap();
460        // Create initial commit
461        std::fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
462        Command::new("git")
463            .current_dir(temp_dir.path())
464            .args(["add", "."])
465            .output()
466            .unwrap();
467        Command::new("git")
468            .current_dir(temp_dir.path())
469            .args(["config", "user.email", "test@example.com"])
470            .output()
471            .unwrap();
472        Command::new("git")
473            .current_dir(temp_dir.path())
474            .args(["config", "user.name", "Test"])
475            .output()
476            .unwrap();
477        Command::new("git")
478            .current_dir(temp_dir.path())
479            .args(["commit", "-m", "Initial"])
480            .output()
481            .unwrap();
482
483        let git = Git::with_work_dir(temp_dir.path());
484        let result = git.notes_show("adr", "HEAD");
485        assert!(result.is_ok());
486        assert!(result.unwrap().is_none());
487    }
488
489    #[test]
490    fn test_repo_root() {
491        let temp_dir = TempDir::new().unwrap();
492        Command::new("git")
493            .current_dir(temp_dir.path())
494            .args(["init"])
495            .output()
496            .unwrap();
497        let git = Git::with_work_dir(temp_dir.path());
498        let root = git.repo_root();
499        assert!(root.is_ok());
500    }
501
502    #[test]
503    fn test_head_and_short_hash() {
504        let temp_dir = TempDir::new().unwrap();
505        Command::new("git")
506            .current_dir(temp_dir.path())
507            .args(["init"])
508            .output()
509            .unwrap();
510        std::fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
511        Command::new("git")
512            .current_dir(temp_dir.path())
513            .args(["add", "."])
514            .output()
515            .unwrap();
516        Command::new("git")
517            .current_dir(temp_dir.path())
518            .args(["config", "user.email", "test@example.com"])
519            .output()
520            .unwrap();
521        Command::new("git")
522            .current_dir(temp_dir.path())
523            .args(["config", "user.name", "Test"])
524            .output()
525            .unwrap();
526        Command::new("git")
527            .current_dir(temp_dir.path())
528            .args(["commit", "-m", "Initial"])
529            .output()
530            .unwrap();
531
532        let git = Git::with_work_dir(temp_dir.path());
533        let head = git.head().unwrap();
534        assert_eq!(head.len(), 40); // Full SHA
535
536        let short = git.short_hash(&head).unwrap();
537        assert!(short.len() < head.len());
538    }
539}