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 { .. }
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            // When updating existing symlinks, we want to update the source symlink and not its target
141            "--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
167/// Resolves a committish reference to a full SHA-1 hash and verifies the commit exists.
168///
169/// This function takes any valid Git committish (commit hash, branch name, tag, or
170/// relative reference like `HEAD~3`) and resolves it to the full 40-character SHA-1
171/// hash of the underlying commit object. It also validates that the commit object
172/// actually exists in the repository.
173///
174/// # Arguments
175///
176/// * `committish` - A Git committish reference (e.g., "HEAD", "main", "a1b2c3d", "HEAD~3")
177///
178/// # Returns
179///
180/// * `Ok(String)` - The full SHA-1 hash of the resolved commit
181/// * `Err` - If the committish cannot be resolved or the commit does not exist
182///
183/// # Examples
184///
185/// ```no_run
186/// # use git_perf::git::git_lowlevel::resolve_committish;
187/// let sha = resolve_committish("HEAD").unwrap();
188/// assert_eq!(sha.len(), 40); // Full SHA-1 hash
189/// ```
190pub fn resolve_committish(committish: &str) -> Result<String> {
191    let resolved = git_rev_parse(committish).map_err(|e| anyhow!(e))?;
192
193    // Verify the resolved commit actually exists using git cat-file
194    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
258/// Get the repository root directory using git
259pub 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        // Should remain as ExecError for unrecognized patterns
364        assert!(matches!(mapped, GitError::ExecError { .. }));
365    }
366
367    #[test]
368    fn test_map_git_error_false_positive_avoidance() {
369        // Test that partial matches don't trigger false positives
370        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        // Should NOT be mapped to RefFailedToLock
381        assert!(matches!(mapped, GitError::ExecError { .. }));
382    }
383
384    #[test]
385    fn test_map_git_error_cannot_lock_ref_pattern_must_match() {
386        // Test that "cannot lock ref" must be present (not just "lock")
387        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        // Test that "but expected" must be present
424        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}