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 { output, .. } if output.stderr.contains("cannot lock ref") => {
101 GitError::RefFailedToLock { output }
102 }
103 GitError::ExecError { output, .. } if output.stderr.contains("but expected") => {
104 GitError::RefConcurrentModification { output }
105 }
106 GitError::ExecError { output, .. } if output.stderr.contains("find remote ref") => {
107 GitError::NoRemoteMeasurements { output }
108 }
109 GitError::ExecError { output, .. } if output.stderr.contains("bad object") => {
110 GitError::BadObject { output }
111 }
112 GitError::ExecError { output, .. } if output.stderr.contains("packed-refs.lock") => {
113 GitError::RefFailedToLock { output }
114 }
115 GitError::ExecError { output, .. }
116 if output.stderr.contains("lock") && output.stderr.contains("File exists") =>
117 {
118 GitError::RefFailedToLock { output }
119 }
120 GitError::ExecError { .. }
121 | GitError::RefFailedToPush { .. }
122 | GitError::MissingHead { .. }
123 | GitError::RefFailedToLock { .. }
124 | GitError::ShallowRepository
125 | GitError::MissingMeasurements
126 | GitError::RefConcurrentModification { .. }
127 | GitError::NoRemoteMeasurements { .. }
128 | GitError::NoUpstream {}
129 | GitError::BadObject { .. }
130 | GitError::IoError(_) => err,
131 }
132}
133
134pub(super) fn get_git_perf_remote(remote: &str) -> Option<String> {
135 capture_git_output(&["remote", "get-url", remote], &None)
136 .ok()
137 .map(|s| s.stdout.trim().to_owned())
138}
139
140pub(super) fn set_git_perf_remote(remote: &str, url: &str) -> Result<(), GitError> {
141 capture_git_output(&["remote", "add", remote, url], &None).map(|_| ())
142}
143
144pub(super) fn git_update_ref(commands: impl AsRef<str>) -> Result<(), GitError> {
145 feed_git_command(
146 &[
147 "update-ref",
148 "--no-deref",
150 "--stdin",
151 ],
152 &None,
153 Some(commands.as_ref()),
154 )
155 .map_err(map_git_error)
156 .map(|_| ())
157}
158
159pub fn get_head_revision() -> Result<String> {
160 Ok(internal_get_head_revision()?)
161}
162
163pub(super) fn internal_get_head_revision() -> Result<String, GitError> {
164 git_rev_parse("HEAD")
165}
166
167pub(super) fn git_rev_parse(reference: &str) -> Result<String, GitError> {
168 capture_git_output(&["rev-parse", "--verify", "-q", reference], &None)
169 .map_err(|_e| GitError::MissingHead {
170 reference: reference.into(),
171 })
172 .map(|s| s.stdout.trim().to_owned())
173}
174
175pub fn resolve_committish(committish: &str) -> Result<String> {
199 let resolved = git_rev_parse(committish).map_err(|e| anyhow!(e))?;
200
201 capture_git_output(&["cat-file", "-e", &resolved], &None)
203 .map_err(|e| anyhow!("Commit '{}' does not exist: {}", committish, e))?;
204
205 Ok(resolved)
206}
207
208pub(super) fn git_rev_parse_symbolic_ref(reference: &str) -> Option<String> {
209 capture_git_output(&["symbolic-ref", "-q", reference], &None)
210 .ok()
211 .map(|s| s.stdout.trim().to_owned())
212}
213
214pub(super) fn git_symbolic_ref_create_or_update(
215 reference: &str,
216 target: &str,
217) -> Result<(), GitError> {
218 capture_git_output(&["symbolic-ref", reference, target], &None)
219 .map_err(map_git_error)
220 .map(|_| ())
221}
222
223pub fn is_shallow_repo() -> Result<bool, GitError> {
224 let output = capture_git_output(&["rev-parse", "--is-shallow-repository"], &None)?;
225
226 Ok(output.stdout.starts_with("true"))
227}
228
229pub(super) fn parse_git_version(version: &str) -> Result<(i32, i32, i32)> {
230 let version = version
231 .split_whitespace()
232 .nth(2)
233 .ok_or(anyhow!("Could not find git version in string {version}"))?;
234 match version.split('.').collect_vec()[..] {
235 [major, minor, patch] => Ok((major.parse()?, minor.parse()?, patch.parse()?)),
236 _ => Err(anyhow!("Failed determine semantic version from {version}")),
237 }
238}
239
240fn get_git_version() -> Result<(i32, i32, i32)> {
241 let version = capture_git_output(&["--version"], &None)
242 .context("Determine git version")?
243 .stdout;
244 parse_git_version(&version)
245}
246
247fn concat_version(version_tuple: (i32, i32, i32)) -> String {
248 format!(
249 "{}.{}.{}",
250 version_tuple.0, version_tuple.1, version_tuple.2
251 )
252}
253
254pub fn check_git_version() -> Result<()> {
255 let version_tuple = get_git_version().context("Determining compatible git version")?;
256 if version_tuple < EXPECTED_VERSION {
257 bail!(
258 "Version {} is smaller than {}",
259 concat_version(version_tuple),
260 concat_version(EXPECTED_VERSION)
261 )
262 }
263 Ok(())
264}
265
266pub fn get_repository_root() -> Result<String, String> {
268 let output = capture_git_output(&["rev-parse", "--show-toplevel"], &None)
269 .map_err(|e| format!("Failed to get repository root: {}", e))?;
270 Ok(output.stdout.trim().to_string())
271}
272
273#[cfg(test)]
274mod test {
275 use super::*;
276 use crate::test_helpers::with_isolated_cwd_git;
277
278 #[test]
279 fn test_get_head_revision() {
280 with_isolated_cwd_git(|_git_dir| {
281 let revision = internal_get_head_revision().unwrap();
282 assert!(
283 &revision.chars().all(|c| c.is_ascii_alphanumeric()),
284 "'{}' contained non alphanumeric or non ASCII characters",
285 &revision
286 )
287 });
288 }
289
290 #[test]
291 fn test_parse_git_version() {
292 let version = parse_git_version("git version 2.52.0");
293 assert_eq!(version.unwrap(), (2, 52, 0));
294
295 let version = parse_git_version("git version 2.52.0\n");
296 assert_eq!(version.unwrap(), (2, 52, 0));
297 }
298
299 #[test]
300 fn test_map_git_error_ref_failed_to_lock() {
301 let output = GitOutput {
302 stdout: String::new(),
303 stderr: "fatal: cannot lock ref 'refs/heads/main': Unable to create lock".to_string(),
304 };
305 let error = GitError::ExecError {
306 command: "update-ref".to_string(),
307 output,
308 };
309
310 let mapped = map_git_error(error);
311 assert!(matches!(mapped, GitError::RefFailedToLock { .. }));
312 }
313
314 #[test]
315 fn test_map_git_error_ref_concurrent_modification() {
316 let output = GitOutput {
317 stdout: String::new(),
318 stderr: "fatal: ref updates forbidden, but expected commit abc123".to_string(),
319 };
320 let error = GitError::ExecError {
321 command: "update-ref".to_string(),
322 output,
323 };
324
325 let mapped = map_git_error(error);
326 assert!(matches!(mapped, GitError::RefConcurrentModification { .. }));
327 }
328
329 #[test]
330 fn test_map_git_error_no_remote_measurements() {
331 let output = GitOutput {
332 stdout: String::new(),
333 stderr: "fatal: couldn't find remote ref refs/notes/measurements".to_string(),
334 };
335 let error = GitError::ExecError {
336 command: "fetch".to_string(),
337 output,
338 };
339
340 let mapped = map_git_error(error);
341 assert!(matches!(mapped, GitError::NoRemoteMeasurements { .. }));
342 }
343
344 #[test]
345 fn test_map_git_error_bad_object() {
346 let output = GitOutput {
347 stdout: String::new(),
348 stderr: "error: bad object abc123def456".to_string(),
349 };
350 let error = GitError::ExecError {
351 command: "cat-file".to_string(),
352 output,
353 };
354
355 let mapped = map_git_error(error);
356 assert!(matches!(mapped, GitError::BadObject { .. }));
357 }
358
359 #[test]
360 fn test_map_git_error_unmapped() {
361 let output = GitOutput {
362 stdout: String::new(),
363 stderr: "fatal: some other error".to_string(),
364 };
365 let error = GitError::ExecError {
366 command: "status".to_string(),
367 output,
368 };
369
370 let mapped = map_git_error(error);
371 assert!(matches!(mapped, GitError::ExecError { .. }));
373 }
374
375 #[test]
376 fn test_map_git_error_false_positive_avoidance() {
377 let output = GitOutput {
379 stdout: String::new(),
380 stderr: "this message mentions 'lock' without the full pattern".to_string(),
381 };
382 let error = GitError::ExecError {
383 command: "test".to_string(),
384 output,
385 };
386
387 let mapped = map_git_error(error);
388 assert!(matches!(mapped, GitError::ExecError { .. }));
390 }
391
392 #[test]
393 fn test_map_git_error_cannot_lock_ref_pattern_must_match() {
394 let test_cases = vec![
396 ("fatal: cannot lock ref 'refs/heads/main'", true),
397 ("error: cannot lock ref update", true),
398 ("fatal: failed to lock something", false),
399 ("error: lock failed", false),
400 ];
401
402 for (stderr_msg, should_map) in test_cases {
403 let output = GitOutput {
404 stdout: String::new(),
405 stderr: stderr_msg.to_string(),
406 };
407 let error = GitError::ExecError {
408 command: "test".to_string(),
409 output,
410 };
411
412 let mapped = map_git_error(error);
413 if should_map {
414 assert!(
415 matches!(mapped, GitError::RefFailedToLock { .. }),
416 "Expected RefFailedToLock for: {}",
417 stderr_msg
418 );
419 } else {
420 assert!(
421 matches!(mapped, GitError::ExecError { .. }),
422 "Expected ExecError for: {}",
423 stderr_msg
424 );
425 }
426 }
427 }
428
429 #[test]
430 fn test_map_git_error_but_expected_pattern_must_match() {
431 let test_cases = vec![
433 ("fatal: but expected commit abc123", true),
434 ("error: ref update failed but expected something", true),
435 ("fatal: expected something", false),
436 ("error: only mentioned the word but", false),
437 ];
438
439 for (stderr_msg, should_map) in test_cases {
440 let output = GitOutput {
441 stdout: String::new(),
442 stderr: stderr_msg.to_string(),
443 };
444 let error = GitError::ExecError {
445 command: "test".to_string(),
446 output,
447 };
448
449 let mapped = map_git_error(error);
450 if should_map {
451 assert!(
452 matches!(mapped, GitError::RefConcurrentModification { .. }),
453 "Expected RefConcurrentModification for: {}",
454 stderr_msg
455 );
456 } else {
457 assert!(
458 matches!(mapped, GitError::ExecError { .. }),
459 "Expected ExecError for: {}",
460 stderr_msg
461 );
462 }
463 }
464 }
465
466 #[test]
467 fn test_map_git_error_packed_refs_lock_maps_to_ref_failed_to_lock() {
468 let output = GitOutput {
469 stdout: String::new(),
470 stderr: "fatal: commit: Unable to create '/tmp/test/.git/packed-refs.lock': File exists.\nAnother git process seems to be running in this repository".to_string(),
471 };
472 let error = GitError::ExecError {
473 command: "update-ref".to_string(),
474 output,
475 };
476 let mapped = map_git_error(error);
477 assert!(matches!(mapped, GitError::RefFailedToLock { .. }));
478 }
479
480 #[test]
481 fn test_map_git_error_lock_file_exists_catch_all() {
482 let output = GitOutput {
483 stdout: String::new(),
484 stderr: "error: Unable to create '/path/to/some.lock': File exists.".to_string(),
485 };
486 let error = GitError::ExecError {
487 command: "update-ref".to_string(),
488 output,
489 };
490 let mapped = map_git_error(error);
491 assert!(matches!(mapped, GitError::RefFailedToLock { .. }));
492 }
493
494 #[test]
495 fn test_map_git_error_lock_without_file_exists_does_not_match() {
496 let output = GitOutput {
498 stdout: String::new(),
499 stderr: "fatal: failed to lock the index".to_string(),
500 };
501 let error = GitError::ExecError {
502 command: "add".to_string(),
503 output,
504 };
505 let mapped = map_git_error(error);
506 assert!(matches!(mapped, GitError::ExecError { .. }));
507 }
508
509 #[test]
510 fn test_map_git_error_packed_refs_lock_without_file_exists_also_maps() {
511 let output = GitOutput {
513 stdout: String::new(),
514 stderr: "error: packed-refs.lock is held by another process".to_string(),
515 };
516 let error = GitError::ExecError {
517 command: "update-ref".to_string(),
518 output,
519 };
520 let mapped = map_git_error(error);
521 assert!(matches!(mapped, GitError::RefFailedToLock { .. }));
522 }
523}