Skip to main content

gitgraph_core/
git.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4
5use crate::error::{GitLgError, Result};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct GitOutput {
9    pub stdout: String,
10    pub stderr: String,
11    pub exit_code: Option<i32>,
12}
13
14#[derive(Debug, Clone)]
15pub struct GitRunner {
16    git_binary: String,
17    env: BTreeMap<String, String>,
18}
19
20impl Default for GitRunner {
21    fn default() -> Self {
22        Self::new("git")
23    }
24}
25
26impl GitRunner {
27    pub fn new(git_binary: impl Into<String>) -> Self {
28        Self {
29            git_binary: git_binary.into(),
30            env: BTreeMap::new(),
31        }
32    }
33
34    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
35        self.env.insert(key.into(), value.into());
36        self
37    }
38
39    pub fn git_binary(&self) -> &str {
40        &self.git_binary
41    }
42
43    pub fn validate_repo(&self, repo_path: &Path) -> Result<()> {
44        if !repo_path.exists() || !repo_path.is_dir() {
45            return Err(GitLgError::InvalidRepository(repo_path.to_path_buf()));
46        }
47        let out = self.exec(
48            repo_path,
49            &["rev-parse".to_string(), "--is-inside-work-tree".to_string()],
50            true,
51        )?;
52        if out.stdout.trim() == "true" {
53            return Ok(());
54        }
55        Err(GitLgError::InvalidRepository(repo_path.to_path_buf()))
56    }
57
58    pub fn exec(
59        &self,
60        repo_path: &Path,
61        args: &[String],
62        allow_non_zero: bool,
63    ) -> Result<GitOutput> {
64        let mut cmd = Command::new(&self.git_binary);
65        cmd.current_dir(repo_path)
66            .args(args)
67            .stdin(Stdio::null())
68            .stdout(Stdio::piped())
69            .stderr(Stdio::piped());
70        for (k, v) in &self.env {
71            cmd.env(k, v);
72        }
73
74        let output = cmd
75            .output()
76            .map_err(|source| GitLgError::io("running git command", source))?;
77        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
78        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
79        let result = GitOutput {
80            stdout,
81            stderr,
82            exit_code: output.status.code(),
83        };
84        if output.status.success() || allow_non_zero {
85            return Ok(result);
86        }
87        Err(GitLgError::GitCommandFailed {
88            program: self.git_binary.clone(),
89            args: args.to_vec(),
90            exit_code: result.exit_code,
91            stderr: result.stderr,
92            stdout: result.stdout,
93        })
94    }
95
96    pub fn exec_shell(
97        &self,
98        repo_path: &Path,
99        script: &str,
100        allow_non_zero: bool,
101    ) -> Result<GitOutput> {
102        #[cfg(target_os = "windows")]
103        let (program, args): (&str, Vec<String>) =
104            ("cmd", vec!["/C".to_string(), script.to_string()]);
105        #[cfg(not(target_os = "windows"))]
106        let (program, args): (&str, Vec<String>) =
107            ("sh", vec!["-lc".to_string(), script.to_string()]);
108
109        let mut cmd = Command::new(program);
110        cmd.current_dir(repo_path)
111            .args(&args)
112            .stdin(Stdio::null())
113            .stdout(Stdio::piped())
114            .stderr(Stdio::piped());
115        for (k, v) in &self.env {
116            cmd.env(k, v);
117        }
118
119        let output = cmd
120            .output()
121            .map_err(|source| GitLgError::io("running shell git script", source))?;
122        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
123        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
124        let result = GitOutput {
125            stdout,
126            stderr,
127            exit_code: output.status.code(),
128        };
129        if output.status.success() || allow_non_zero {
130            return Ok(result);
131        }
132        Err(GitLgError::GitCommandFailed {
133            program: program.to_string(),
134            args,
135            exit_code: result.exit_code,
136            stderr: result.stderr,
137            stdout: result.stdout,
138        })
139    }
140
141    pub fn discover_repo_root(&self, start_path: &Path) -> Result<PathBuf> {
142        let out = self.exec(
143            start_path,
144            &["rev-parse".to_string(), "--show-toplevel".to_string()],
145            false,
146        )?;
147        let root = out.stdout.trim();
148        if root.is_empty() {
149            return Err(GitLgError::InvalidRepository(start_path.to_path_buf()));
150        }
151        Ok(PathBuf::from(root))
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use std::fs;
158    use std::path::Path;
159
160    use tempfile::TempDir;
161
162    use super::GitRunner;
163
164    fn has_git() -> bool {
165        std::process::Command::new("git")
166            .arg("--version")
167            .output()
168            .map(|o| o.status.success())
169            .unwrap_or(false)
170    }
171
172    fn init_repo(tmp: &Path) {
173        std::process::Command::new("git")
174            .args(["init"])
175            .current_dir(tmp)
176            .output()
177            .expect("git init must run");
178        std::process::Command::new("git")
179            .args(["config", "user.name", "Test"])
180            .current_dir(tmp)
181            .output()
182            .expect("set user.name");
183        std::process::Command::new("git")
184            .args(["config", "user.email", "test@example.com"])
185            .current_dir(tmp)
186            .output()
187            .expect("set user.email");
188        fs::write(tmp.join("README.md"), "hello\n").expect("write readme");
189        std::process::Command::new("git")
190            .args(["add", "README.md"])
191            .current_dir(tmp)
192            .output()
193            .expect("add");
194        std::process::Command::new("git")
195            .args(["commit", "-m", "init"])
196            .current_dir(tmp)
197            .output()
198            .expect("commit");
199    }
200
201    #[test]
202    fn validates_git_repository() {
203        if !has_git() {
204            return;
205        }
206        let tmp = TempDir::new().expect("tempdir");
207        init_repo(tmp.path());
208
209        let runner = GitRunner::default();
210        runner
211            .validate_repo(tmp.path())
212            .expect("repo should be valid");
213    }
214}