git_perf/git/
git_interop.rs

1use std::{
2    io::{BufRead, BufReader, BufWriter, Write},
3    path::Path,
4    process::Stdio,
5    thread,
6    time::Duration,
7};
8
9use defer::defer;
10use log::{debug, warn};
11use unindent::unindent;
12
13use anyhow::{anyhow, bail, Context, Result};
14use backoff::{ExponentialBackoff, ExponentialBackoffBuilder};
15use itertools::Itertools;
16
17use chrono::prelude::*;
18use rand::{thread_rng, Rng};
19
20use crate::config;
21
22use super::git_definitions::{
23    GIT_ORIGIN, GIT_PERF_REMOTE, REFS_NOTES_ADD_TARGET_PREFIX, REFS_NOTES_BRANCH,
24    REFS_NOTES_MERGE_BRANCH_PREFIX, REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX,
25    REFS_NOTES_WRITE_SYMBOLIC_REF, REFS_NOTES_WRITE_TARGET_PREFIX,
26};
27use super::git_lowlevel::{
28    capture_git_output, get_git_perf_remote, git_rev_parse, git_rev_parse_symbolic_ref,
29    git_update_ref, internal_get_head_revision, is_shallow_repo, map_git_error,
30    set_git_perf_remote, spawn_git_command,
31};
32use super::git_types::GitError;
33use super::git_types::Reference;
34
35pub use super::git_lowlevel::get_head_revision;
36
37pub use super::git_lowlevel::check_git_version;
38
39// TODO(kaihowl) separate into git low and high level logic
40
41fn map_git_error_for_backoff(e: GitError) -> ::backoff::Error<GitError> {
42    match e {
43        GitError::RefFailedToPush { .. }
44        | GitError::RefFailedToLock { .. }
45        | GitError::RefConcurrentModification { .. } => ::backoff::Error::transient(e),
46        GitError::ExecError { .. }
47        | GitError::IoError(..)
48        | GitError::ShallowRepository
49        | GitError::MissingHead { .. }
50        | GitError::NoRemoteMeasurements { .. }
51        | GitError::NoUpstream { .. }
52        | GitError::MissingMeasurements => ::backoff::Error::permanent(e),
53    }
54}
55
56/// Central place to configure backoff policy for git-perf operations.
57fn default_backoff() -> ExponentialBackoff {
58    let max_elapsed = config::backoff_max_elapsed_seconds();
59    ExponentialBackoffBuilder::default()
60        .with_max_elapsed_time(Some(Duration::from_secs(max_elapsed)))
61        .build()
62}
63
64pub fn add_note_line_to_head(line: &str) -> Result<()> {
65    let op = || -> Result<(), ::backoff::Error<GitError>> {
66        raw_add_note_line_to_head(line).map_err(map_git_error_for_backoff)
67    };
68
69    let backoff = default_backoff();
70
71    ::backoff::retry(backoff, op).map_err(|e| match e {
72        ::backoff::Error::Permanent(err) => {
73            anyhow!(err).context("Permanent failure while adding note line to head")
74        }
75        ::backoff::Error::Transient { err, .. } => {
76            anyhow!(err).context("Timed out while adding note line to head")
77        }
78    })?;
79
80    Ok(())
81}
82
83fn raw_add_note_line_to_head(line: &str) -> Result<(), GitError> {
84    ensure_symbolic_write_ref_exists()?;
85
86    // `git notes append` is not safe to use concurrently.
87    // We create a new type of temporary reference: Cannot reuse the normal write references as
88    // they only get merged upon push. This can take arbitrarily long.
89    let current_note_head =
90        git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).unwrap_or(EMPTY_OID.to_string());
91    let current_symbolic_ref_target = git_rev_parse_symbolic_ref(REFS_NOTES_WRITE_SYMBOLIC_REF)
92        .expect("Missing symbolic-ref for target");
93    let temp_target = create_temp_add_head(&current_note_head)?;
94
95    defer!(remove_reference(&temp_target)
96        .expect("Deleting our own temp ref for adding should never fail"));
97
98    // Test if the repo has any commit checked out at HEAD
99    if internal_get_head_revision().is_err() {
100        return Err(GitError::MissingHead {
101            reference: "HEAD".to_string(),
102        });
103    }
104
105    capture_git_output(
106        &[
107            "notes",
108            "--ref",
109            &temp_target,
110            "append",
111            // TODO(kaihowl) disabled until #96 is solved
112            // "--no-separator",
113            "-m",
114            line,
115        ],
116        &None,
117    )?;
118
119    // Update current write branch with pending write
120    git_update_ref(unindent(
121        format!(
122            r#"
123            start
124            symref-verify {REFS_NOTES_WRITE_SYMBOLIC_REF} {current_symbolic_ref_target}
125            update {current_symbolic_ref_target} {temp_target} {current_note_head}
126            commit
127            "#
128        )
129        .as_str(),
130    ))?;
131
132    Ok(())
133}
134
135fn ensure_remote_exists() -> Result<(), GitError> {
136    if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
137        return Ok(());
138    }
139
140    if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
141        return set_git_perf_remote(GIT_PERF_REMOTE, &x);
142    }
143
144    Err(GitError::NoUpstream {})
145}
146
147/// Creates a temporary reference name by combining a prefix with a random suffix.
148fn create_temp_ref_name(prefix: &str) -> String {
149    let suffix = random_suffix();
150    format!("{prefix}{suffix}")
151}
152
153fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
154    if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
155        let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
156
157        git_update_ref(unindent(
158            format!(
159                r#"
160                start
161                symref-create {REFS_NOTES_WRITE_SYMBOLIC_REF} {target}
162                commit
163                "#
164            )
165            .as_str(),
166        ))
167        .or_else(|err| {
168            if let GitError::RefFailedToLock { .. } = err {
169                Ok(())
170            } else {
171                Err(err)
172            }
173        })?;
174    }
175    Ok(())
176}
177
178fn random_suffix() -> String {
179    let suffix: u32 = thread_rng().gen();
180    format!("{suffix:08x}")
181}
182
183fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
184    ensure_remote_exists()?;
185
186    let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
187    // Use git directly to avoid having to implement ssh-agent and/or extraHeader handling
188    capture_git_output(
189        &[
190            "fetch",
191            "--atomic",
192            "--no-write-fetch-head",
193            GIT_PERF_REMOTE,
194            // Always force overwrite the local reference
195            // Separation into write, merge, and read branches ensures that this does not lead to
196            // any data loss
197            format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
198        ],
199        &work_dir,
200    )
201    .map_err(map_git_error)?;
202
203    let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
204
205    if ref_before == ref_after {
206        println!("Already up to date");
207    }
208
209    Ok(())
210}
211
212fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
213    _ = capture_git_output(
214        &[
215            "notes",
216            "--ref",
217            target,
218            "merge",
219            "-s",
220            "cat_sort_uniq",
221            branch,
222        ],
223        &None,
224    )?;
225    Ok(())
226}
227
228fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
229    let target = create_temp_ref_name(prefix);
230    if current_head != EMPTY_OID {
231        git_update_ref(unindent(
232            format!(
233                r#"
234            start
235            create {target} {current_head}
236            commit
237            "#
238            )
239            .as_str(),
240        ))?;
241    }
242    Ok(target)
243}
244
245fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
246    create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
247}
248
249fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
250    create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
251}
252
253fn compact_head(target: &str) -> Result<(), GitError> {
254    let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
255
256    // Orphan compaction commit
257    let compaction_head = capture_git_output(
258        &["commit-tree", "-m", "cutoff history", &new_removal_head],
259        &None,
260    )?
261    .stdout;
262
263    let compaction_head = compaction_head.trim();
264
265    git_update_ref(unindent(
266        format!(
267            r#"
268            start
269            update {target} {compaction_head}
270            commit
271            "#
272        )
273        .as_str(),
274    ))?;
275
276    Ok(())
277}
278
279fn retry_notify(err: GitError, dur: Duration) {
280    debug!("Error happened at {dur:?}: {err}");
281    warn!("Retrying...");
282}
283
284pub fn remove_measurements_from_commits(older_than: DateTime<Utc>) -> Result<()> {
285    let op = || -> Result<(), ::backoff::Error<GitError>> {
286        raw_remove_measurements_from_commits(older_than).map_err(map_git_error_for_backoff)
287    };
288
289    let backoff = default_backoff();
290
291    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
292        ::backoff::Error::Permanent(err) => {
293            anyhow!(err).context("Permanent failure while adding note line to head")
294        }
295        ::backoff::Error::Transient { err, .. } => {
296            anyhow!(err).context("Timed out while adding note line to head")
297        }
298    })?;
299
300    Ok(())
301}
302
303fn raw_remove_measurements_from_commits(older_than: DateTime<Utc>) -> Result<(), GitError> {
304    // 1. pull
305    // 2. remove measurements
306    // 3. compact
307    // 4. try to push
308    fetch(None)?;
309
310    let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
311
312    let target = create_temp_rewrite_head(&current_notes_head)?;
313
314    remove_measurements_from_reference(&target, older_than)?;
315
316    compact_head(&target)?;
317
318    git_push_notes_ref(&current_notes_head, &target, &None)?;
319
320    git_update_ref(unindent(
321        format!(
322            r#"
323            start
324            update {REFS_NOTES_BRANCH} {target}
325            commit
326            "#
327        )
328        .as_str(),
329    ))?;
330
331    // Delete target
332    remove_reference(&target)?;
333
334    Ok(())
335}
336
337// Remove notes pertaining to git commits whose commit date is older than specified.
338fn remove_measurements_from_reference(
339    reference: &str,
340    older_than: DateTime<Utc>,
341) -> Result<(), GitError> {
342    let oldest_timestamp = older_than.timestamp();
343    // Outputs line-by-line <note_oid> <annotated_oid>
344    let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
345    let notes_out = list_notes.stdout.take().unwrap();
346
347    let mut get_commit_dates = spawn_git_command(
348        &[
349            "log",
350            "--ignore-missing",
351            "--no-walk",
352            "--pretty=format:%H %ct",
353            "--stdin",
354        ],
355        &None,
356        Some(Stdio::piped()),
357    )?;
358    let dates_in = get_commit_dates.stdin.take().unwrap();
359    let dates_out = get_commit_dates.stdout.take().unwrap();
360
361    let mut remove_measurements = spawn_git_command(
362        &[
363            "notes",
364            "--ref",
365            reference,
366            "remove",
367            "--stdin",
368            "--ignore-missing",
369        ],
370        &None,
371        Some(Stdio::piped()),
372    )?;
373    let removal_in = remove_measurements.stdin.take().unwrap();
374    let removal_out = remove_measurements.stdout.take().unwrap();
375
376    let removal_handler = thread::spawn(move || {
377        let reader = BufReader::new(dates_out);
378        let mut writer = BufWriter::new(removal_in);
379        for line in reader.lines().map_while(Result::ok) {
380            if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
381                if let Ok(timestamp) = timestamp.parse::<i64>() {
382                    if timestamp <= oldest_timestamp {
383                        writeln!(writer, "{commit}").expect("Could not write to stream");
384                    }
385                }
386            }
387        }
388    });
389
390    let debugging_handler = thread::spawn(move || {
391        let reader = BufReader::new(removal_out);
392        reader
393            .lines()
394            .map_while(Result::ok)
395            .for_each(|l| println!("{l}"))
396    });
397
398    {
399        let reader = BufReader::new(notes_out);
400        let mut writer = BufWriter::new(dates_in);
401
402        reader.lines().map_while(Result::ok).for_each(|line| {
403            if let Some(line) = line.split_whitespace().nth(1) {
404                writeln!(writer, "{line}").expect("Failed to write to pipe");
405            }
406        });
407    }
408
409    removal_handler.join().expect("Failed to join");
410    debugging_handler.join().expect("Failed to join");
411
412    list_notes.wait()?;
413    get_commit_dates.wait()?;
414    remove_measurements.wait()?;
415
416    Ok(())
417}
418
419fn new_symbolic_write_ref() -> Result<String, GitError> {
420    let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
421
422    git_update_ref(unindent(
423        format!(
424            r#"
425            start
426            symref-update {REFS_NOTES_WRITE_SYMBOLIC_REF} {target}
427            commit
428            "#
429        )
430        .as_str(),
431    ))?;
432    Ok(target)
433}
434
435const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
436
437fn consolidate_write_branches_into(
438    current_upstream_oid: &str,
439    target: &str,
440    except_ref: Option<&str>,
441) -> Result<Vec<Reference>, GitError> {
442    // - Reset the merge ref to the upstream perf ref iff it still matches the captured OID
443    //   - otherwise concurrent pull occurred.
444    git_update_ref(unindent(
445        format!(
446            r#"
447                start
448                verify {REFS_NOTES_BRANCH} {current_upstream_oid}
449                update {target} {current_upstream_oid} {EMPTY_OID}
450                commit
451            "#
452        )
453        .as_str(),
454    ))?;
455
456    // - merge in all existing write refs, except for the newly created one from first step
457    //     - Same step (except for filtering of the new ref) happens on local read as well.)
458    //     - Relies on unrelated histories, cat_sort_uniq merge strategy
459    //     - Allows to cut off the history on upstream periodically
460    let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
461    let refs = get_refs(additional_args)?
462        .into_iter()
463        .filter(|r| r.refname != except_ref.unwrap_or_default())
464        .collect_vec();
465
466    for reference in &refs {
467        reconcile_branch_with(target, &reference.oid)?;
468    }
469
470    Ok(refs)
471}
472
473fn remove_reference(ref_name: &str) -> Result<(), GitError> {
474    git_update_ref(unindent(
475        format!(
476            r#"
477                    start
478                    delete {ref_name}
479                    commit
480                "#
481        )
482        .as_str(),
483    ))
484}
485
486fn raw_push(work_dir: Option<&Path>) -> Result<(), GitError> {
487    ensure_remote_exists()?;
488    // This might merge concurrently created write branches. There is no protection against that.
489    // This wants to achieve an at-least-once semantic. The exactly-once semantic is ensured by the
490    // cat_sort_uniq merge strategy.
491
492    // - Reset the symbolic-ref "write" to a new unique write ref.
493    //     - Allows to continue committing measurements while pushing.
494    //     - ?? What happens when a git notes amend concurrently still writes to the old ref?
495    let new_write_ref = new_symbolic_write_ref()?;
496
497    let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
498
499    defer!(remove_reference(&merge_ref).expect("Deleting our own branch should never fail"));
500
501    // - Create a temporary merge ref, set to the upstream perf ref, merge in all existing write refs except the newly created one from the previous step.
502    //     - Same step (except for filtering of the new ref) happens on local read as well.)
503    //     - Relies on unrelated histories, cat_sort_uniq merge strategy
504    //     - Allows to cut off the history on upstream periodically
505    // NEW
506    // - Note down the current upstream perf ref oid
507    let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
508    let refs =
509        consolidate_write_branches_into(&current_upstream_oid, &merge_ref, Some(&new_write_ref))?;
510
511    if refs.is_empty() && current_upstream_oid == EMPTY_OID {
512        return Err(GitError::MissingMeasurements);
513    }
514
515    git_push_notes_ref(&current_upstream_oid, &merge_ref, &work_dir)?;
516
517    // It is acceptable to fetch here independent of the push. Only one concurrent push will succeed.
518    fetch(None)?;
519
520    // Delete merged-in write references
521    let mut commands = Vec::new();
522    commands.push(String::from("start"));
523    for Reference { refname, oid } in &refs {
524        commands.push(format!("delete {refname} {oid}"));
525    }
526    commands.push(String::from("commit"));
527    // empty line
528    commands.push(String::new());
529    let commands = commands.join("\n");
530    git_update_ref(commands)?;
531
532    Ok(())
533}
534
535fn git_push_notes_ref(
536    expected_upstream: &str,
537    push_ref: &str,
538    working_dir: &Option<&Path>,
539) -> Result<(), GitError> {
540    // - CAS push the temporary merge ref to upstream using the noted down upstream ref
541    //     - In case of concurrent pushes, back off and restart fresh from previous step.
542    let output = capture_git_output(
543        &[
544            "push",
545            "--porcelain",
546            format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
547            GIT_PERF_REMOTE,
548            format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
549        ],
550        working_dir,
551    );
552
553    // - Clean your own temporary merge ref and all others with a merge commit older than x days.
554    //     - In case of crashes before clean up, old merge refs are eliminated eventually.
555
556    match output {
557        Ok(output) => {
558            print!("{}", &output.stdout);
559            Ok(())
560        }
561        Err(GitError::ExecError { command: _, output }) => {
562            let successful_push = output.stdout.lines().any(|l| {
563                l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
564            });
565            if successful_push {
566                Ok(())
567            } else {
568                Err(GitError::RefFailedToPush { output })
569            }
570        }
571        Err(e) => Err(e),
572    }?;
573
574    Ok(())
575}
576
577// TODO(kaihowl) what happens with a git dir supplied with -C?
578pub fn prune() -> Result<()> {
579    let op = || -> Result<(), ::backoff::Error<GitError>> {
580        raw_prune().map_err(map_git_error_for_backoff)
581    };
582
583    let backoff = default_backoff();
584
585    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
586        ::backoff::Error::Permanent(err) => {
587            anyhow!(err).context("Permanent failure while pruning refs")
588        }
589        ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
590    })?;
591
592    Ok(())
593}
594
595fn raw_prune() -> Result<(), GitError> {
596    if is_shallow_repo()? {
597        return Err(GitError::ShallowRepository);
598    }
599
600    // TODO(kaihowl) code duplication with remove_measurements_from_commits
601
602    // - update local upstream from remote
603    pull_internal(None)?;
604
605    // - create temp branch for pruning and set to current upstream
606    let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
607    let target = create_temp_rewrite_head(&current_notes_head)?;
608
609    // - invoke prune
610    capture_git_output(&["notes", "--ref", &target, "prune"], &None)?;
611
612    // - compact the new head
613    compact_head(&target)?;
614
615    // TODO(kaihowl) add additional test coverage checking that the head has been compacted
616    // / elements are dropped
617
618    // - CAS remote upstream
619    git_push_notes_ref(&current_notes_head, &target, &None)?;
620    git_update_ref(unindent(
621        format!(
622            r#"
623            start
624            update {REFS_NOTES_BRANCH} {target}
625            commit
626            "#
627        )
628        .as_str(),
629    ))?;
630
631    // - clean up temp branch
632    remove_reference(&target)?;
633
634    Ok(())
635}
636
637fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
638    let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
639    args.extend(additional_args.iter().map(|s| s.as_str()));
640
641    let output = capture_git_output(&args, &None)?;
642    Ok(output
643        .stdout
644        .lines()
645        .map(|s| {
646            let items = s.split('\0').take(2).collect_vec();
647            assert!(items.len() == 2);
648            Reference {
649                refname: items[0].to_string(),
650                oid: items[1].to_string(),
651            }
652        })
653        .collect_vec())
654}
655
656struct TempRef {
657    ref_name: String,
658}
659
660impl TempRef {
661    fn new(prefix: &str) -> Result<Self, GitError> {
662        Ok(TempRef {
663            ref_name: create_temp_ref(prefix, EMPTY_OID)?,
664        })
665    }
666}
667
668impl Drop for TempRef {
669    fn drop(&mut self) {
670        remove_reference(&self.ref_name)
671            .unwrap_or_else(|_| panic!("Failed to remove reference: {}", self.ref_name))
672    }
673}
674
675fn update_read_branch() -> Result<TempRef, GitError> {
676    let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)?;
677    // - With the upstream refs/notes/perf-v3
678    //     - If not merged into refs/notes/perf-v3-read: set refs/notes/perf-v3-read to refs/notes/perf-v3
679    //     - Protect against concurrent invocations by checking that the refs/notes/perf-v3-read has
680    //     not changed between invocations!
681    //
682    // TODO(kaihowl) add test for bug:
683    //   read branch might not be up to date with the remote branch after a history cut off.
684    //   Then the _old_ read branch might have all writes already merged in.
685    //   But the upstream does not. But we check the pending write branches against the old read
686    //   branch......
687    //   Better to just create the read branch fresh from the remote and add in all pending write
688    //   branches and not optimize. This should be the same as creating the merge branch. Can the
689    //   code be ..merged..?
690
691    let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
692
693    let _ = consolidate_write_branches_into(&current_upstream_oid, &temp_ref.ref_name, None)?;
694
695    Ok(temp_ref)
696}
697
698pub fn walk_commits(num_commits: usize) -> Result<Vec<(String, Vec<String>)>> {
699    // update local read branch
700    let temp_ref = update_read_branch()?;
701
702    let output = capture_git_output(
703        &[
704            "--no-pager",
705            "log",
706            "--no-color",
707            "--ignore-missing",
708            "-n",
709            num_commits.to_string().as_str(),
710            "--first-parent",
711            "--pretty=--,%H,%D%n%N",
712            "--decorate=full",
713            format!("--notes={}", temp_ref.ref_name).as_str(),
714            "HEAD",
715        ],
716        &None,
717    )
718    .context("Failed to retrieve commits")?;
719
720    let mut commits: Vec<(String, Vec<String>)> = Vec::new();
721    let mut detected_shallow = false;
722    let mut current_commit: Option<String> = None;
723
724    for l in output.stdout.lines() {
725        if l.starts_with("--") {
726            let info = l.split(',').collect_vec();
727            let commit_hash = info
728                .get(1)
729                .expect("No commit header found before measurement line in git log output");
730            detected_shallow |= info[2..].contains(&"grafted");
731            current_commit = Some(commit_hash.to_string());
732            commits.push((commit_hash.to_string(), Vec::new()));
733        } else if let Some(commit_hash) = current_commit.as_ref() {
734            if let Some(last) = commits.last_mut() {
735                last.1.push(l.to_string());
736            } else {
737                // Should not happen, but just in case
738                commits.push((commit_hash.to_string(), vec![l.to_string()]));
739            }
740        }
741    }
742
743    if detected_shallow && commits.len() < num_commits {
744        bail!("Refusing to continue as commit log depth was limited by shallow clone");
745    }
746
747    Ok(commits)
748}
749
750pub fn pull(work_dir: Option<&Path>) -> Result<()> {
751    pull_internal(work_dir)?;
752    Ok(())
753}
754
755fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
756    fetch(work_dir).or_else(|err| match err {
757        // A concurrent modification comes from a concurrent fetch.
758        // Don't fail for that.
759        // TODO(kaihowl) must potentially be moved into the retry logic from the push backoff as it
760        // only is there safe to assume that we successfully pulled.
761        GitError::RefConcurrentModification { .. } | GitError::RefFailedToLock { .. } => Ok(()),
762        _ => Err(err),
763    })?;
764
765    Ok(())
766}
767
768pub fn push(work_dir: Option<&Path>) -> Result<()> {
769    let op = || {
770        raw_push(work_dir)
771            .map_err(map_git_error_for_backoff)
772            .map_err(|e: ::backoff::Error<GitError>| match e {
773                ::backoff::Error::Transient { .. } => {
774                    match pull_internal(work_dir).map_err(map_git_error_for_backoff) {
775                        Ok(_) => e,
776                        Err(e) => e,
777                    }
778                }
779                ::backoff::Error::Permanent { .. } => e,
780            })
781    };
782
783    let backoff = default_backoff();
784
785    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
786        ::backoff::Error::Permanent(err) => {
787            anyhow!(err).context("Permanent failure while pushing refs")
788        }
789        ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
790    })?;
791
792    Ok(())
793}
794
795#[cfg(test)]
796mod test {
797    use super::*;
798    use std::env::{self, set_current_dir};
799    use std::process;
800
801    use httptest::{
802        http::{header::AUTHORIZATION, Uri},
803        matchers::{self, request},
804        responders::status_code,
805        Expectation, Server,
806    };
807    use serial_test::serial;
808    use tempfile::{tempdir, TempDir};
809
810    fn run_git_command(args: &[&str], dir: &Path) {
811        assert!(process::Command::new("git")
812            .args(args)
813            .envs([
814                ("GIT_CONFIG_NOSYSTEM", "true"),
815                ("GIT_CONFIG_GLOBAL", "/dev/null"),
816                ("GIT_AUTHOR_NAME", "testuser"),
817                ("GIT_AUTHOR_EMAIL", "testuser@example.com"),
818                ("GIT_COMMITTER_NAME", "testuser"),
819                ("GIT_COMMITTER_EMAIL", "testuser@example.com"),
820            ])
821            .stdout(Stdio::null())
822            .stderr(Stdio::null())
823            .current_dir(dir)
824            .status()
825            .expect("Failed to spawn git command")
826            .success());
827    }
828
829    fn init_repo(dir: &Path) {
830        run_git_command(&["init", "--initial-branch", "master"], dir);
831        run_git_command(&["commit", "--allow-empty", "-m", "Initial commit"], dir);
832    }
833
834    fn dir_with_repo() -> TempDir {
835        let tempdir = tempdir().unwrap();
836        init_repo(tempdir.path());
837        tempdir
838    }
839
840    fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
841        let url = origin_url.to_string();
842
843        run_git_command(&["remote", "add", "origin", &url], dir);
844        run_git_command(
845            &[
846                "config",
847                "--add",
848                format!("http.{}.extraHeader", url).as_str(),
849                extra_header,
850            ],
851            dir,
852        );
853    }
854
855    fn hermetic_git_env() {
856        env::set_var("GIT_CONFIG_NOSYSTEM", "true");
857        env::set_var("GIT_CONFIG_GLOBAL", "/dev/null");
858        env::set_var("GIT_AUTHOR_NAME", "testuser");
859        env::set_var("GIT_AUTHOR_EMAIL", "testuser@example.com");
860        env::set_var("GIT_COMMITTER_NAME", "testuser");
861        env::set_var("GIT_COMMITTER_EMAIL", "testuser@example.com");
862    }
863
864    #[test]
865    #[serial]
866    fn test_customheader_pull() {
867        let tempdir = dir_with_repo();
868        set_current_dir(tempdir.path()).expect("Failed to change dir");
869
870        let test_server = Server::run();
871        add_server_remote(
872            test_server.url(""),
873            "AUTHORIZATION: sometoken",
874            tempdir.path(),
875        );
876
877        test_server.expect(
878            Expectation::matching(request::headers(matchers::contains((
879                AUTHORIZATION.as_str(),
880                "sometoken",
881            ))))
882            .times(1..)
883            .respond_with(status_code(200)),
884        );
885
886        // TODO(kaihowl) not so great test as this fails with/without authorization
887        // We only want to verify that a call on the server with the authorization header was
888        // received.
889        hermetic_git_env();
890        pull(None).expect_err("We have no valid git http server setup -> should fail");
891    }
892
893    #[test]
894    #[serial]
895    fn test_customheader_push() {
896        let tempdir = dir_with_repo();
897        set_current_dir(tempdir.path()).expect("Failed to change dir");
898
899        let test_server = Server::run();
900        add_server_remote(
901            test_server.url(""),
902            "AUTHORIZATION: someothertoken",
903            tempdir.path(),
904        );
905
906        test_server.expect(
907            Expectation::matching(request::headers(matchers::contains((
908                AUTHORIZATION.as_str(),
909                "someothertoken",
910            ))))
911            .times(1..)
912            .respond_with(status_code(200)),
913        );
914
915        // Must add a single write as a push without pending local writes just succeeds
916        ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
917        add_note_line_to_head("test note line").expect("Failed to add note line");
918
919        // TODO(kaihowl) duplication, leaks out of this test
920        hermetic_git_env();
921
922        let error = push(None);
923        error
924            .as_ref()
925            .expect_err("We have no valid git http server setup -> should fail");
926        dbg!(&error);
927    }
928
929    #[test]
930    fn test_random_suffix() {
931        for _ in 1..1000 {
932            let first = random_suffix();
933            dbg!(&first);
934            let second = random_suffix();
935            dbg!(&second);
936
937            let all_hex = |s: &String| s.chars().all(|c| c.is_ascii_hexdigit());
938
939            assert_ne!(first, second);
940            assert_eq!(first.len(), 8);
941            assert_eq!(second.len(), 8);
942            assert!(all_hex(&first));
943            assert!(all_hex(&second));
944        }
945    }
946
947    #[test]
948    #[serial]
949    fn test_empty_or_never_pushed_remote_error_for_fetch() {
950        let tempdir = tempdir().unwrap();
951        init_repo(tempdir.path());
952        set_current_dir(tempdir.path()).expect("Failed to change dir");
953        // Add a dummy remote so the code can check for empty remote
954        let git_dir_url = format!("file://{}", tempdir.path().display());
955        run_git_command(&["remote", "add", "origin", &git_dir_url], tempdir.path());
956
957        // TODO(kaihowl) hack to check where the fetch went to
958        std::env::set_var("GIT_TRACE", "true");
959
960        // Do not add any notes/measurements or push anything
961        let result = super::fetch(Some(tempdir.path()));
962        match result {
963            Err(GitError::NoRemoteMeasurements { output }) => {
964                assert!(
965                    output.stderr.contains(GIT_PERF_REMOTE),
966                    "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
967                    output.stderr
968                )
969            }
970            other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
971        }
972    }
973
974    #[test]
975    #[serial]
976    fn test_empty_or_never_pushed_remote_error_for_push() {
977        let tempdir = tempdir().unwrap();
978        init_repo(tempdir.path());
979        set_current_dir(tempdir.path()).expect("Failed to change dir");
980
981        run_git_command(
982            &["remote", "add", "origin", "invalid invalid"],
983            tempdir.path(),
984        );
985
986        // TODO(kaihowl) hack to inspect git commands
987        std::env::set_var("GIT_TRACE", "true");
988
989        add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
990
991        let result = super::raw_push(Some(tempdir.path()));
992        match result {
993            Err(GitError::RefFailedToPush { output }) => {
994                assert!(
995                    output.stderr.contains(GIT_PERF_REMOTE),
996                    "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
997                    output.stderr
998                )
999            }
1000            other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1001        }
1002    }
1003}