Skip to main content

git_perf/git/
git_lowlevel.rs

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    // Disable Git's automatic maintenance to prevent interference with concurrent operations
25    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    // Parsing error messages is not a very good idea, but(!) there are no consistent + documented error code for these cases.
98    // This is tested by the git compatibility check and we add an explicit LANG to the git invocation.
99    match err {
100        // "cannot lock ref" also subsumes the reftable-backend variant "cannot lock references"
101        // (no ref name) via substring match.
102        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            // When updating existing symlinks, we want to update the source symlink and not its target
154            "--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
180/// Resolves a committish reference to a full SHA-1 hash and verifies the commit exists.
181///
182/// This function takes any valid Git committish (commit hash, branch name, tag, or
183/// relative reference like `HEAD~3`) and resolves it to the full 40-character SHA-1
184/// hash of the underlying commit object. It also validates that the commit object
185/// actually exists in the repository.
186///
187/// # Arguments
188///
189/// * `committish` - A Git committish reference (e.g., "HEAD", "main", "a1b2c3d", "HEAD~3")
190///
191/// # Returns
192///
193/// * `Ok(String)` - The full SHA-1 hash of the resolved commit
194/// * `Err` - If the committish cannot be resolved or the commit does not exist
195///
196/// # Examples
197///
198/// ```no_run
199/// # use git_perf::git::git_interop::resolve_committish;
200/// let sha = resolve_committish("HEAD").unwrap();
201/// assert_eq!(sha.len(), 40); // Full SHA-1 hash
202/// ```
203pub fn resolve_committish(committish: &str) -> Result<String> {
204    let resolved = git_rev_parse(committish).map_err(|e| anyhow!(e))?;
205
206    // Verify the resolved commit actually exists using git cat-file
207    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
271/// Get the repository root directory using git
272pub 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
278/// Get the repository's common git directory (shared across worktrees, and
279/// the repository directory itself for bare repos), as an absolute path.
280pub(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        // Should remain as ExecError for unrecognized patterns
387        assert!(matches!(mapped, GitError::ExecError { .. }));
388    }
389
390    #[test]
391    fn test_map_git_error_false_positive_avoidance() {
392        // Test that partial matches don't trigger false positives
393        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        // Should NOT be mapped to RefFailedToLock
404        assert!(matches!(mapped, GitError::ExecError { .. }));
405    }
406
407    #[test]
408    fn test_map_git_error_cannot_lock_ref_pattern_must_match() {
409        // Test that "cannot lock ref" must be present (not just "lock")
410        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        // Test that "but expected" must be present
447        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        // Regression guard: "lock" alone must not map to RefFailedToLock
512        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        // Verifies the "packed-refs.lock" arm fires independently of "File exists"
527        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        // reftable backend emits "cannot lock references" (no ref name in message).
557        // "cannot lock ref" is a substring of "cannot lock references", so Pattern 1 covers it.
558        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}