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 messasges 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        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            // When updating existing symlinks, we want to update the source symlink and not its target
149            "--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
175/// Resolves a committish reference to a full SHA-1 hash and verifies the commit exists.
176///
177/// This function takes any valid Git committish (commit hash, branch name, tag, or
178/// relative reference like `HEAD~3`) and resolves it to the full 40-character SHA-1
179/// hash of the underlying commit object. It also validates that the commit object
180/// actually exists in the repository.
181///
182/// # Arguments
183///
184/// * `committish` - A Git committish reference (e.g., "HEAD", "main", "a1b2c3d", "HEAD~3")
185///
186/// # Returns
187///
188/// * `Ok(String)` - The full SHA-1 hash of the resolved commit
189/// * `Err` - If the committish cannot be resolved or the commit does not exist
190///
191/// # Examples
192///
193/// ```no_run
194/// # use git_perf::git::git_interop::resolve_committish;
195/// let sha = resolve_committish("HEAD").unwrap();
196/// assert_eq!(sha.len(), 40); // Full SHA-1 hash
197/// ```
198pub fn resolve_committish(committish: &str) -> Result<String> {
199    let resolved = git_rev_parse(committish).map_err(|e| anyhow!(e))?;
200
201    // Verify the resolved commit actually exists using git cat-file
202    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
266/// Get the repository root directory using git
267pub 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        // Should remain as ExecError for unrecognized patterns
372        assert!(matches!(mapped, GitError::ExecError { .. }));
373    }
374
375    #[test]
376    fn test_map_git_error_false_positive_avoidance() {
377        // Test that partial matches don't trigger false positives
378        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        // Should NOT be mapped to RefFailedToLock
389        assert!(matches!(mapped, GitError::ExecError { .. }));
390    }
391
392    #[test]
393    fn test_map_git_error_cannot_lock_ref_pattern_must_match() {
394        // Test that "cannot lock ref" must be present (not just "lock")
395        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        // Test that "but expected" must be present
432        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        // Regression guard: "lock" alone must not map to RefFailedToLock
497        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        // Verifies the "packed-refs.lock" arm fires independently of "File exists"
512        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}