git_perf/
git_interop.rs

1use std::{
2    env::current_dir,
3    io,
4    path::{Path, PathBuf},
5    process::{self},
6    time::Duration,
7};
8
9use anyhow::{bail, Context, Result};
10use backoff::{Error, ExponentialBackoffBuilder};
11use itertools::Itertools;
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15enum GitError {
16    #[error("Git failed to execute.\n\nstdout:\n{stdout}\nstderr:\n{stderr}")]
17    ExecError { stdout: String, stderr: String },
18
19    #[error("Failed to execute git command")]
20    IoError(#[from] io::Error),
21}
22
23fn run_git(args: &[&str], working_dir: &Option<&Path>) -> Result<String, GitError> {
24    let working_dir = working_dir.map(PathBuf::from).unwrap_or(current_dir()?);
25
26    let output = process::Command::new("git")
27        // TODO(kaihowl) set correct encoding and lang?
28        .env("LANG", "")
29        .env("LC_ALL", "C")
30        .current_dir(working_dir)
31        .args(args)
32        .output()?;
33
34    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
35
36    if !output.status.success() {
37        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
38        return Err(GitError::ExecError { stdout, stderr });
39    }
40
41    Ok(stdout)
42}
43
44const REFS_NOTES_BRANCH: &str = "refs/notes/perf-v3";
45
46pub fn add_note_line_to_head(line: &str) -> Result<()> {
47    run_git(
48        &[
49            "notes",
50            "--ref",
51            REFS_NOTES_BRANCH,
52            "append",
53            // TODO(kaihowl) disabled until #96 is solved
54            // "--no-separator",
55            "-m",
56            line,
57        ],
58        &None,
59    )
60    .context("Failed to add new measurement")?;
61
62    Ok(())
63}
64
65pub fn get_head_revision() -> Result<String> {
66    let head = run_git(&["rev-parse", "HEAD"], &None).context("Failed to parse HEAD.")?;
67
68    Ok(head.trim().to_owned())
69}
70pub fn fetch(work_dir: Option<&Path>) -> Result<()> {
71    // Use git directly to avoid having to implement ssh-agent and/or extraHeader handling
72    run_git(&["fetch", "origin", REFS_NOTES_BRANCH], &work_dir)
73        .context("Failed to fetch performance measurements.")?;
74
75    Ok(())
76}
77
78pub fn reconcile() -> Result<()> {
79    let _ = run_git(
80        &[
81            "notes",
82            "--ref",
83            REFS_NOTES_BRANCH,
84            "merge",
85            "-s",
86            "cat_sort_uniq",
87            "FETCH_HEAD",
88        ],
89        &None,
90    )
91    .context("Failed to merge measurements with upstream")?;
92    Ok(())
93}
94
95#[derive(Debug, Error)]
96enum PushError {
97    #[error("A ref failed to be pushed:\n{stdout}\n{stderr}")]
98    RefFailedToPush { stdout: String, stderr: String },
99}
100
101pub fn raw_push(work_dir: Option<&Path>) -> Result<()> {
102    // TODO(kaihowl) configure remote?
103    // TODO(kaihowl) factor into constants
104    // TODO(kaihowl) capture output
105    let output = run_git(
106        &[
107            "push",
108            "--porcelain",
109            "origin",
110            format!("{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
111        ],
112        &work_dir,
113    );
114
115    match output {
116        Ok(_) => Ok(()),
117        Err(GitError::ExecError { stdout, stderr }) => {
118            for line in stdout.lines() {
119                if !line.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) {
120                    continue;
121                }
122                if !line.starts_with('!') {
123                    return Ok(());
124                }
125            }
126            bail!(PushError::RefFailedToPush { stdout, stderr })
127        }
128        Err(e) => bail!(e),
129    }
130}
131
132// TODO(kaihowl) what happens with a git dir supplied with -C?
133pub fn prune() -> Result<()> {
134    if is_shallow_repo().context("Could not determine if shallow clone.")? {
135        // TODO(kaihowl) is this not already checked by git itself?
136        bail!("Refusing to prune on a shallow repo")
137    }
138
139    run_git(&["notes", "--ref", REFS_NOTES_BRANCH, "prune"], &None).context("Failed to prune.")?;
140
141    Ok(())
142}
143
144fn is_shallow_repo() -> Result<bool> {
145    let output = run_git(&["rev-parse", "--is-shallow-repository"], &None)
146        .context("Failed to determine if repo is a shallow clone.")?;
147
148    Ok(output.starts_with("true"))
149}
150
151// TODO(kaihowl) return a nested iterator / generator instead?
152pub fn walk_commits(num_commits: usize) -> Result<Vec<(String, Vec<String>)>> {
153    let output = run_git(
154        &[
155            "--no-pager",
156            "log",
157            "--no-color",
158            "--ignore-missing",
159            "-n",
160            num_commits.to_string().as_str(),
161            "--first-parent",
162            "--pretty=--,%H,%D%n%N",
163            "--decorate=full",
164            format!("--notes={REFS_NOTES_BRANCH}").as_str(),
165            "HEAD",
166        ],
167        &None,
168    )
169    .context("Failed to retrieve commits")?;
170
171    let mut current_commit = None;
172    let mut detected_shallow = false;
173
174    // TODO(kaihowl) iterator or generator instead / how to propagate exit code?
175    let it = output.lines().filter_map(|l| {
176        if l.starts_with("--") {
177            let info = l.split(',').collect_vec();
178
179            current_commit = Some(
180                info.get(1)
181                    .expect("Could not read commit header.")
182                    .to_owned(),
183            );
184
185            detected_shallow |= info[2..].iter().any(|s| *s == "grafted");
186
187            None
188        } else {
189            // TODO(kaihowl) lot's of string copies...
190            Some((
191                current_commit.as_ref().expect("TODO(kaihowl)").to_owned(),
192                l,
193            ))
194        }
195    });
196
197    let commits: Vec<_> = it
198        .group_by(|it| it.0.to_owned())
199        .into_iter()
200        .map(|(k, v)| {
201            (
202                k.to_owned(),
203                // TODO(kaihowl) joining what was split above already
204                // TODO(kaihowl) lot's of string copies...
205                v.map(|(_, v)| v.to_owned()).collect::<Vec<_>>(),
206            )
207        })
208        .collect();
209
210    if detected_shallow && commits.len() < num_commits {
211        bail!("Refusing to continue as commit log depth was limited by shallow clone");
212    }
213
214    Ok(commits)
215}
216
217pub fn pull(work_dir: Option<&Path>) -> Result<()> {
218    fetch(work_dir)?;
219    reconcile()
220}
221
222pub fn push(work_dir: Option<&Path>) -> Result<()> {
223    // TODO(kaihowl) check transient/permanent error
224    let op = || -> Result<(), backoff::Error<anyhow::Error>> {
225        raw_push(work_dir).map_err(|e| match e.downcast_ref::<PushError>() {
226            Some(PushError::RefFailedToPush { .. }) => match pull(work_dir) {
227                Err(pull_error) => Error::permanent(pull_error),
228                Ok(_) => Error::transient(e),
229            },
230            None => Error::Permanent(e),
231        })
232    };
233
234    // TODO(kaihowl) configure
235    let backoff = ExponentialBackoffBuilder::default()
236        .with_max_elapsed_time(Some(Duration::from_secs(60)))
237        .build();
238
239    backoff::retry(backoff, op).map_err(|e| match e {
240        Error::Permanent(e) => e.context("Permanent failure while pushing refs"),
241        Error::Transient { err, .. } => err.context("Timed out pushing refs"),
242    })?;
243
244    Ok(())
245}
246
247#[cfg(test)]
248mod test {
249    use super::*;
250    use std::env::{self, set_current_dir};
251
252    use httptest::{
253        http::{header::AUTHORIZATION, Uri},
254        matchers::{self, request},
255        responders::status_code,
256        Expectation, Server,
257    };
258    use tempfile::{tempdir, TempDir};
259
260    fn run_git_command(args: &[&str], dir: &Path) {
261        assert!(process::Command::new("git")
262            .args(args)
263            .envs([
264                ("GIT_CONFIG_NOSYSTEM", "true"),
265                ("GIT_CONFIG_GLOBAL", "/dev/null"),
266                ("GIT_AUTHOR_NAME", "testuser"),
267                ("GIT_AUTHOR_EMAIL", "testuser@example.com"),
268                ("GIT_COMMITTER_NAME", "testuser"),
269                ("GIT_COMMITTER_EMAIL", "testuser@example.com"),
270            ])
271            .current_dir(dir)
272            .status()
273            .expect("Failed to spawn git command")
274            .success());
275    }
276
277    fn init_repo(dir: &Path) {
278        run_git_command(&["init", "--initial-branch", "master"], dir);
279        run_git_command(&["commit", "--allow-empty", "-m", "Initial commit"], dir);
280    }
281
282    fn dir_with_repo() -> TempDir {
283        let tempdir = tempdir().unwrap();
284        init_repo(tempdir.path());
285        tempdir
286    }
287
288    fn dir_with_repo_and_customheader(origin_url: Uri, extra_header: &str) -> TempDir {
289        let tempdir = dir_with_repo();
290
291        let url = origin_url.to_string();
292
293        run_git_command(&["remote", "add", "origin", &url], tempdir.path());
294        run_git_command(
295            &[
296                "config",
297                "--add",
298                format!("http.{}.extraHeader", url).as_str(),
299                extra_header,
300            ],
301            tempdir.path(),
302        );
303
304        tempdir
305    }
306
307    fn hermetic_git_env() {
308        env::set_var("GIT_CONFIG_NOSYSTEM", "true");
309        env::set_var("GIT_CONFIG_GLOBAL", "/dev/null");
310        env::set_var("GIT_AUTHOR_NAME", "testuser");
311        env::set_var("GIT_AUTHOR_EMAIL", "testuser@example.com");
312        env::set_var("GIT_COMMITTER_NAME", "testuser");
313        env::set_var("GIT_COMMITTER_EMAIL", "testuser@example.com");
314    }
315
316    #[test]
317    fn test_customheader_push() {
318        let test_server = Server::run();
319        let repo_dir =
320            dir_with_repo_and_customheader(test_server.url(""), "AUTHORIZATION: sometoken");
321        set_current_dir(repo_dir.path()).expect("Failed to change dir");
322
323        test_server.expect(
324            Expectation::matching(request::headers(matchers::contains((
325                AUTHORIZATION.as_str(),
326                "sometoken",
327            ))))
328            .times(1..)
329            .respond_with(status_code(200)),
330        );
331
332        // TODO(kaihowl) not so great test as this fails with/without authorization
333        // We only want to verify that a call on the server with the authorization header was
334        // received.
335        hermetic_git_env();
336        pull(Some(repo_dir.path()))
337            .expect_err("We have no valid git http server setup -> should fail");
338    }
339
340    #[test]
341    fn test_customheader_pull() {
342        let test_server = Server::run();
343        let repo_dir =
344            dir_with_repo_and_customheader(test_server.url(""), "AUTHORIZATION: someothertoken");
345        set_current_dir(&repo_dir).expect("Failed to change dir");
346
347        test_server.expect(
348            Expectation::matching(request::headers(matchers::contains((
349                AUTHORIZATION.as_str(),
350                "someothertoken",
351            ))))
352            .times(1..)
353            .respond_with(status_code(200)),
354        );
355
356        // TODO(kaihowl) duplication, leaks out of this test
357        hermetic_git_env();
358        let error = push(Some(repo_dir.path()));
359        error
360            .as_ref()
361            .expect_err("We have no valid git http server setup -> should fail");
362        dbg!(&error);
363        let error = error.unwrap_err().root_cause().to_string();
364        dbg!(&error);
365    }
366
367    #[test]
368    fn test_get_head_revision() {
369        let repo_dir = dir_with_repo();
370        set_current_dir(repo_dir.path()).expect("Failed to change dir");
371        let revision = get_head_revision().unwrap();
372        assert!(
373            &revision.chars().all(|c| c.is_ascii_alphanumeric()),
374            "'{}' contained non alphanumeric or non ASCII characters",
375            &revision
376        )
377    }
378}