1use super::{
2 git_definitions::EXPECTED_VERSION,
3 git_types::{GitError, GitOutput},
4};
5
6use std::{
7 env::current_dir,
8 io::{self, BufWriter, Write},
9 path::{Path, PathBuf},
10 process::{self, Child, Stdio},
11};
12
13use log::{debug, trace};
14
15use anyhow::{anyhow, bail, Context, Result};
16use itertools::Itertools;
17
18pub(super) fn spawn_git_command(
19 args: &[&str],
20 working_dir: &Option<&Path>,
21 stdin: Option<Stdio>,
22) -> Result<Child, io::Error> {
23 let working_dir = working_dir.map(PathBuf::from).unwrap_or(current_dir()?);
24 let default_pre_args = [
26 "-c",
27 "gc.auto=0",
28 "-c",
29 "maintenance.auto=0",
30 "-c",
31 "fetch.fsckObjects=false",
32 ];
33 let stdin = stdin.unwrap_or(Stdio::null());
34 let all_args: Vec<_> = default_pre_args.iter().chain(args.iter()).collect();
35 debug!("execute: git {}", all_args.iter().join(" "));
36 process::Command::new("git")
37 .env("LANG", "C.UTF-8")
38 .env("LC_ALL", "C.UTF-8")
39 .env("LANGUAGE", "C.UTF-8")
40 .stdin(stdin)
41 .stdout(Stdio::piped())
42 .stderr(Stdio::piped())
43 .current_dir(working_dir)
44 .args(all_args)
45 .spawn()
46}
47
48pub(super) fn capture_git_output(
49 args: &[&str],
50 working_dir: &Option<&Path>,
51) -> Result<GitOutput, GitError> {
52 feed_git_command(args, working_dir, None)
53}
54
55pub(super) fn feed_git_command(
56 args: &[&str],
57 working_dir: &Option<&Path>,
58 input: Option<&str>,
59) -> Result<GitOutput, GitError> {
60 let stdin = input.map(|_| Stdio::piped());
61
62 let child = spawn_git_command(args, working_dir, stdin)?;
63
64 debug!("input: {}", input.unwrap_or(""));
65
66 let output = match child.stdin {
67 Some(ref stdin) => {
68 let mut writer = BufWriter::new(stdin);
69 writer.write_all(input.unwrap().as_bytes())?;
70 drop(writer);
71 child.wait_with_output()
72 }
73 None => child.wait_with_output(),
74 }?;
75
76 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
77 trace!("stdout: {stdout}");
78
79 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
80 trace!("stderr: {stderr}");
81
82 let git_output = GitOutput { stdout, stderr };
83
84 if output.status.success() {
85 trace!("exec succeeded");
86 Ok(git_output)
87 } else {
88 trace!("exec failed");
89 Err(GitError::ExecError {
90 command: args.join(" "),
91 output: git_output,
92 })
93 }
94}
95
96pub(super) fn map_git_error(err: GitError) -> GitError {
97 match err {
100 GitError::ExecError { command: _, output } if output.stderr.contains("cannot lock ref") => {
101 GitError::RefFailedToLock { output }
102 }
103 GitError::ExecError { command: _, output } if output.stderr.contains("but expected") => {
104 GitError::RefConcurrentModification { output }
105 }
106 GitError::ExecError { command: _, output } if output.stderr.contains("find remote ref") => {
107 GitError::NoRemoteMeasurements { output }
108 }
109 GitError::ExecError { command: _, output } if output.stderr.contains("bad object") => {
110 GitError::BadObject { output }
111 }
112 _ => err,
113 }
114}
115
116pub(super) fn get_git_perf_remote(remote: &str) -> Option<String> {
117 capture_git_output(&["remote", "get-url", remote], &None)
118 .ok()
119 .map(|s| s.stdout.trim().to_owned())
120}
121
122pub(super) fn set_git_perf_remote(remote: &str, url: &str) -> Result<(), GitError> {
123 capture_git_output(&["remote", "add", remote, url], &None).map(|_| ())
124}
125
126pub(super) fn git_update_ref(commands: impl AsRef<str>) -> Result<(), GitError> {
127 feed_git_command(
128 &[
129 "update-ref",
130 "--no-deref",
132 "--stdin",
133 ],
134 &None,
135 Some(commands.as_ref()),
136 )
137 .map_err(map_git_error)
138 .map(|_| ())
139}
140
141pub fn get_head_revision() -> Result<String> {
142 Ok(internal_get_head_revision()?)
143}
144
145pub(super) fn internal_get_head_revision() -> Result<String, GitError> {
146 git_rev_parse("HEAD")
147}
148
149pub(super) fn git_rev_parse(reference: &str) -> Result<String, GitError> {
150 capture_git_output(&["rev-parse", "--verify", "-q", reference], &None)
151 .map_err(|_e| GitError::MissingHead {
152 reference: reference.into(),
153 })
154 .map(|s| s.stdout.trim().to_owned())
155}
156
157pub(super) fn git_rev_parse_symbolic_ref(reference: &str) -> Option<String> {
158 capture_git_output(&["symbolic-ref", "-q", reference], &None)
159 .ok()
160 .map(|s| s.stdout.trim().to_owned())
161}
162
163pub(super) fn git_symbolic_ref_create_or_update(
164 reference: &str,
165 target: &str,
166) -> Result<(), GitError> {
167 capture_git_output(&["symbolic-ref", reference, target], &None)
168 .map_err(map_git_error)
169 .map(|_| ())
170}
171
172pub(super) fn is_shallow_repo() -> Result<bool, GitError> {
173 let output = capture_git_output(&["rev-parse", "--is-shallow-repository"], &None)?;
174
175 Ok(output.stdout.starts_with("true"))
176}
177
178pub(super) fn parse_git_version(version: &str) -> Result<(i32, i32, i32)> {
179 let version = version
180 .split_whitespace()
181 .nth(2)
182 .ok_or(anyhow!("Could not find git version in string {version}"))?;
183 match version.split('.').collect_vec()[..] {
184 [major, minor, patch] => Ok((major.parse()?, minor.parse()?, patch.parse()?)),
185 _ => Err(anyhow!("Failed determine semantic version from {version}")),
186 }
187}
188
189fn get_git_version() -> Result<(i32, i32, i32)> {
190 let version = capture_git_output(&["--version"], &None)
191 .context("Determine git version")?
192 .stdout;
193 parse_git_version(&version)
194}
195
196fn concat_version(version_tuple: (i32, i32, i32)) -> String {
197 format!(
198 "{}.{}.{}",
199 version_tuple.0, version_tuple.1, version_tuple.2
200 )
201}
202
203pub fn check_git_version() -> Result<()> {
204 let version_tuple = get_git_version().context("Determining compatible git version")?;
205 if version_tuple < EXPECTED_VERSION {
206 bail!(
207 "Version {} is smaller than {}",
208 concat_version(version_tuple),
209 concat_version(EXPECTED_VERSION)
210 )
211 }
212 Ok(())
213}
214
215pub fn get_repository_root() -> Result<String, String> {
217 let output = capture_git_output(&["rev-parse", "--show-toplevel"], &None)
218 .map_err(|e| format!("Failed to get repository root: {}", e))?;
219 Ok(output.stdout.trim().to_string())
220}
221
222#[cfg(test)]
223mod test {
224 use super::*;
225 use std::env::set_current_dir;
226
227 use tempfile::{tempdir, TempDir};
228
229 fn run_git_command(args: &[&str], dir: &Path) {
230 assert!(process::Command::new("git")
231 .args(args)
232 .envs([
233 ("GIT_CONFIG_NOSYSTEM", "true"),
234 ("GIT_CONFIG_GLOBAL", "/dev/null"),
235 ("GIT_AUTHOR_NAME", "testuser"),
236 ("GIT_AUTHOR_EMAIL", "testuser@example.com"),
237 ("GIT_COMMITTER_NAME", "testuser"),
238 ("GIT_COMMITTER_EMAIL", "testuser@example.com"),
239 ])
240 .current_dir(dir)
241 .stdout(Stdio::null())
242 .stderr(Stdio::null())
243 .status()
244 .expect("Failed to spawn git command")
245 .success());
246 }
247
248 fn init_repo(dir: &Path) {
249 run_git_command(&["init", "--initial-branch", "master"], dir);
250 run_git_command(&["commit", "--allow-empty", "-m", "Initial commit"], dir);
251 }
252
253 fn dir_with_repo() -> TempDir {
254 let tempdir = tempdir().unwrap();
255 init_repo(tempdir.path());
256 tempdir
257 }
258
259 #[test]
260 fn test_get_head_revision() {
261 let repo_dir = dir_with_repo();
262 set_current_dir(repo_dir.path()).expect("Failed to change dir");
263 let revision = internal_get_head_revision().unwrap();
264 assert!(
265 &revision.chars().all(|c| c.is_ascii_alphanumeric()),
266 "'{}' contained non alphanumeric or non ASCII characters",
267 &revision
268 )
269 }
270
271 #[test]
272 fn test_parse_git_version() {
273 let version = parse_git_version("git version 2.52.0");
274 assert_eq!(version.unwrap(), (2, 52, 0));
275
276 let version = parse_git_version("git version 2.52.0\n");
277 assert_eq!(version.unwrap(), (2, 52, 0));
278 }
279
280 #[test]
281 fn test_map_git_error_ref_failed_to_lock() {
282 let output = GitOutput {
283 stdout: String::new(),
284 stderr: "fatal: cannot lock ref 'refs/heads/main': Unable to create lock".to_string(),
285 };
286 let error = GitError::ExecError {
287 command: "update-ref".to_string(),
288 output,
289 };
290
291 let mapped = map_git_error(error);
292 assert!(matches!(mapped, GitError::RefFailedToLock { .. }));
293 }
294
295 #[test]
296 fn test_map_git_error_ref_concurrent_modification() {
297 let output = GitOutput {
298 stdout: String::new(),
299 stderr: "fatal: ref updates forbidden, but expected commit abc123".to_string(),
300 };
301 let error = GitError::ExecError {
302 command: "update-ref".to_string(),
303 output,
304 };
305
306 let mapped = map_git_error(error);
307 assert!(matches!(mapped, GitError::RefConcurrentModification { .. }));
308 }
309
310 #[test]
311 fn test_map_git_error_no_remote_measurements() {
312 let output = GitOutput {
313 stdout: String::new(),
314 stderr: "fatal: couldn't find remote ref refs/notes/measurements".to_string(),
315 };
316 let error = GitError::ExecError {
317 command: "fetch".to_string(),
318 output,
319 };
320
321 let mapped = map_git_error(error);
322 assert!(matches!(mapped, GitError::NoRemoteMeasurements { .. }));
323 }
324
325 #[test]
326 fn test_map_git_error_bad_object() {
327 let output = GitOutput {
328 stdout: String::new(),
329 stderr: "error: bad object abc123def456".to_string(),
330 };
331 let error = GitError::ExecError {
332 command: "cat-file".to_string(),
333 output,
334 };
335
336 let mapped = map_git_error(error);
337 assert!(matches!(mapped, GitError::BadObject { .. }));
338 }
339
340 #[test]
341 fn test_map_git_error_unmapped() {
342 let output = GitOutput {
343 stdout: String::new(),
344 stderr: "fatal: some other error".to_string(),
345 };
346 let error = GitError::ExecError {
347 command: "status".to_string(),
348 output,
349 };
350
351 let mapped = map_git_error(error);
352 assert!(matches!(mapped, GitError::ExecError { .. }));
354 }
355
356 #[test]
357 fn test_map_git_error_false_positive_avoidance() {
358 let output = GitOutput {
360 stdout: String::new(),
361 stderr: "this message mentions 'lock' without the full pattern".to_string(),
362 };
363 let error = GitError::ExecError {
364 command: "test".to_string(),
365 output,
366 };
367
368 let mapped = map_git_error(error);
369 assert!(matches!(mapped, GitError::ExecError { .. }));
371 }
372
373 #[test]
374 fn test_map_git_error_cannot_lock_ref_pattern_must_match() {
375 let test_cases = vec![
377 ("fatal: cannot lock ref 'refs/heads/main'", true),
378 ("error: cannot lock ref update", true),
379 ("fatal: failed to lock something", false),
380 ("error: lock failed", false),
381 ];
382
383 for (stderr_msg, should_map) in test_cases {
384 let output = GitOutput {
385 stdout: String::new(),
386 stderr: stderr_msg.to_string(),
387 };
388 let error = GitError::ExecError {
389 command: "test".to_string(),
390 output,
391 };
392
393 let mapped = map_git_error(error);
394 if should_map {
395 assert!(
396 matches!(mapped, GitError::RefFailedToLock { .. }),
397 "Expected RefFailedToLock for: {}",
398 stderr_msg
399 );
400 } else {
401 assert!(
402 matches!(mapped, GitError::ExecError { .. }),
403 "Expected ExecError for: {}",
404 stderr_msg
405 );
406 }
407 }
408 }
409
410 #[test]
411 fn test_map_git_error_but_expected_pattern_must_match() {
412 let test_cases = vec![
414 ("fatal: but expected commit abc123", true),
415 ("error: ref update failed but expected something", true),
416 ("fatal: expected something", false),
417 ("error: only mentioned the word but", false),
418 ];
419
420 for (stderr_msg, should_map) in test_cases {
421 let output = GitOutput {
422 stdout: String::new(),
423 stderr: stderr_msg.to_string(),
424 };
425 let error = GitError::ExecError {
426 command: "test".to_string(),
427 output,
428 };
429
430 let mapped = map_git_error(error);
431 if should_map {
432 assert!(
433 matches!(mapped, GitError::RefConcurrentModification { .. }),
434 "Expected RefConcurrentModification for: {}",
435 stderr_msg
436 );
437 } else {
438 assert!(
439 matches!(mapped, GitError::ExecError { .. }),
440 "Expected ExecError for: {}",
441 stderr_msg
442 );
443 }
444 }
445 }
446}