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::{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::GitOutput;
34use super::git_types::Reference;
35
36pub use super::git_lowlevel::get_head_revision;
37
38pub use super::git_lowlevel::check_git_version;
39
40pub use super::git_lowlevel::get_repository_root;
41
42pub use super::git_lowlevel::resolve_committish;
43
44/// Represents a commit with its associated git-notes data and metadata.
45///
46/// This structure is returned by `walk_commits_from` and contains:
47/// - The commit SHA
48/// - Commit title (subject line)
49/// - Author name
50/// - Raw note lines for deserialization
51#[derive(Debug, Clone, PartialEq)]
52pub struct CommitWithNotes {
53    pub sha: String,
54    pub title: String,
55    pub author: String,
56    pub note_lines: Vec<String>,
57}
58
59/// Check if the current repository is a shallow clone
60pub fn is_shallow_repository() -> Result<bool> {
61    super::git_lowlevel::is_shallow_repo()
62        .map_err(|e| anyhow!("Failed to check if repository is shallow: {}", e))
63}
64
65fn map_git_error_for_backoff(e: GitError) -> ::backoff::Error<GitError> {
66    match e {
67        GitError::RefFailedToPush { .. }
68        | GitError::RefFailedToLock { .. }
69        | GitError::RefConcurrentModification { .. }
70        | GitError::BadObject { .. } => ::backoff::Error::transient(e),
71        GitError::ExecError { .. }
72        | GitError::IoError(..)
73        | GitError::ShallowRepository
74        | GitError::MissingHead { .. }
75        | GitError::NoRemoteMeasurements { .. }
76        | GitError::NoUpstream { .. }
77        | GitError::MissingMeasurements => ::backoff::Error::permanent(e),
78    }
79}
80
81/// Central place to configure backoff policy for git-perf operations.
82fn default_backoff() -> ExponentialBackoff {
83    let max_elapsed = config::backoff_max_elapsed_seconds();
84    ExponentialBackoffBuilder::default()
85        .with_max_elapsed_time(Some(Duration::from_secs(max_elapsed)))
86        .build()
87}
88
89/// Appends a note line to a specific commit with exponential backoff retry logic.
90///
91/// This function adds a single line to the git notes associated with the specified
92/// commit in the performance notes ref (`refs/notes/perf-v3`). The operation is
93/// retried with exponential backoff to handle transient failures such as concurrent
94/// write conflicts or filesystem locks.
95///
96/// # Arguments
97///
98/// * `commit` - The commit hash (or committish reference) to add the note to
99/// * `line` - The text content to append to the commit's notes
100///
101/// # Returns
102///
103/// * `Ok(())` - The note line was successfully added
104/// * `Err` - If the operation fails permanently or times out after retries
105///
106/// # Errors
107///
108/// Returns an error if:
109/// - The commit does not exist
110/// - The operation times out after exhausting retry attempts
111/// - A permanent failure occurs (e.g., invalid commit reference)
112///
113/// # Examples
114///
115/// ```no_run
116/// # use git_perf::git::git_interop::add_note_line;
117/// add_note_line("HEAD", "benchmark_result=1.23s").unwrap();
118/// ```
119pub fn add_note_line(commit: &str, line: &str) -> Result<()> {
120    let op = || -> Result<(), ::backoff::Error<GitError>> {
121        raw_add_note_line(commit, line).map_err(map_git_error_for_backoff)
122    };
123
124    let backoff = default_backoff();
125
126    ::backoff::retry(backoff, op).map_err(|e| match e {
127        ::backoff::Error::Permanent(err) => anyhow!(err).context(format!(
128            "Permanent failure while adding note line to commit {}",
129            commit
130        )),
131        ::backoff::Error::Transient { err, .. } => anyhow!(err).context(format!(
132            "Timed out while adding note line to commit {}",
133            commit
134        )),
135    })?;
136
137    Ok(())
138}
139
140/// Add a note line to HEAD (convenience wrapper)
141pub fn add_note_line_to_head(line: &str) -> Result<()> {
142    let head = internal_get_head_revision()
143        .map_err(|e| anyhow!(e).context("Failed to get HEAD revision"))?;
144    add_note_line(&head, line)
145}
146
147fn raw_add_note_line(commit: &str, line: &str) -> Result<(), GitError> {
148    ensure_symbolic_write_ref_exists()?;
149
150    // `git notes append` is not safe to use concurrently.
151    // We create a new type of temporary reference: Cannot reuse the normal write references as
152    // they only get merged upon push. This can take arbitrarily long.
153    let current_note_head =
154        git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).unwrap_or(EMPTY_OID.to_string());
155    let current_symbolic_ref_target = git_rev_parse_symbolic_ref(REFS_NOTES_WRITE_SYMBOLIC_REF)
156        .expect("Missing symbolic-ref for target");
157    let temp_target = create_temp_add_head(&current_note_head)?;
158
159    defer!(remove_reference(&temp_target)
160        .expect("Deleting our own temp ref for adding should never fail"));
161
162    // Verify the target commit exists by resolving it
163    let resolved_commit = git_rev_parse(commit)?;
164
165    capture_git_output(
166        &[
167            "notes",
168            "--ref",
169            &temp_target,
170            "append",
171            "-m",
172            line,
173            &resolved_commit,
174        ],
175        &None,
176    )?;
177
178    // Update current write branch with pending write
179    // We update the target ref directly (no symref-verify needed in git 2.43.0)
180    // The old-oid verification ensures atomicity of the target ref update
181    // If the symref was redirected between reading it and updating, the write goes
182    // to the old target which will still be merged during consolidation
183    git_update_ref(unindent(
184        format!(
185            r#"
186            start
187            update {current_symbolic_ref_target} {temp_target} {current_note_head}
188            commit
189            "#
190        )
191        .as_str(),
192    ))?;
193
194    Ok(())
195}
196
197fn ensure_remote_exists() -> Result<(), GitError> {
198    if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
199        return Ok(());
200    }
201
202    if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
203        return set_git_perf_remote(GIT_PERF_REMOTE, &x);
204    }
205
206    Err(GitError::NoUpstream {})
207}
208
209/// Creates a temporary reference name by combining a prefix with a random suffix.
210fn create_temp_ref_name(prefix: &str) -> String {
211    let suffix = random_suffix();
212    format!("{prefix}{suffix}")
213}
214
215fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
216    if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
217        let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
218
219        // Use git symbolic-ref to create the symbolic reference
220        // This is not atomic with other ref operations, but that's acceptable
221        // as this only runs once during initialization
222        git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
223            |err| {
224                // If ref already exists (race with another process), that's fine
225                if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
226                    Ok(())
227                } else {
228                    Err(err)
229                }
230            },
231        )?;
232    }
233    Ok(())
234}
235
236fn random_suffix() -> String {
237    let suffix: u32 = rng().random::<u32>();
238    format!("{suffix:08x}")
239}
240
241fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
242    ensure_remote_exists()?;
243
244    let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
245    // Use git directly to avoid having to implement ssh-agent and/or extraHeader handling
246    capture_git_output(
247        &[
248            "fetch",
249            "--atomic",
250            "--no-write-fetch-head",
251            GIT_PERF_REMOTE,
252            // Always force overwrite the local reference
253            // Separation into write, merge, and read branches ensures that this does not lead to
254            // any data loss
255            format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
256        ],
257        &work_dir,
258    )
259    .map_err(map_git_error)?;
260
261    let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
262
263    if ref_before == ref_after {
264        println!("Already up to date");
265    }
266
267    Ok(())
268}
269
270fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
271    _ = capture_git_output(
272        &[
273            "notes",
274            "--ref",
275            target,
276            "merge",
277            "-s",
278            "cat_sort_uniq",
279            branch,
280        ],
281        &None,
282    )?;
283    Ok(())
284}
285
286fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
287    let target = create_temp_ref_name(prefix);
288    if current_head != EMPTY_OID {
289        git_update_ref(unindent(
290            format!(
291                r#"
292            start
293            create {target} {current_head}
294            commit
295            "#
296            )
297            .as_str(),
298        ))?;
299    }
300    Ok(target)
301}
302
303fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
304    create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
305}
306
307fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
308    create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
309}
310
311fn compact_head(target: &str) -> Result<(), GitError> {
312    let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
313
314    // Orphan compaction commit
315    let compaction_head = capture_git_output(
316        &["commit-tree", "-m", "cutoff history", &new_removal_head],
317        &None,
318    )?
319    .stdout;
320
321    let compaction_head = compaction_head.trim();
322
323    git_update_ref(unindent(
324        format!(
325            r#"
326            start
327            update {target} {compaction_head}
328            commit
329            "#
330        )
331        .as_str(),
332    ))?;
333
334    Ok(())
335}
336
337fn retry_notify(err: GitError, dur: Duration) {
338    debug!("Error happened at {dur:?}: {err}");
339    warn!("Retrying...");
340}
341
342pub fn remove_measurements_from_commits(
343    older_than: DateTime<Utc>,
344    prune: bool,
345    dry_run: bool,
346) -> Result<()> {
347    if dry_run {
348        // In dry-run mode, don't use backoff retry since we're not modifying anything
349        return raw_remove_measurements_from_commits(older_than, prune, dry_run)
350            .map_err(|e| anyhow!(e));
351    }
352
353    let op = || -> Result<(), ::backoff::Error<GitError>> {
354        raw_remove_measurements_from_commits(older_than, prune, dry_run)
355            .map_err(map_git_error_for_backoff)
356    };
357
358    let backoff = default_backoff();
359
360    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
361        ::backoff::Error::Permanent(err) => {
362            anyhow!(err).context("Permanent failure while removing measurements")
363        }
364        ::backoff::Error::Transient { err, .. } => {
365            anyhow!(err).context("Timed out while removing measurements")
366        }
367    })?;
368
369    Ok(())
370}
371
372fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
373where
374    F: FnOnce(&str) -> Result<(), GitError>,
375{
376    pull_internal(None)?;
377
378    let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
379    let target = create_temp_rewrite_head(&current_notes_head)?;
380
381    operation(&target)?;
382
383    compact_head(&target)?;
384
385    git_push_notes_ref(&current_notes_head, &target, &None, None)?;
386
387    git_update_ref(unindent(
388        format!(
389            r#"
390            start
391            update {REFS_NOTES_BRANCH} {target}
392            commit
393            "#
394        )
395        .as_str(),
396    ))?;
397
398    remove_reference(&target)?;
399
400    Ok(())
401}
402
403fn raw_remove_measurements_from_commits(
404    older_than: DateTime<Utc>,
405    prune: bool,
406    dry_run: bool,
407) -> Result<(), GitError> {
408    // Check for shallow repo once at the beginning (needed for prune)
409    if prune && is_shallow_repo()? {
410        return Err(GitError::ShallowRepository);
411    }
412
413    if dry_run {
414        // In dry-run mode, skip the execute_notes_operation wrapper since we don't modify anything
415        remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
416        if prune {
417            println!("[DRY-RUN] Would prune orphaned measurements after removal");
418        }
419        return Ok(());
420    }
421
422    execute_notes_operation(|target| {
423        // Remove measurements older than the specified date
424        remove_measurements_from_reference(target, older_than, dry_run)?;
425
426        // Prune orphaned measurements if requested
427        if prune {
428            capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
429        }
430
431        Ok(())
432    })
433}
434
435// Remove notes pertaining to git commits whose commit date is older than specified.
436fn remove_measurements_from_reference(
437    reference: &str,
438    older_than: DateTime<Utc>,
439    dry_run: bool,
440) -> Result<(), GitError> {
441    let oldest_timestamp = older_than.timestamp();
442    // Outputs line-by-line <note_oid> <annotated_oid>
443    let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
444    let notes_out = list_notes.stdout.take().unwrap();
445
446    let mut get_commit_dates = spawn_git_command(
447        &[
448            "log",
449            "--ignore-missing",
450            "--no-walk",
451            "--pretty=format:%H %ct",
452            "--stdin",
453        ],
454        &None,
455        Some(Stdio::piped()),
456    )?;
457    let dates_in = get_commit_dates.stdin.take().unwrap();
458    let dates_out = get_commit_dates.stdout.take().unwrap();
459
460    if dry_run {
461        // In dry-run mode, collect and display what would be removed without actually removing
462        let date_collection_handler = thread::spawn(move || {
463            let reader = BufReader::new(dates_out);
464            let mut results = Vec::new();
465            for line in reader.lines().map_while(Result::ok) {
466                if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
467                    if let Ok(timestamp) = timestamp.parse::<i64>() {
468                        if timestamp <= oldest_timestamp {
469                            results.push(commit.to_string());
470                        }
471                    }
472                }
473            }
474            results
475        });
476
477        {
478            let reader = BufReader::new(notes_out);
479            let mut writer = BufWriter::new(dates_in);
480
481            reader.lines().map_while(Result::ok).for_each(|line| {
482                if let Some(line) = line.split_whitespace().nth(1) {
483                    writeln!(writer, "{line}").expect("Failed to write to pipe");
484                }
485            });
486        }
487
488        let commits_to_remove = date_collection_handler
489            .join()
490            .expect("Failed to join date collection thread");
491        let count = commits_to_remove.len();
492
493        list_notes.wait()?;
494        get_commit_dates.wait()?;
495
496        if count == 0 {
497            println!(
498                "[DRY-RUN] No measurements older than {} would be removed",
499                older_than
500            );
501        } else {
502            println!(
503                "[DRY-RUN] Would remove measurements from {} commits older than {}",
504                count, older_than
505            );
506            for commit in &commits_to_remove {
507                println!("  {}", commit);
508            }
509        }
510
511        return Ok(());
512    }
513
514    // Normal mode: actually remove measurements
515    let mut remove_measurements = spawn_git_command(
516        &[
517            "notes",
518            "--ref",
519            reference,
520            "remove",
521            "--stdin",
522            "--ignore-missing",
523        ],
524        &None,
525        Some(Stdio::piped()),
526    )?;
527    let removal_in = remove_measurements.stdin.take().unwrap();
528    let removal_out = remove_measurements.stdout.take().unwrap();
529
530    let removal_handler = thread::spawn(move || {
531        let reader = BufReader::new(dates_out);
532        let mut writer = BufWriter::new(removal_in);
533        for line in reader.lines().map_while(Result::ok) {
534            if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
535                if let Ok(timestamp) = timestamp.parse::<i64>() {
536                    if timestamp <= oldest_timestamp {
537                        writeln!(writer, "{commit}").expect("Could not write to stream");
538                    }
539                }
540            }
541        }
542    });
543
544    let debugging_handler = thread::spawn(move || {
545        let reader = BufReader::new(removal_out);
546        reader
547            .lines()
548            .map_while(Result::ok)
549            .for_each(|l| println!("{l}"))
550    });
551
552    {
553        let reader = BufReader::new(notes_out);
554        let mut writer = BufWriter::new(dates_in);
555
556        reader.lines().map_while(Result::ok).for_each(|line| {
557            if let Some(line) = line.split_whitespace().nth(1) {
558                writeln!(writer, "{line}").expect("Failed to write to pipe");
559            }
560        });
561    }
562
563    removal_handler.join().expect("Failed to join");
564    debugging_handler.join().expect("Failed to join");
565
566    list_notes.wait()?;
567    get_commit_dates.wait()?;
568    remove_measurements.wait()?;
569
570    Ok(())
571}
572
573fn new_symbolic_write_ref() -> Result<String, GitError> {
574    let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
575
576    // Use git symbolic-ref to update the symbolic reference target
577    // This is not atomic with other ref operations, but any concurrent writes
578    // that go to the old target will still be merged during consolidation
579    git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
580    Ok(target)
581}
582
583const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
584
585fn consolidate_write_branches_into(
586    current_upstream_oid: &str,
587    target: &str,
588    except_ref: Option<&str>,
589) -> Result<Vec<Reference>, GitError> {
590    // - Reset the merge ref to the upstream perf ref iff it still matches the captured OID
591    //   - otherwise concurrent pull occurred.
592    git_update_ref(unindent(
593        format!(
594            r#"
595                start
596                verify {REFS_NOTES_BRANCH} {current_upstream_oid}
597                update {target} {current_upstream_oid} {EMPTY_OID}
598                commit
599            "#
600        )
601        .as_str(),
602    ))?;
603
604    // - merge in all existing write refs, except for the newly created one from first step
605    //     - Same step (except for filtering of the new ref) happens on local read as well.)
606    //     - Relies on unrelated histories, cat_sort_uniq merge strategy
607    //     - Allows to cut off the history on upstream periodically
608    let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
609    let refs = get_refs(additional_args)?
610        .into_iter()
611        .filter(|r| r.refname != except_ref.unwrap_or_default())
612        .collect_vec();
613
614    for reference in &refs {
615        reconcile_branch_with(target, &reference.oid)?;
616    }
617
618    Ok(refs)
619}
620
621fn remove_reference(ref_name: &str) -> Result<(), GitError> {
622    git_update_ref(unindent(
623        format!(
624            r#"
625                    start
626                    delete {ref_name}
627                    commit
628                "#
629        )
630        .as_str(),
631    ))
632}
633
634fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
635    ensure_remote_exists()?;
636    // This might merge concurrently created write branches. There is no protection against that.
637    // This wants to achieve an at-least-once semantic. The exactly-once semantic is ensured by the
638    // cat_sort_uniq merge strategy.
639
640    // - Reset the symbolic-ref "write" to a new unique write ref.
641    //     - Allows to continue committing measurements while pushing.
642    //     - ?? What happens when a git notes amend concurrently still writes to the old ref?
643    let new_write_ref = new_symbolic_write_ref()?;
644
645    let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
646
647    defer!(remove_reference(&merge_ref).expect("Deleting our own branch should never fail"));
648
649    // - 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.
650    //     - Same step (except for filtering of the new ref) happens on local read as well.)
651    //     - Relies on unrelated histories, cat_sort_uniq merge strategy
652    //     - Allows to cut off the history on upstream periodically
653    // NEW
654    // - Note down the current upstream perf ref oid
655    let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
656    let refs =
657        consolidate_write_branches_into(&current_upstream_oid, &merge_ref, Some(&new_write_ref))?;
658
659    if refs.is_empty() && current_upstream_oid == EMPTY_OID {
660        return Err(GitError::MissingMeasurements);
661    }
662
663    git_push_notes_ref(&current_upstream_oid, &merge_ref, &work_dir, remote)?;
664
665    // It is acceptable to fetch here independent of the push. Only one concurrent push will succeed.
666    fetch(None)?;
667
668    // Delete merged-in write references
669    let mut commands = Vec::new();
670    commands.push(String::from("start"));
671    for Reference { refname, oid } in &refs {
672        commands.push(format!("delete {refname} {oid}"));
673    }
674    commands.push(String::from("commit"));
675    // empty line
676    commands.push(String::new());
677    let commands = commands.join("\n");
678    git_update_ref(commands)?;
679
680    Ok(())
681}
682
683fn git_push_notes_ref(
684    expected_upstream: &str,
685    push_ref: &str,
686    working_dir: &Option<&Path>,
687    remote: Option<&str>,
688) -> Result<(), GitError> {
689    // - CAS push the temporary merge ref to upstream using the noted down upstream ref
690    //     - In case of concurrent pushes, back off and restart fresh from previous step.
691    let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
692    let output = capture_git_output(
693        &[
694            "push",
695            "--porcelain",
696            format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
697            remote_name,
698            format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
699        ],
700        working_dir,
701    );
702
703    // - Clean your own temporary merge ref and all others with a merge commit older than x days.
704    //     - In case of crashes before clean up, old merge refs are eliminated eventually.
705
706    match output {
707        Ok(output) => {
708            print!("{}", &output.stdout);
709            Ok(())
710        }
711        Err(GitError::ExecError { output, .. }) => {
712            let successful_push = output.stdout.lines().any(|l| {
713                l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
714            });
715            if successful_push {
716                Ok(())
717            } else {
718                Err(GitError::RefFailedToPush { output })
719            }
720        }
721        Err(e) => Err(e),
722    }?;
723
724    Ok(())
725}
726
727pub fn prune() -> Result<()> {
728    let op = || -> Result<(), ::backoff::Error<GitError>> {
729        raw_prune().map_err(map_git_error_for_backoff)
730    };
731
732    let backoff = default_backoff();
733
734    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
735        ::backoff::Error::Permanent(err) => {
736            anyhow!(err).context("Permanent failure while pruning refs")
737        }
738        ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
739    })?;
740
741    Ok(())
742}
743
744fn raw_prune() -> Result<(), GitError> {
745    if is_shallow_repo()? {
746        return Err(GitError::ShallowRepository);
747    }
748
749    execute_notes_operation(|target| {
750        capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
751    })
752}
753
754/// Returns a list of all commit SHA-1 hashes that have performance measurements
755/// in the refs/notes/perf-v3 branch.
756///
757/// Each commit hash is returned as a 40-character hexadecimal string.
758pub fn list_commits_with_measurements() -> Result<Vec<String>> {
759    // Update local read branch to include pending writes (like walk_commits does)
760    let temp_ref = update_read_branch()?;
761
762    // Use git notes list to get all annotated commits
763    // Output format: <note_oid> <commit_oid>
764    let mut list_notes =
765        spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
766
767    let stdout = list_notes
768        .stdout
769        .take()
770        .ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
771
772    // Parse output line by line: each line is "note_sha commit_sha"
773    // We want the commit_sha (second column)
774    // Process directly from BufReader for efficiency
775    let commits: Vec<String> = BufReader::new(stdout)
776        .lines()
777        .filter_map(|line_result| {
778            line_result
779                .ok()
780                .and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
781        })
782        .collect();
783
784    Ok(commits)
785}
786
787/// Guard for a temporary read branch that includes all pending writes.
788/// Automatically cleans up the temporary reference when dropped.
789pub struct ReadBranchGuard {
790    temp_ref: TempRef,
791}
792
793impl ReadBranchGuard {
794    /// Get the reference name for use in git commands
795    #[must_use]
796    pub fn ref_name(&self) -> &str {
797        &self.temp_ref.ref_name
798    }
799}
800
801/// Creates a temporary read branch that consolidates all pending writes.
802/// The returned guard must be kept alive for as long as the reference is needed.
803/// The temporary reference is automatically cleaned up when the guard is dropped.
804pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
805    let temp_ref = update_read_branch()?;
806    Ok(ReadBranchGuard { temp_ref })
807}
808
809fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
810    let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
811    args.extend(additional_args.iter().map(|s| s.as_str()));
812
813    let output = capture_git_output(&args, &None)?;
814    let refs: Result<Vec<Reference>, _> = output
815        .stdout
816        .lines()
817        .filter(|s| !s.is_empty())
818        .map(|s| {
819            let items = s.split('\0').take(2).collect_vec();
820            if items.len() != 2 {
821                return Err(GitError::ExecError {
822                    command: format!("git {}", args.join(" ")),
823                    output: GitOutput {
824                        stdout: format!("Unexpected git for-each-ref output format: {}", s),
825                        stderr: String::new(),
826                    },
827                });
828            }
829            Ok(Reference {
830                refname: items[0].to_string(),
831                oid: items[1].to_string(),
832            })
833        })
834        .collect();
835    refs
836}
837
838struct TempRef {
839    ref_name: String,
840}
841
842impl TempRef {
843    fn new(prefix: &str) -> Result<Self, GitError> {
844        Ok(TempRef {
845            ref_name: create_temp_ref(prefix, EMPTY_OID)?,
846        })
847    }
848}
849
850impl Drop for TempRef {
851    fn drop(&mut self) {
852        remove_reference(&self.ref_name)
853            .unwrap_or_else(|_| panic!("Failed to remove reference: {}", self.ref_name))
854    }
855}
856
857fn update_read_branch() -> Result<TempRef> {
858    let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
859        .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
860    // Create a fresh read branch from the remote and consolidate all pending write branches.
861    // This ensures the read branch is always up to date with the remote branch, even after
862    // a history cutoff, by checking against the current upstream state.
863    let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
864
865    consolidate_write_branches_into(&current_upstream_oid, &temp_ref.ref_name, None)
866        .map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
867
868    Ok(temp_ref)
869}
870
871/// Retrieves raw git notes data for commits starting from a specific commit.
872///
873/// This function performs a low-level git log operation to extract commit hashes
874/// and their associated raw note lines from the performance notes ref. It updates
875/// the local read branch, resolves the starting commit, and traverses up to
876/// `num_commits` following the first-parent ancestry chain.
877///
878/// # Arguments
879///
880/// * `start_commit` - The committish reference to start walking from (e.g., "HEAD", "main", commit hash)
881/// * `num_commits` - Maximum number of commits to retrieve
882///
883/// # Returns
884///
885/// Returns a vector of `CommitWithNotes`, where each entry contains:
886/// - The commit SHA-1 hash
887/// - The commit title (subject line)
888/// - The commit author name
889/// - A vector of raw note lines associated with that commit
890///
891/// # Errors
892///
893/// Returns an error if:
894/// - The starting commit cannot be resolved or does not exist
895/// - Git log operation fails
896/// - The repository is a shallow clone (issues a warning but may still succeed)
897/// - Git log output format is invalid
898///
899/// # Warnings
900///
901/// If a shallow clone is detected (grafted commits), a warning is issued as this
902/// may result in incomplete history traversal.
903///
904/// # Examples
905///
906/// ```no_run
907/// # use git_perf::git::git_interop::walk_commits_from;
908/// let commits = walk_commits_from("HEAD", 5).unwrap();
909/// for commit in commits {
910///     println!("Commit: {} by {}: {}", commit.sha, commit.author, commit.title);
911/// }
912/// ```
913pub fn walk_commits_from(start_commit: &str, num_commits: usize) -> Result<Vec<CommitWithNotes>> {
914    // update local read branch
915    let temp_ref = update_read_branch()?;
916
917    // Resolve and validate the starting commit to ensure it exists
918    let resolved_commit = resolve_committish(start_commit)
919        .context(format!("Failed to resolve commit '{}'", start_commit))?;
920
921    let output = capture_git_output(
922        &[
923            "--no-pager",
924            "log",
925            "--no-color",
926            "--ignore-missing",
927            "-n",
928            num_commits.to_string().as_str(),
929            "--first-parent",
930            "--pretty=--,%H,%s,%an,%D%n%N",
931            "--decorate=full",
932            format!("--notes={}", temp_ref.ref_name).as_str(),
933            &resolved_commit,
934        ],
935        &None,
936    )
937    .context(format!("Failed to retrieve commits from {}", start_commit))?;
938
939    let mut commits: Vec<CommitWithNotes> = Vec::new();
940    let mut detected_shallow = false;
941    let mut current_commit_sha: Option<String> = None;
942
943    for l in output.stdout.lines() {
944        if l.starts_with("--") {
945            // Parse format: --,<sha>,<title>,<author>,<decorations>
946            let parts: Vec<&str> = l.splitn(5, ',').collect();
947            if parts.len() < 5 {
948                bail!(
949                    "Invalid git log format: expected 5 fields, got {}",
950                    parts.len()
951                );
952            }
953
954            let sha = parts[1].to_string();
955            let title = if parts[2].is_empty() {
956                "[no subject]".to_string()
957            } else {
958                parts[2].to_string()
959            };
960            let author = if parts[3].is_empty() {
961                "[unknown]".to_string()
962            } else {
963                parts[3].to_string()
964            };
965            let decorations = parts[4];
966
967            detected_shallow |= decorations.contains("grafted");
968            current_commit_sha = Some(sha.clone());
969
970            commits.push(CommitWithNotes {
971                sha,
972                title,
973                author,
974                note_lines: Vec::new(),
975            });
976        } else if current_commit_sha.is_some() {
977            if let Some(last) = commits.last_mut() {
978                last.note_lines.push(l.to_string());
979            }
980        }
981    }
982
983    if detected_shallow && commits.len() < num_commits {
984        bail!("Refusing to continue as commit log depth was limited by shallow clone");
985    }
986
987    Ok(commits)
988}
989
990/// Walk commits starting from HEAD (convenience wrapper)
991pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
992    walk_commits_from("HEAD", num_commits)
993}
994
995pub fn pull(work_dir: Option<&Path>) -> Result<()> {
996    pull_internal(work_dir)?;
997    Ok(())
998}
999
1000fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
1001    fetch(work_dir)?;
1002    Ok(())
1003}
1004
1005pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
1006    let op = || {
1007        raw_push(work_dir, remote)
1008            .map_err(map_git_error_for_backoff)
1009            .map_err(|e: ::backoff::Error<GitError>| match e {
1010                ::backoff::Error::Transient { .. } => {
1011                    // Attempt to pull to resolve conflicts
1012                    let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
1013
1014                    // A concurrent modification comes from a concurrent fetch.
1015                    // Don't fail for that - it's safe to assume we successfully pulled
1016                    // in the context of the retry logic.
1017                    let pull_succeeded = pull_result.is_ok()
1018                        || matches!(
1019                            pull_result,
1020                            Err(::backoff::Error::Permanent(
1021                                GitError::RefConcurrentModification { .. }
1022                                    | GitError::RefFailedToLock { .. }
1023                            ))
1024                        );
1025
1026                    if pull_succeeded {
1027                        // Pull succeeded or failed with expected concurrent errors,
1028                        // return the original push error to retry
1029                        e
1030                    } else {
1031                        // Pull failed with unexpected error, propagate it
1032                        pull_result.unwrap_err()
1033                    }
1034                }
1035                ::backoff::Error::Permanent { .. } => e,
1036            })
1037    };
1038
1039    let backoff = default_backoff();
1040
1041    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
1042        ::backoff::Error::Permanent(err) => {
1043            anyhow!(err).context("Permanent failure while pushing refs")
1044        }
1045        ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
1046    })?;
1047
1048    Ok(())
1049}
1050
1051#[cfg(test)]
1052mod test {
1053    use super::*;
1054    use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
1055    use std::process::Command;
1056
1057    use httptest::{
1058        http::{header::AUTHORIZATION, Uri},
1059        matchers::{self, request},
1060        responders::status_code,
1061        Expectation, Server,
1062    };
1063
1064    fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
1065        let url = origin_url.to_string();
1066
1067        run_git_command(&["remote", "add", "origin", &url], dir);
1068        run_git_command(
1069            &[
1070                "config",
1071                "--add",
1072                format!("http.{}.extraHeader", url).as_str(),
1073                extra_header,
1074            ],
1075            dir,
1076        );
1077    }
1078
1079    #[test]
1080    fn test_customheader_pull() {
1081        with_isolated_cwd_git(|git_dir| {
1082            let mut test_server = Server::run();
1083            add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
1084
1085            test_server.expect(
1086                Expectation::matching(request::headers(matchers::contains((
1087                    AUTHORIZATION.as_str(),
1088                    "sometoken",
1089                ))))
1090                .times(1..)
1091                .respond_with(status_code(200)),
1092            );
1093
1094            // The pull operation will fail because the mock server doesn't provide a valid git
1095            // response, but we verify that the authorization header was sent by checking that
1096            // the server's expectations are met (httptest will panic on drop if not).
1097            let _ = pull(None); // Ignore result - we only care that auth header was sent
1098
1099            // Explicitly verify server expectations were met
1100            test_server.verify_and_clear();
1101        });
1102    }
1103
1104    #[test]
1105    fn test_customheader_push() {
1106        with_isolated_cwd_git(|git_dir| {
1107            let test_server = Server::run();
1108            add_server_remote(
1109                test_server.url(""),
1110                "AUTHORIZATION: someothertoken",
1111                git_dir,
1112            );
1113
1114            test_server.expect(
1115                Expectation::matching(request::headers(matchers::contains((
1116                    AUTHORIZATION.as_str(),
1117                    "someothertoken",
1118                ))))
1119                .times(1..)
1120                .respond_with(status_code(200)),
1121            );
1122
1123            // Must add a single write as a push without pending local writes just succeeds
1124            ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
1125            add_note_line_to_head("test note line").expect("Failed to add note line");
1126
1127            let error = push(None, None);
1128            error
1129                .as_ref()
1130                .expect_err("We have no valid git http server setup -> should fail");
1131            dbg!(&error);
1132        });
1133    }
1134
1135    #[test]
1136    fn test_random_suffix() {
1137        for _ in 1..1000 {
1138            let first = random_suffix();
1139            dbg!(&first);
1140            let second = random_suffix();
1141            dbg!(&second);
1142
1143            let all_hex = |s: &String| s.chars().all(|c| c.is_ascii_hexdigit());
1144
1145            assert_ne!(first, second);
1146            assert_eq!(first.len(), 8);
1147            assert_eq!(second.len(), 8);
1148            assert!(all_hex(&first));
1149            assert!(all_hex(&second));
1150        }
1151    }
1152
1153    #[test]
1154    fn test_empty_or_never_pushed_remote_error_for_fetch() {
1155        with_isolated_cwd_git(|git_dir| {
1156            // Add a dummy remote so the code can check for empty remote
1157            let git_dir_url = format!("file://{}", git_dir.display());
1158            run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
1159
1160            // NOTE: GIT_TRACE is required for this test to function correctly
1161            std::env::set_var("GIT_TRACE", "true");
1162
1163            // Do not add any notes/measurements or push anything
1164            let result = super::fetch(Some(git_dir));
1165            match result {
1166                Err(GitError::NoRemoteMeasurements { output }) => {
1167                    assert!(
1168                        output.stderr.contains(GIT_PERF_REMOTE),
1169                        "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
1170                        output.stderr
1171                    )
1172                }
1173                other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
1174            }
1175        });
1176    }
1177
1178    #[test]
1179    fn test_empty_or_never_pushed_remote_error_for_push() {
1180        with_isolated_cwd_git(|git_dir| {
1181            run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
1182
1183            // NOTE: GIT_TRACE is required for this test to function correctly
1184            std::env::set_var("GIT_TRACE", "true");
1185
1186            add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
1187
1188            let result = super::raw_push(Some(git_dir), None);
1189            match result {
1190                Err(GitError::RefFailedToPush { output }) => {
1191                    assert!(
1192                        output.stderr.contains(GIT_PERF_REMOTE),
1193                        "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
1194                        output.stderr
1195                    )
1196                }
1197                other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1198            }
1199        });
1200    }
1201
1202    /// Test that new_symbolic_write_ref returns valid, non-empty reference names
1203    /// Targets missed mutants:
1204    /// - Could return Ok(String::new()) - empty string
1205    /// - Could return Ok("xyzzy".into()) - arbitrary invalid string
1206    #[test]
1207    fn test_new_symbolic_write_ref_returns_valid_ref() {
1208        with_isolated_cwd_git(|_git_dir| {
1209            // Test the private function directly since we're in the same module
1210            let result = new_symbolic_write_ref();
1211            assert!(
1212                result.is_ok(),
1213                "Should create symbolic write ref: {:?}",
1214                result
1215            );
1216
1217            let ref_name = result.unwrap();
1218
1219            // Mutation 1: Should not be empty string
1220            assert!(
1221                !ref_name.is_empty(),
1222                "Reference name should not be empty, got: '{}'",
1223                ref_name
1224            );
1225
1226            // Mutation 2: Should not be arbitrary string like "xyzzy"
1227            assert!(
1228                ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
1229                "Reference should start with {}, got: {}",
1230                REFS_NOTES_WRITE_TARGET_PREFIX,
1231                ref_name
1232            );
1233
1234            // Should have a hex suffix
1235            let suffix = ref_name
1236                .strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
1237                .expect("Should have prefix");
1238            assert!(
1239                !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()),
1240                "Suffix should be non-empty hex string, got: {}",
1241                suffix
1242            );
1243        });
1244    }
1245
1246    /// Test that notes can be added successfully via add_note_line_to_head
1247    /// Verifies end-to-end note operations work correctly
1248    #[test]
1249    fn test_add_and_retrieve_notes() {
1250        with_isolated_cwd_git(|_git_dir| {
1251            // Add first note - this calls ensure_symbolic_write_ref_exists -> new_symbolic_write_ref
1252            let result = add_note_line_to_head("test: 100");
1253            assert!(
1254                result.is_ok(),
1255                "Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
1256                result
1257            );
1258
1259            // Add second note to ensure ref operations continue to work
1260            let result2 = add_note_line_to_head("test: 200");
1261            assert!(result2.is_ok(), "Should add second note: {:?}", result2);
1262
1263            // Verify notes were actually added by walking commits
1264            let commits = walk_commits(10);
1265            assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
1266
1267            let commits = commits.unwrap();
1268            assert!(!commits.is_empty(), "Should have commits");
1269
1270            // Check that HEAD commit has notes
1271            let commit_with_notes = &commits[0];
1272            assert!(
1273                !commit_with_notes.note_lines.is_empty(),
1274                "HEAD should have notes"
1275            );
1276            assert!(
1277                commit_with_notes
1278                    .note_lines
1279                    .iter()
1280                    .any(|n| n.contains("test:")),
1281                "Notes should contain our test data"
1282            );
1283        });
1284    }
1285
1286    /// Test walk_commits with shallow repository containing multiple grafted commits
1287    /// Targets missed mutant at line 725: detected_shallow |= vs ^=
1288    /// The XOR operator would toggle instead of OR, failing with multiple grafts
1289    #[test]
1290    fn test_walk_commits_shallow_repo_detection() {
1291        use std::env::set_current_dir;
1292
1293        with_isolated_cwd_git(|git_dir| {
1294            // Create multiple commits
1295            for i in 2..=5 {
1296                run_git_command(
1297                    &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1298                    git_dir,
1299                );
1300            }
1301
1302            // Create a shallow clone (depth 2) which will have grafted commits
1303            let shallow_dir = git_dir.join("shallow");
1304            let output = Command::new("git")
1305                .args([
1306                    "clone",
1307                    "--depth",
1308                    "2",
1309                    git_dir.to_str().unwrap(),
1310                    shallow_dir.to_str().unwrap(),
1311                ])
1312                .output()
1313                .unwrap();
1314
1315            assert!(
1316                output.status.success(),
1317                "Shallow clone failed: {}",
1318                String::from_utf8_lossy(&output.stderr)
1319            );
1320
1321            // Change to shallow clone directory
1322            set_current_dir(&shallow_dir).unwrap();
1323
1324            // Add a note to enable walk_commits
1325            add_note_line_to_head("test: 100").expect("Should add note");
1326
1327            // Walk commits - should detect as shallow
1328            let result = walk_commits(10);
1329            assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
1330
1331            let commits = result.unwrap();
1332
1333            // In a shallow repo, git log --boundary shows grafted markers
1334            // The |= operator correctly sets detected_shallow to true
1335            // The ^= mutant would toggle the flag, potentially giving wrong result
1336
1337            // Verify we got commits (the function works)
1338            assert!(
1339                !commits.is_empty(),
1340                "Should have found commits in shallow repo"
1341            );
1342        });
1343    }
1344
1345    /// Test walk_commits correctly identifies normal (non-shallow) repos
1346    #[test]
1347    fn test_walk_commits_normal_repo_not_shallow() {
1348        with_isolated_cwd_git(|git_dir| {
1349            // Create a few commits
1350            for i in 2..=3 {
1351                run_git_command(
1352                    &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1353                    git_dir,
1354                );
1355            }
1356
1357            // Add a note to enable walk_commits
1358            add_note_line_to_head("test: 100").expect("Should add note");
1359
1360            let result = walk_commits(10);
1361            assert!(result.is_ok(), "walk_commits should succeed");
1362
1363            let commits = result.unwrap();
1364
1365            // Should have commits
1366            assert!(!commits.is_empty(), "Should have found commits");
1367        });
1368    }
1369}