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 .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 "-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 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 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
132pub fn prune() -> Result<()> {
134 if is_shallow_repo().context("Could not determine if shallow clone.")? {
135 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
151pub 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 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 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 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 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 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 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 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}