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