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 = ["-c", "gc.auto=0", "-c", "maintenance.auto=0"];
26    let stdin = stdin.unwrap_or(Stdio::null());
27    let all_args: Vec<_> = default_pre_args.iter().chain(args.iter()).collect();
28    debug!("execute: git {}", all_args.iter().join(" "));
29    process::Command::new("git")
30        .env("LANG", "C.UTF-8")
31        .env("LC_ALL", "C.UTF-8")
32        .env("LANGUAGE", "C.UTF-8")
33        .stdin(stdin)
34        .stdout(Stdio::piped())
35        .stderr(Stdio::piped())
36        .current_dir(working_dir)
37        .args(all_args)
38        .spawn()
39}
40
41pub(super) fn capture_git_output(
42    args: &[&str],
43    working_dir: &Option<&Path>,
44) -> Result<GitOutput, GitError> {
45    feed_git_command(args, working_dir, None)
46}
47
48pub(super) fn feed_git_command(
49    args: &[&str],
50    working_dir: &Option<&Path>,
51    input: Option<&str>,
52) -> Result<GitOutput, GitError> {
53    let stdin = input.map(|_| Stdio::piped());
54
55    let child = spawn_git_command(args, working_dir, stdin)?;
56
57    debug!("input: {}", input.unwrap_or(""));
58
59    let output = match child.stdin {
60        Some(ref stdin) => {
61            let mut writer = BufWriter::new(stdin);
62            writer.write_all(input.unwrap().as_bytes())?;
63            drop(writer);
64            child.wait_with_output()
65        }
66        None => child.wait_with_output(),
67    }?;
68
69    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
70    trace!("stdout: {stdout}");
71
72    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
73    trace!("stderr: {stderr}");
74
75    let git_output = GitOutput { stdout, stderr };
76
77    if output.status.success() {
78        trace!("exec succeeded");
79        Ok(git_output)
80    } else {
81        trace!("exec failed");
82        Err(GitError::ExecError {
83            command: args.join(" "),
84            output: git_output,
85        })
86    }
87}
88
89pub(super) fn map_git_error(err: GitError) -> GitError {
90    // Parsing error messasges is not a very good idea, but(!) there are no consistent + documented error code for these cases.
91    // This is tested by the git compatibility check and we add an explicit LANG to the git invocation.
92    match err {
93        GitError::ExecError { command: _, output } if output.stderr.contains("cannot lock ref") => {
94            GitError::RefFailedToLock { output }
95        }
96        GitError::ExecError { command: _, output } if output.stderr.contains("but expected") => {
97            GitError::RefConcurrentModification { output }
98        }
99        GitError::ExecError { command: _, output } if output.stderr.contains("find remote ref") => {
100            GitError::NoRemoteMeasurements { output }
101        }
102        _ => err,
103    }
104}
105
106pub(super) fn get_git_perf_remote(remote: &str) -> Option<String> {
107    capture_git_output(&["remote", "get-url", remote], &None)
108        .ok()
109        .map(|s| s.stdout.trim().to_owned())
110}
111
112pub(super) fn set_git_perf_remote(remote: &str, url: &str) -> Result<(), GitError> {
113    capture_git_output(&["remote", "add", remote, url], &None).map(|_| ())
114}
115
116pub(super) fn git_update_ref(commands: impl AsRef<str>) -> Result<(), GitError> {
117    feed_git_command(
118        &[
119            "update-ref",
120            // When updating existing symlinks, we want to update the source symlink and not its target
121            "--no-deref",
122            "--stdin",
123        ],
124        &None,
125        Some(commands.as_ref()),
126    )
127    .map_err(map_git_error)
128    .map(|_| ())
129}
130
131pub fn get_head_revision() -> Result<String> {
132    Ok(internal_get_head_revision()?)
133}
134
135pub(super) fn internal_get_head_revision() -> Result<String, GitError> {
136    git_rev_parse("HEAD")
137}
138
139pub(super) fn git_rev_parse(reference: &str) -> Result<String, GitError> {
140    capture_git_output(&["rev-parse", "--verify", "-q", reference], &None)
141        .map_err(|_e| GitError::MissingHead {
142            reference: reference.into(),
143        })
144        .map(|s| s.stdout.trim().to_owned())
145}
146
147pub(super) fn git_rev_parse_symbolic_ref(reference: &str) -> Option<String> {
148    capture_git_output(&["symbolic-ref", "-q", reference], &None)
149        .ok()
150        .map(|s| s.stdout.trim().to_owned())
151}
152
153pub(super) fn is_shallow_repo() -> Result<bool, GitError> {
154    let output = capture_git_output(&["rev-parse", "--is-shallow-repository"], &None)?;
155
156    Ok(output.stdout.starts_with("true"))
157}
158
159pub(super) fn parse_git_version(version: &str) -> Result<(i32, i32, i32)> {
160    let version = version
161        .split_whitespace()
162        .nth(2)
163        .ok_or(anyhow!("Could not find git version in string {version}"))?;
164    match version.split('.').collect_vec()[..] {
165        [major, minor, patch] => Ok((major.parse()?, minor.parse()?, patch.parse()?)),
166        _ => Err(anyhow!("Failed determine semantic version from {version}")),
167    }
168}
169
170fn get_git_version() -> Result<(i32, i32, i32)> {
171    let version = capture_git_output(&["--version"], &None)
172        .context("Determine git version")?
173        .stdout;
174    parse_git_version(&version)
175}
176
177fn concat_version(version_tuple: (i32, i32, i32)) -> String {
178    format!(
179        "{}.{}.{}",
180        version_tuple.0, version_tuple.1, version_tuple.2
181    )
182}
183
184pub fn check_git_version() -> Result<()> {
185    let version_tuple = get_git_version().context("Determining compatible git version")?;
186    if version_tuple < EXPECTED_VERSION {
187        bail!(
188            "Version {} is smaller than {}",
189            concat_version(version_tuple),
190            concat_version(EXPECTED_VERSION)
191        )
192    }
193    Ok(())
194}
195
196#[cfg(test)]
197mod test {
198    use super::*;
199    use std::env::set_current_dir;
200
201    use serial_test::serial;
202    use tempfile::{tempdir, TempDir};
203
204    fn run_git_command(args: &[&str], dir: &Path) {
205        assert!(process::Command::new("git")
206            .args(args)
207            .envs([
208                ("GIT_CONFIG_NOSYSTEM", "true"),
209                ("GIT_CONFIG_GLOBAL", "/dev/null"),
210                ("GIT_AUTHOR_NAME", "testuser"),
211                ("GIT_AUTHOR_EMAIL", "testuser@example.com"),
212                ("GIT_COMMITTER_NAME", "testuser"),
213                ("GIT_COMMITTER_EMAIL", "testuser@example.com"),
214            ])
215            .current_dir(dir)
216            .stdout(Stdio::null())
217            .stderr(Stdio::null())
218            .status()
219            .expect("Failed to spawn git command")
220            .success());
221    }
222
223    fn init_repo(dir: &Path) {
224        run_git_command(&["init", "--initial-branch", "master"], dir);
225        run_git_command(&["commit", "--allow-empty", "-m", "Initial commit"], dir);
226    }
227
228    fn dir_with_repo() -> TempDir {
229        let tempdir = tempdir().unwrap();
230        init_repo(tempdir.path());
231        tempdir
232    }
233
234    #[test]
235    #[serial]
236    fn test_get_head_revision() {
237        let repo_dir = dir_with_repo();
238        set_current_dir(repo_dir.path()).expect("Failed to change dir");
239        let revision = internal_get_head_revision().unwrap();
240        assert!(
241            &revision.chars().all(|c| c.is_ascii_alphanumeric()),
242            "'{}' contained non alphanumeric or non ASCII characters",
243            &revision
244        )
245    }
246
247    #[test]
248    fn test_parse_git_version() {
249        let version = parse_git_version("git version 2.52.0");
250        assert_eq!(version.unwrap(), (2, 52, 0));
251
252        let version = parse_git_version("git version 2.52.0\n");
253        assert_eq!(version.unwrap(), (2, 52, 0));
254    }
255}