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