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 { command: _, output } if output.stderr.contains("cannot lock ref") => {
101            GitError::RefFailedToLock { output }
102        }
103        GitError::ExecError { command: _, output } if output.stderr.contains("but expected") => {
104            GitError::RefConcurrentModification { output }
105        }
106        GitError::ExecError { command: _, output } if output.stderr.contains("find remote ref") => {
107            GitError::NoRemoteMeasurements { output }
108        }
109        GitError::ExecError { command: _, output } if output.stderr.contains("bad object") => {
110            GitError::BadObject { output }
111        }
112        _ => err,
113    }
114}
115
116pub(super) fn get_git_perf_remote(remote: &str) -> Option<String> {
117    capture_git_output(&["remote", "get-url", remote], &None)
118        .ok()
119        .map(|s| s.stdout.trim().to_owned())
120}
121
122pub(super) fn set_git_perf_remote(remote: &str, url: &str) -> Result<(), GitError> {
123    capture_git_output(&["remote", "add", remote, url], &None).map(|_| ())
124}
125
126pub(super) fn git_update_ref(commands: impl AsRef<str>) -> Result<(), GitError> {
127    feed_git_command(
128        &[
129            "update-ref",
130            // When updating existing symlinks, we want to update the source symlink and not its target
131            "--no-deref",
132            "--stdin",
133        ],
134        &None,
135        Some(commands.as_ref()),
136    )
137    .map_err(map_git_error)
138    .map(|_| ())
139}
140
141pub fn get_head_revision() -> Result<String> {
142    Ok(internal_get_head_revision()?)
143}
144
145pub(super) fn internal_get_head_revision() -> Result<String, GitError> {
146    git_rev_parse("HEAD")
147}
148
149pub(super) fn git_rev_parse(reference: &str) -> Result<String, GitError> {
150    capture_git_output(&["rev-parse", "--verify", "-q", reference], &None)
151        .map_err(|_e| GitError::MissingHead {
152            reference: reference.into(),
153        })
154        .map(|s| s.stdout.trim().to_owned())
155}
156
157pub(super) fn git_rev_parse_symbolic_ref(reference: &str) -> Option<String> {
158    capture_git_output(&["symbolic-ref", "-q", reference], &None)
159        .ok()
160        .map(|s| s.stdout.trim().to_owned())
161}
162
163pub(super) fn git_symbolic_ref_create_or_update(
164    reference: &str,
165    target: &str,
166) -> Result<(), GitError> {
167    capture_git_output(&["symbolic-ref", reference, target], &None)
168        .map_err(map_git_error)
169        .map(|_| ())
170}
171
172pub(super) fn is_shallow_repo() -> Result<bool, GitError> {
173    let output = capture_git_output(&["rev-parse", "--is-shallow-repository"], &None)?;
174
175    Ok(output.stdout.starts_with("true"))
176}
177
178pub(super) fn parse_git_version(version: &str) -> Result<(i32, i32, i32)> {
179    let version = version
180        .split_whitespace()
181        .nth(2)
182        .ok_or(anyhow!("Could not find git version in string {version}"))?;
183    match version.split('.').collect_vec()[..] {
184        [major, minor, patch] => Ok((major.parse()?, minor.parse()?, patch.parse()?)),
185        _ => Err(anyhow!("Failed determine semantic version from {version}")),
186    }
187}
188
189fn get_git_version() -> Result<(i32, i32, i32)> {
190    let version = capture_git_output(&["--version"], &None)
191        .context("Determine git version")?
192        .stdout;
193    parse_git_version(&version)
194}
195
196fn concat_version(version_tuple: (i32, i32, i32)) -> String {
197    format!(
198        "{}.{}.{}",
199        version_tuple.0, version_tuple.1, version_tuple.2
200    )
201}
202
203pub fn check_git_version() -> Result<()> {
204    let version_tuple = get_git_version().context("Determining compatible git version")?;
205    if version_tuple < EXPECTED_VERSION {
206        bail!(
207            "Version {} is smaller than {}",
208            concat_version(version_tuple),
209            concat_version(EXPECTED_VERSION)
210        )
211    }
212    Ok(())
213}
214
215/// Get the repository root directory using git
216pub fn get_repository_root() -> Result<String, String> {
217    let output = capture_git_output(&["rev-parse", "--show-toplevel"], &None)
218        .map_err(|e| format!("Failed to get repository root: {}", e))?;
219    Ok(output.stdout.trim().to_string())
220}
221
222#[cfg(test)]
223mod test {
224    use super::*;
225    use std::env::set_current_dir;
226
227    use tempfile::{tempdir, TempDir};
228
229    fn run_git_command(args: &[&str], dir: &Path) {
230        assert!(process::Command::new("git")
231            .args(args)
232            .envs([
233                ("GIT_CONFIG_NOSYSTEM", "true"),
234                ("GIT_CONFIG_GLOBAL", "/dev/null"),
235                ("GIT_AUTHOR_NAME", "testuser"),
236                ("GIT_AUTHOR_EMAIL", "testuser@example.com"),
237                ("GIT_COMMITTER_NAME", "testuser"),
238                ("GIT_COMMITTER_EMAIL", "testuser@example.com"),
239            ])
240            .current_dir(dir)
241            .stdout(Stdio::null())
242            .stderr(Stdio::null())
243            .status()
244            .expect("Failed to spawn git command")
245            .success());
246    }
247
248    fn init_repo(dir: &Path) {
249        run_git_command(&["init", "--initial-branch", "master"], dir);
250        run_git_command(&["commit", "--allow-empty", "-m", "Initial commit"], dir);
251    }
252
253    fn dir_with_repo() -> TempDir {
254        let tempdir = tempdir().unwrap();
255        init_repo(tempdir.path());
256        tempdir
257    }
258
259    #[test]
260    fn test_get_head_revision() {
261        let repo_dir = dir_with_repo();
262        set_current_dir(repo_dir.path()).expect("Failed to change dir");
263        let revision = internal_get_head_revision().unwrap();
264        assert!(
265            &revision.chars().all(|c| c.is_ascii_alphanumeric()),
266            "'{}' contained non alphanumeric or non ASCII characters",
267            &revision
268        )
269    }
270
271    #[test]
272    fn test_parse_git_version() {
273        let version = parse_git_version("git version 2.52.0");
274        assert_eq!(version.unwrap(), (2, 52, 0));
275
276        let version = parse_git_version("git version 2.52.0\n");
277        assert_eq!(version.unwrap(), (2, 52, 0));
278    }
279
280    #[test]
281    fn test_map_git_error_ref_failed_to_lock() {
282        let output = GitOutput {
283            stdout: String::new(),
284            stderr: "fatal: cannot lock ref 'refs/heads/main': Unable to create lock".to_string(),
285        };
286        let error = GitError::ExecError {
287            command: "update-ref".to_string(),
288            output,
289        };
290
291        let mapped = map_git_error(error);
292        assert!(matches!(mapped, GitError::RefFailedToLock { .. }));
293    }
294
295    #[test]
296    fn test_map_git_error_ref_concurrent_modification() {
297        let output = GitOutput {
298            stdout: String::new(),
299            stderr: "fatal: ref updates forbidden, but expected commit abc123".to_string(),
300        };
301        let error = GitError::ExecError {
302            command: "update-ref".to_string(),
303            output,
304        };
305
306        let mapped = map_git_error(error);
307        assert!(matches!(mapped, GitError::RefConcurrentModification { .. }));
308    }
309
310    #[test]
311    fn test_map_git_error_no_remote_measurements() {
312        let output = GitOutput {
313            stdout: String::new(),
314            stderr: "fatal: couldn't find remote ref refs/notes/measurements".to_string(),
315        };
316        let error = GitError::ExecError {
317            command: "fetch".to_string(),
318            output,
319        };
320
321        let mapped = map_git_error(error);
322        assert!(matches!(mapped, GitError::NoRemoteMeasurements { .. }));
323    }
324
325    #[test]
326    fn test_map_git_error_bad_object() {
327        let output = GitOutput {
328            stdout: String::new(),
329            stderr: "error: bad object abc123def456".to_string(),
330        };
331        let error = GitError::ExecError {
332            command: "cat-file".to_string(),
333            output,
334        };
335
336        let mapped = map_git_error(error);
337        assert!(matches!(mapped, GitError::BadObject { .. }));
338    }
339
340    #[test]
341    fn test_map_git_error_unmapped() {
342        let output = GitOutput {
343            stdout: String::new(),
344            stderr: "fatal: some other error".to_string(),
345        };
346        let error = GitError::ExecError {
347            command: "status".to_string(),
348            output,
349        };
350
351        let mapped = map_git_error(error);
352        // Should remain as ExecError for unrecognized patterns
353        assert!(matches!(mapped, GitError::ExecError { .. }));
354    }
355
356    #[test]
357    fn test_map_git_error_false_positive_avoidance() {
358        // Test that partial matches don't trigger false positives
359        let output = GitOutput {
360            stdout: String::new(),
361            stderr: "this message mentions 'lock' without the full pattern".to_string(),
362        };
363        let error = GitError::ExecError {
364            command: "test".to_string(),
365            output,
366        };
367
368        let mapped = map_git_error(error);
369        // Should NOT be mapped to RefFailedToLock
370        assert!(matches!(mapped, GitError::ExecError { .. }));
371    }
372
373    #[test]
374    fn test_map_git_error_cannot_lock_ref_pattern_must_match() {
375        // Test that "cannot lock ref" must be present (not just "lock")
376        let test_cases = vec![
377            ("fatal: cannot lock ref 'refs/heads/main'", true),
378            ("error: cannot lock ref update", true),
379            ("fatal: failed to lock something", false),
380            ("error: lock failed", false),
381        ];
382
383        for (stderr_msg, should_map) in test_cases {
384            let output = GitOutput {
385                stdout: String::new(),
386                stderr: stderr_msg.to_string(),
387            };
388            let error = GitError::ExecError {
389                command: "test".to_string(),
390                output,
391            };
392
393            let mapped = map_git_error(error);
394            if should_map {
395                assert!(
396                    matches!(mapped, GitError::RefFailedToLock { .. }),
397                    "Expected RefFailedToLock for: {}",
398                    stderr_msg
399                );
400            } else {
401                assert!(
402                    matches!(mapped, GitError::ExecError { .. }),
403                    "Expected ExecError for: {}",
404                    stderr_msg
405                );
406            }
407        }
408    }
409
410    #[test]
411    fn test_map_git_error_but_expected_pattern_must_match() {
412        // Test that "but expected" must be present
413        let test_cases = vec![
414            ("fatal: but expected commit abc123", true),
415            ("error: ref update failed but expected something", true),
416            ("fatal: expected something", false),
417            ("error: only mentioned the word but", false),
418        ];
419
420        for (stderr_msg, should_map) in test_cases {
421            let output = GitOutput {
422                stdout: String::new(),
423                stderr: stderr_msg.to_string(),
424            };
425            let error = GitError::ExecError {
426                command: "test".to_string(),
427                output,
428            };
429
430            let mapped = map_git_error(error);
431            if should_map {
432                assert!(
433                    matches!(mapped, GitError::RefConcurrentModification { .. }),
434                    "Expected RefConcurrentModification for: {}",
435                    stderr_msg
436                );
437            } else {
438                assert!(
439                    matches!(mapped, GitError::ExecError { .. }),
440                    "Expected ExecError for: {}",
441                    stderr_msg
442                );
443            }
444        }
445    }
446}