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