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}