Skip to main content

git_perf/git/
git_interop.rs

1use std::{
2    fs::{File, OpenOptions},
3    io::{BufRead, BufReader, BufWriter, Write},
4    path::Path,
5    process::Stdio,
6    thread,
7    time::Duration,
8};
9
10use defer::defer;
11use log::{debug, info, warn};
12use unindent::unindent;
13
14use anyhow::{anyhow, bail, Context, Result};
15use backoff::{ExponentialBackoff, ExponentialBackoffBuilder};
16use itertools::Itertools;
17
18use chrono::prelude::*;
19use rand::{rng, RngExt};
20
21use crate::config;
22
23pub use super::git_definitions::REFS_NOTES_BRANCH;
24use super::git_definitions::{
25    GIT_ORIGIN, GIT_PERF_REMOTE, PUSH_LOCK_FILE_NAME, REFS_NOTES_ADD_TARGET_PREFIX,
26    REFS_NOTES_MERGE_BRANCH_PREFIX, REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX,
27    REFS_NOTES_WRITE_SYMBOLIC_REF, REFS_NOTES_WRITE_TARGET_PREFIX,
28};
29use super::git_lowlevel::{
30    capture_git_output, get_git_perf_remote, git_common_dir, git_rev_parse,
31    git_rev_parse_symbolic_ref, git_symbolic_ref_create_or_update, git_update_ref,
32    internal_get_head_revision, is_shallow_repo, map_git_error, set_git_perf_remote,
33    spawn_git_command,
34};
35use super::git_types::GitError;
36use super::git_types::GitOutput;
37use super::git_types::Reference;
38
39pub use super::git_lowlevel::get_head_revision;
40
41pub use super::git_lowlevel::check_git_version;
42
43pub use super::git_lowlevel::get_repository_root;
44
45pub use super::git_lowlevel::resolve_committish;
46
47/// Represents a commit with its associated git-notes data and metadata.
48///
49/// This structure is returned by `walk_commits_from` and contains:
50/// - The commit SHA
51/// - Commit title (subject line)
52/// - Author name
53/// - Raw note lines for deserialization
54#[derive(Debug, Clone, PartialEq)]
55pub struct CommitWithNotes {
56    pub sha: String,
57    pub title: String,
58    pub author: String,
59    pub note_lines: Vec<String>,
60}
61
62/// Check if the current repository is a shallow clone
63pub fn is_shallow_repository() -> Result<bool> {
64    super::git_lowlevel::is_shallow_repo()
65        .map_err(|e| anyhow!("Failed to check if repository is shallow: {}", e))
66}
67
68fn map_git_error_for_backoff(e: GitError) -> ::backoff::Error<GitError> {
69    match e {
70        GitError::RefFailedToPush { .. }
71        | GitError::RefFailedToLock { .. }
72        | GitError::RefConcurrentModification { .. }
73        | GitError::BadObject { .. } => ::backoff::Error::transient(e),
74        GitError::ExecError { .. }
75        | GitError::IoError(..)
76        | GitError::ShallowRepository
77        | GitError::MissingHead { .. }
78        | GitError::NoRemoteMeasurements { .. }
79        | GitError::NoUpstream { .. }
80        | GitError::MissingMeasurements => ::backoff::Error::permanent(e),
81    }
82}
83
84/// Central place to configure backoff policy for git-perf operations.
85fn default_backoff() -> ExponentialBackoff {
86    let max_elapsed = config::backoff_max_elapsed_seconds();
87    ExponentialBackoffBuilder::default()
88        .with_max_elapsed_time(Some(Duration::from_secs(max_elapsed)))
89        .build()
90}
91
92/// Appends a note line to a specific commit with exponential backoff retry logic.
93///
94/// This function adds a single line to the git notes associated with the specified
95/// commit in the performance notes ref (`refs/notes/perf-v3`). The operation is
96/// retried with exponential backoff to handle transient failures such as concurrent
97/// write conflicts or filesystem locks.
98///
99/// # Arguments
100///
101/// * `commit` - The commit hash (or committish reference) to add the note to
102/// * `line` - The text content to append to the commit's notes
103///
104/// # Returns
105///
106/// * `Ok(())` - The note line was successfully added
107/// * `Err` - If the operation fails permanently or times out after retries
108///
109/// # Errors
110///
111/// Returns an error if:
112/// - The commit does not exist
113/// - The operation times out after exhausting retry attempts
114/// - A permanent failure occurs (e.g., invalid commit reference)
115///
116/// # Examples
117///
118/// ```no_run
119/// # use git_perf::git::git_interop::add_note_line;
120/// add_note_line("HEAD", "benchmark_result=1.23s").unwrap();
121/// ```
122pub fn add_note_line(commit: &str, line: &str) -> Result<()> {
123    let op = || -> Result<(), ::backoff::Error<GitError>> {
124        raw_add_note_line(commit, line).map_err(map_git_error_for_backoff)
125    };
126
127    let backoff = default_backoff();
128
129    ::backoff::retry(backoff, op).map_err(|e| match e {
130        ::backoff::Error::Permanent(err) => anyhow!(err).context(format!(
131            "Permanent failure while adding note line to commit {}",
132            commit
133        )),
134        ::backoff::Error::Transient { err, .. } => anyhow!(err).context(format!(
135            "Timed out while adding note line to commit {}",
136            commit
137        )),
138    })?;
139
140    Ok(())
141}
142
143/// Add a note line to HEAD (convenience wrapper)
144pub fn add_note_line_to_head(line: &str) -> Result<()> {
145    let head = internal_get_head_revision()
146        .map_err(|e| anyhow!(e).context("Failed to get HEAD revision"))?;
147    add_note_line(&head, line)
148}
149
150fn raw_add_note_line(commit: &str, line: &str) -> Result<(), GitError> {
151    ensure_symbolic_write_ref_exists()?;
152
153    // `git notes append` is not safe to use concurrently.
154    // We create a new type of temporary reference: Cannot reuse the normal write references as
155    // they only get merged upon push. This can take arbitrarily long.
156    let current_note_head =
157        git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).unwrap_or(EMPTY_OID.to_string());
158    let current_symbolic_ref_target = git_rev_parse_symbolic_ref(REFS_NOTES_WRITE_SYMBOLIC_REF)
159        .ok_or_else(|| GitError::RefFailedToLock {
160            output: GitOutput {
161                stdout: String::new(),
162                stderr: format!(
163                    "symbolic-ref {REFS_NOTES_WRITE_SYMBOLIC_REF} vanished after creation"
164                ),
165            },
166        })?;
167    let temp_target = create_temp_add_head(&current_note_head)?;
168
169    defer!(if let Err(e) = remove_reference(&temp_target) {
170        warn!("Failed to delete temp add ref {temp_target}: {e:?}");
171    });
172
173    // Verify the target commit exists by resolving it
174    let resolved_commit = git_rev_parse(commit)?;
175
176    capture_git_output(
177        &[
178            "notes",
179            "--ref",
180            &temp_target,
181            "append",
182            "--no-separator",
183            "-m",
184            line,
185            &resolved_commit,
186        ],
187        &None,
188    )
189    .map_err(map_git_error)?;
190
191    // Update current write branch with pending write
192    // We update the target ref directly (no symref-verify needed in git 2.43.0)
193    // The old-oid verification ensures atomicity of the target ref update
194    // If the symref was redirected between reading it and updating, the write goes
195    // to the old target which will still be merged during consolidation
196    git_update_ref(unindent(
197        format!(
198            r#"
199            start
200            update {current_symbolic_ref_target} {temp_target} {current_note_head}
201            commit
202            "#
203        )
204        .as_str(),
205    ))?;
206
207    Ok(())
208}
209
210fn ensure_remote_exists() -> Result<(), GitError> {
211    if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
212        return Ok(());
213    }
214
215    if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
216        return set_git_perf_remote(GIT_PERF_REMOTE, &x);
217    }
218
219    Err(GitError::NoUpstream {})
220}
221
222/// Creates a temporary reference name by combining a prefix with a random suffix.
223fn create_temp_ref_name(prefix: &str) -> String {
224    let suffix = random_suffix();
225    format!("{prefix}{suffix}")
226}
227
228fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
229    if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
230        let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
231
232        // Use git symbolic-ref to create the symbolic reference
233        // This is not atomic with other ref operations, but that's acceptable
234        // as this only runs once during initialization
235        git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
236            |err| {
237                // If ref already exists (race with another process), that's fine
238                if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
239                    Ok(())
240                } else {
241                    Err(err)
242                }
243            },
244        )?;
245    }
246    Ok(())
247}
248
249fn random_suffix() -> String {
250    let pid = std::process::id();
251    let random: u32 = rng().random::<u32>();
252    format!("{pid:08x}-{random:08x}")
253}
254
255fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
256    ensure_remote_exists()?;
257
258    let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
259    // Use git directly to avoid having to implement ssh-agent and/or extraHeader handling
260    capture_git_output(
261        &[
262            "fetch",
263            "--atomic",
264            "--no-write-fetch-head",
265            GIT_PERF_REMOTE,
266            // Always force overwrite the local reference
267            // Separation into write, merge, and read branches ensures that this does not lead to
268            // any data loss
269            format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
270        ],
271        &work_dir,
272    )
273    .map_err(map_git_error)?;
274
275    let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
276
277    if ref_before == ref_after {
278        println!("Already up to date");
279    }
280
281    Ok(())
282}
283
284/// Merges notes from one branch into a target using the cat_sort_uniq strategy.
285/// This is used to consolidate measurements from multiple write refs.
286fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
287    _ = capture_git_output(
288        &[
289            "notes",
290            "--ref",
291            target,
292            "merge",
293            "-s",
294            "cat_sort_uniq",
295            branch,
296        ],
297        &None,
298    )?;
299    Ok(())
300}
301
302fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
303    let target = create_temp_ref_name(prefix);
304    if current_head != EMPTY_OID {
305        git_update_ref(unindent(
306            format!(
307                r#"
308            start
309            create {target} {current_head}
310            commit
311            "#
312            )
313            .as_str(),
314        ))?;
315    }
316    Ok(target)
317}
318
319fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
320    create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
321}
322
323fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
324    create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
325}
326
327fn compact_head(target: &str) -> Result<(), GitError> {
328    let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
329
330    // Orphan compaction commit
331    let compaction_head = capture_git_output(
332        &["commit-tree", "-m", "cutoff history", &new_removal_head],
333        &None,
334    )?
335    .stdout;
336
337    let compaction_head = compaction_head.trim();
338
339    git_update_ref(unindent(
340        format!(
341            r#"
342            start
343            update {target} {compaction_head}
344            commit
345            "#
346        )
347        .as_str(),
348    ))?;
349
350    Ok(())
351}
352
353fn retry_notify(err: GitError, dur: Duration) {
354    debug!("Error happened at {dur:?}: {err}");
355    warn!("Retrying...");
356}
357
358pub fn remove_measurements_from_commits(
359    older_than: DateTime<Utc>,
360    prune: bool,
361    dry_run: bool,
362) -> Result<()> {
363    if dry_run {
364        // In dry-run mode, don't use backoff retry since we're not modifying anything
365        return raw_remove_measurements_from_commits(older_than, prune, dry_run)
366            .map_err(|e| anyhow!(e));
367    }
368
369    let op = || -> Result<(), ::backoff::Error<GitError>> {
370        raw_remove_measurements_from_commits(older_than, prune, dry_run)
371            .map_err(map_git_error_for_backoff)
372    };
373
374    let backoff = default_backoff();
375
376    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
377        ::backoff::Error::Permanent(err) => {
378            anyhow!(err).context("Permanent failure while removing measurements")
379        }
380        ::backoff::Error::Transient { err, .. } => {
381            anyhow!(err).context("Timed out while removing measurements")
382        }
383    })?;
384
385    Ok(())
386}
387
388fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
389where
390    F: FnOnce(&str) -> Result<(), GitError>,
391{
392    pull_internal(None)?;
393
394    let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
395    let target = create_temp_rewrite_head(&current_notes_head)?;
396
397    operation(&target)?;
398
399    // Skip push if the operation made no changes to the notes tree.
400    // This avoids unnecessary upstream contention for no-op prune/remove calls.
401    if git_rev_parse(&target)? == current_notes_head {
402        if let Err(e) = remove_reference(&target) {
403            warn!("Failed to delete temp notes ref {target}: {e:?}");
404        }
405        return Ok(());
406    }
407
408    compact_head(&target)?;
409
410    git_push_notes_ref(&current_notes_head, &target, &None, None)?;
411
412    git_update_ref(unindent(
413        format!(
414            r#"
415            start
416            update {REFS_NOTES_BRANCH} {target}
417            commit
418            "#
419        )
420        .as_str(),
421    ))?;
422
423    if let Err(e) = remove_reference(&target) {
424        warn!("Failed to delete temp notes ref {target}: {e:?}");
425    }
426
427    Ok(())
428}
429
430fn raw_remove_measurements_from_commits(
431    older_than: DateTime<Utc>,
432    prune: bool,
433    dry_run: bool,
434) -> Result<(), GitError> {
435    // Check for shallow repo once at the beginning (needed for prune)
436    if prune && is_shallow_repo()? {
437        return Err(GitError::ShallowRepository);
438    }
439
440    if dry_run {
441        // In dry-run mode, skip the execute_notes_operation wrapper since we don't modify anything
442        remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
443        if prune {
444            println!("[DRY-RUN] Would prune orphaned measurements after removal");
445        }
446        return Ok(());
447    }
448
449    execute_notes_operation(|target| {
450        // Remove measurements older than the specified date
451        remove_measurements_from_reference(target, older_than, dry_run)?;
452
453        // Prune orphaned measurements if requested
454        if prune {
455            capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
456        }
457
458        Ok(())
459    })
460}
461
462// Remove notes pertaining to git commits whose commit date is older than specified.
463fn remove_measurements_from_reference(
464    reference: &str,
465    older_than: DateTime<Utc>,
466    dry_run: bool,
467) -> Result<(), GitError> {
468    let oldest_timestamp = older_than.timestamp();
469    // Outputs line-by-line <note_oid> <annotated_oid>
470    let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
471    let notes_out = list_notes.stdout.take().unwrap();
472
473    let mut get_commit_dates = spawn_git_command(
474        &[
475            "log",
476            "--ignore-missing",
477            "--no-walk",
478            "--pretty=format:%H %ct",
479            "--stdin",
480        ],
481        &None,
482        Some(Stdio::piped()),
483    )?;
484    let dates_in = get_commit_dates.stdin.take().unwrap();
485    let dates_out = get_commit_dates.stdout.take().unwrap();
486
487    if dry_run {
488        // In dry-run mode, collect and display what would be removed without actually removing
489        let date_collection_handler = thread::spawn(move || {
490            let reader = BufReader::new(dates_out);
491            let mut results = Vec::new();
492            for line in reader.lines().map_while(Result::ok) {
493                if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
494                    if let Ok(timestamp) = timestamp.parse::<i64>() {
495                        if timestamp <= oldest_timestamp {
496                            results.push(commit.to_string());
497                        }
498                    }
499                }
500            }
501            results
502        });
503
504        {
505            let reader = BufReader::new(notes_out);
506            let mut writer = BufWriter::new(dates_in);
507
508            reader.lines().map_while(Result::ok).for_each(|line| {
509                if let Some(line) = line.split_whitespace().nth(1) {
510                    writeln!(writer, "{line}").expect("Failed to write to pipe");
511                }
512            });
513        }
514
515        let commits_to_remove = date_collection_handler
516            .join()
517            .expect("Failed to join date collection thread");
518        let count = commits_to_remove.len();
519
520        list_notes.wait()?;
521        get_commit_dates.wait()?;
522
523        if count == 0 {
524            println!(
525                "[DRY-RUN] No measurements older than {} would be removed",
526                older_than
527            );
528        } else {
529            println!(
530                "[DRY-RUN] Would remove measurements from {} commits older than {}",
531                count, older_than
532            );
533            for commit in &commits_to_remove {
534                println!("  {}", commit);
535            }
536        }
537
538        return Ok(());
539    }
540
541    // Normal mode: actually remove measurements
542    let mut remove_measurements = spawn_git_command(
543        &[
544            "notes",
545            "--ref",
546            reference,
547            "remove",
548            "--stdin",
549            "--ignore-missing",
550        ],
551        &None,
552        Some(Stdio::piped()),
553    )?;
554    let removal_in = remove_measurements.stdin.take().unwrap();
555    let removal_out = remove_measurements.stdout.take().unwrap();
556
557    let removal_handler = thread::spawn(move || {
558        let reader = BufReader::new(dates_out);
559        let mut writer = BufWriter::new(removal_in);
560        for line in reader.lines().map_while(Result::ok) {
561            if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
562                if let Ok(timestamp) = timestamp.parse::<i64>() {
563                    if timestamp <= oldest_timestamp {
564                        writeln!(writer, "{commit}").expect("Could not write to stream");
565                    }
566                }
567            }
568        }
569    });
570
571    let debugging_handler = thread::spawn(move || {
572        let reader = BufReader::new(removal_out);
573        reader
574            .lines()
575            .map_while(Result::ok)
576            .for_each(|l| println!("{l}"))
577    });
578
579    {
580        let reader = BufReader::new(notes_out);
581        let mut writer = BufWriter::new(dates_in);
582
583        reader.lines().map_while(Result::ok).for_each(|line| {
584            if let Some(line) = line.split_whitespace().nth(1) {
585                writeln!(writer, "{line}").expect("Failed to write to pipe");
586            }
587        });
588    }
589
590    removal_handler.join().expect("Failed to join");
591    debugging_handler.join().expect("Failed to join");
592
593    list_notes.wait()?;
594    get_commit_dates.wait()?;
595    remove_measurements.wait()?;
596
597    Ok(())
598}
599
600/// Creates a new write ref and updates the symbolic ref to point to it.
601/// This is used to ensure concurrent writes go to a new location, preventing
602/// race conditions during operations like reset or push.
603/// Internal version that returns GitError.
604fn new_symbolic_write_ref() -> Result<String, GitError> {
605    let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
606
607    // Use git symbolic-ref to update the symbolic reference target
608    // This is not atomic with other ref operations, but any concurrent writes
609    // that go to the old target will still be merged during consolidation
610    git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
611    Ok(target)
612}
613
614/// Creates a new write ref and updates the symbolic ref to point to it (public wrapper).
615/// This is used to ensure concurrent writes go to a new location, preventing
616/// race conditions during operations like reset or push.
617pub fn create_new_write_ref() -> Result<String> {
618    new_symbolic_write_ref().map_err(|e| anyhow!("{:?}", e))
619}
620
621const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
622
623fn consolidate_write_branches_into(
624    current_upstream_oid: &str,
625    target: &str,
626    except_ref: Option<&str>,
627) -> Result<Vec<Reference>, GitError> {
628    // - Reset the merge ref to the upstream perf ref iff it still matches the captured OID
629    //   - otherwise concurrent pull occurred.
630    git_update_ref(unindent(
631        format!(
632            r#"
633                start
634                verify {REFS_NOTES_BRANCH} {current_upstream_oid}
635                update {target} {current_upstream_oid} {EMPTY_OID}
636                commit
637            "#
638        )
639        .as_str(),
640    ))?;
641
642    // - merge in all existing write refs, except for the newly created one from first step
643    //     - Same step (except for filtering of the new ref) happens on local read as well.)
644    //     - Relies on unrelated histories, cat_sort_uniq merge strategy
645    //     - Allows to cut off the history on upstream periodically
646    let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
647    let refs = get_refs(additional_args)?
648        .into_iter()
649        .filter(|r| r.refname != except_ref.unwrap_or_default())
650        .collect_vec();
651
652    for reference in &refs {
653        reconcile_branch_with(target, &reference.oid)?;
654    }
655
656    Ok(refs)
657}
658
659fn remove_reference(ref_name: &str) -> Result<(), GitError> {
660    git_update_ref(unindent(
661        format!(
662            r#"
663                    start
664                    delete {ref_name}
665                    commit
666                "#
667        )
668        .as_str(),
669    ))
670}
671
672fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
673    ensure_remote_exists()?;
674    // This might merge concurrently created write branches. There is no protection against that.
675    // This wants to achieve an at-least-once semantic. The exactly-once semantic is ensured by the
676    // cat_sort_uniq merge strategy.
677
678    // - Reset the symbolic-ref "write" to a new unique write ref.
679    //     - Allows to continue committing measurements while pushing.
680    //     - ?? What happens when a git notes amend concurrently still writes to the old ref?
681    let new_write_ref = new_symbolic_write_ref()?;
682
683    let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
684
685    defer!(if let Err(e) = remove_reference(&merge_ref) {
686        warn!("Failed to delete temp merge ref {merge_ref}: {e:?}");
687    });
688
689    // - 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.
690    //     - Same step (except for filtering of the new ref) happens on local read as well.)
691    //     - Relies on unrelated histories, cat_sort_uniq merge strategy
692    //     - Allows to cut off the history on upstream periodically
693    // NEW
694    // - Note down the current upstream perf ref oid
695    let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
696    let refs =
697        consolidate_write_branches_into(&current_upstream_oid, &merge_ref, Some(&new_write_ref))?;
698
699    if refs.is_empty() && current_upstream_oid == EMPTY_OID {
700        return Err(GitError::MissingMeasurements);
701    }
702
703    git_push_notes_ref(&current_upstream_oid, &merge_ref, &work_dir, remote)?;
704
705    // It is acceptable to fetch here independent of the push. Only one concurrent push will succeed.
706    fetch(None)?;
707
708    // Delete merged-in write references
709    let mut commands = Vec::new();
710    commands.push(String::from("start"));
711    for Reference { refname, oid } in &refs {
712        commands.push(format!("delete {refname} {oid}"));
713    }
714    commands.push(String::from("commit"));
715    // empty line
716    commands.push(String::new());
717    let commands = commands.join("\n");
718    git_update_ref(commands)?;
719
720    Ok(())
721}
722
723/// Acquires an exclusive, blocking advisory lock scoped to this repository.
724///
725/// Concurrent `git push` invocations from this machine can corrupt each
726/// other even though the ref update itself is protected by
727/// `--force-with-lease`: each `receive-pack` session gets its own private
728/// object quarantine, so a thin pack from one push can delta against an
729/// object that only exists in another, still in-flight push's quarantine.
730/// The remote's `index-pack` then fails with "unresolved deltas" and the
731/// push is rejected — a failure that happens during object transfer, before
732/// the CAS check ever runs, so *every* concurrent pusher can lose in the
733/// same round instead of exactly one of them winning the ref update. Holding
734/// this lock for the duration of a push ensures at most one `git push` to
735/// this repository's remotes is ever in flight, restoring the property that
736/// the CAS retry loop always makes progress.
737///
738/// The lock is released automatically when the returned `File` is dropped.
739fn acquire_push_lock(working_dir: &Option<&Path>) -> Result<File, GitError> {
740    let lock_path = git_common_dir(working_dir)?.join(PUSH_LOCK_FILE_NAME);
741    let lock_file = OpenOptions::new()
742        .create(true)
743        .write(true)
744        .truncate(false)
745        .open(lock_path)?;
746    lock_file.lock()?;
747    Ok(lock_file)
748}
749
750fn git_push_notes_ref(
751    expected_upstream: &str,
752    push_ref: &str,
753    working_dir: &Option<&Path>,
754    remote: Option<&str>,
755) -> Result<(), GitError> {
756    // - CAS push the temporary merge ref to upstream using the noted down upstream ref
757    //     - In case of concurrent pushes, back off and restart fresh from previous step.
758    // Serialize the actual push against any other push from this repository -
759    // see `acquire_push_lock` for why this is necessary beyond the CAS itself.
760    let _push_lock = acquire_push_lock(working_dir)?;
761
762    let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
763    let output = capture_git_output(
764        &[
765            "push",
766            "--porcelain",
767            format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
768            remote_name,
769            format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
770        ],
771        working_dir,
772    );
773
774    // - Clean your own temporary merge ref and all others with a merge commit older than x days.
775    //     - In case of crashes before clean up, old merge refs are eliminated eventually.
776
777    match output {
778        Ok(output) => {
779            print!("{}", output.stdout);
780            Ok(())
781        }
782        Err(GitError::ExecError { output, .. }) => {
783            let successful_push = output.stdout.lines().any(|l| {
784                l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
785            });
786            if successful_push {
787                Ok(())
788            } else {
789                Err(GitError::RefFailedToPush { output })
790            }
791        }
792        Err(e) => Err(e),
793    }?;
794
795    Ok(())
796}
797
798pub fn prune() -> Result<()> {
799    let op = || -> Result<(), ::backoff::Error<GitError>> {
800        raw_prune().map_err(map_git_error_for_backoff)
801    };
802
803    let backoff = default_backoff();
804
805    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
806        ::backoff::Error::Permanent(err) => {
807            anyhow!(err).context("Permanent failure while pruning refs")
808        }
809        ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
810    })?;
811
812    Ok(())
813}
814
815fn extract_pid_from_staging_ref(refname: &str, prefix: &str) -> Option<u32> {
816    let suffix = refname.strip_prefix(prefix)?;
817    // Old-format refs (8-hex-char only, no dash) predate PID-prefixed naming.
818    if !suffix.contains('-') {
819        return None;
820    }
821    let pid_hex = suffix.split('-').next()?;
822    u32::from_str_radix(pid_hex, 16).ok()
823}
824
825fn is_process_alive(pid: u32) -> bool {
826    use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
827    let pid = Pid::from(pid as usize);
828    let mut system = System::new();
829    system.refresh_processes_specifics(
830        ProcessesToUpdate::Some(&[pid]),
831        false,
832        ProcessRefreshKind::nothing(),
833    );
834    system.process(pid).is_some()
835}
836
837fn cleanup_orphan_staging_refs() {
838    // REFS_NOTES_WRITE_TARGET_PREFIX is intentionally excluded: write-target refs accumulate
839    // measurements and are consumed by consolidate_write_branches_into() on the next push.
840    // Deleting them here would silently discard uncommitted measurements.
841    let prefixes = [
842        REFS_NOTES_ADD_TARGET_PREFIX,
843        REFS_NOTES_MERGE_BRANCH_PREFIX,
844        REFS_NOTES_READ_PREFIX,
845        REFS_NOTES_REWRITE_TARGET_PREFIX,
846    ];
847    for prefix in prefixes {
848        let refs = get_refs(vec![format!("{prefix}*")]).unwrap_or_default();
849        for r in refs {
850            let Some(pid) = extract_pid_from_staging_ref(&r.refname, prefix) else {
851                // Cannot determine owning process — skip rather than risk deleting a ref
852                // from a live process (e.g., old-format refs created before PID tracking).
853                continue;
854            };
855            if is_process_alive(pid) {
856                continue;
857            }
858            info!("Cleaning up orphan staging ref {}", r.refname);
859            if let Err(e) = remove_reference(&r.refname) {
860                warn!("Failed to clean up orphan ref {}: {e:?}", r.refname);
861            }
862        }
863    }
864}
865
866fn raw_prune() -> Result<(), GitError> {
867    if is_shallow_repo()? {
868        return Err(GitError::ShallowRepository);
869    }
870
871    cleanup_orphan_staging_refs();
872
873    execute_notes_operation(|target| {
874        capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
875    })
876}
877
878/// Returns a list of all commit SHA-1 hashes that have performance measurements
879/// in the refs/notes/perf-v3 branch.
880///
881/// Each commit hash is returned as a 40-character hexadecimal string.
882pub fn list_commits_with_measurements() -> Result<Vec<String>> {
883    // Update local read branch to include pending writes (like walk_commits does)
884    let temp_ref = update_read_branch()?;
885
886    // Use git notes list to get all annotated commits
887    // Output format: <note_oid> <commit_oid>
888    let mut list_notes =
889        spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
890
891    let stdout = list_notes
892        .stdout
893        .take()
894        .ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
895
896    // Parse output line by line: each line is "note_sha commit_sha"
897    // We want the commit_sha (second column)
898    // Process directly from BufReader for efficiency
899    let commits: Vec<String> = BufReader::new(stdout)
900        .lines()
901        .filter_map(|line_result| {
902            line_result
903                .ok()
904                .and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
905        })
906        .collect();
907
908    Ok(commits)
909}
910
911/// Guard for a temporary read branch that includes all pending writes.
912/// Automatically cleans up the temporary reference when dropped.
913pub struct ReadBranchGuard {
914    temp_ref: TempRef,
915}
916
917impl ReadBranchGuard {
918    /// Get the reference name for use in git commands
919    #[must_use]
920    pub fn ref_name(&self) -> &str {
921        &self.temp_ref.ref_name
922    }
923}
924
925/// Creates a temporary read branch that consolidates all pending writes.
926/// The returned guard must be kept alive for as long as the reference is needed.
927/// The temporary reference is automatically cleaned up when the guard is dropped.
928pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
929    let temp_ref = update_read_branch()?;
930    Ok(ReadBranchGuard { temp_ref })
931}
932
933/// Creates a temporary read branch that consolidates ONLY pending writes (excludes remote).
934/// This is used by status and reset commands to see only local pending measurements.
935/// The returned guard must be kept alive for as long as the reference is needed.
936/// The temporary reference is automatically cleaned up when the guard is dropped.
937pub fn create_consolidated_pending_read_branch() -> Result<ReadBranchGuard> {
938    let temp_ref = update_pending_read_branch()?;
939    Ok(ReadBranchGuard { temp_ref })
940}
941
942fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
943    let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
944    args.extend(additional_args.iter().map(|s| s.as_str()));
945
946    let output = capture_git_output(&args, &None)?;
947    let refs: Result<Vec<Reference>, _> = output
948        .stdout
949        .lines()
950        .filter(|s| !s.is_empty())
951        .map(|s| {
952            let items = s.split('\0').take(2).collect_vec();
953            if items.len() != 2 {
954                return Err(GitError::ExecError {
955                    command: format!("git {}", args.join(" ")),
956                    output: GitOutput {
957                        stdout: format!("Unexpected git for-each-ref output format: {}", s),
958                        stderr: String::new(),
959                    },
960                });
961            }
962            Ok(Reference {
963                refname: items[0].to_string(),
964                oid: items[1].to_string(),
965            })
966        })
967        .collect();
968    refs
969}
970
971struct TempRef {
972    ref_name: String,
973}
974
975impl TempRef {
976    fn new(prefix: &str) -> Result<Self, GitError> {
977        Ok(TempRef {
978            ref_name: create_temp_ref(prefix, EMPTY_OID)?,
979        })
980    }
981}
982
983impl Drop for TempRef {
984    fn drop(&mut self) {
985        if let Err(e) = remove_reference(&self.ref_name) {
986            warn!("Failed to remove reference {}: {e:?}", self.ref_name);
987        }
988    }
989}
990
991fn update_read_branch() -> Result<TempRef> {
992    let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
993        .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
994    // Create a fresh read branch from the remote and consolidate all pending write branches.
995    // This ensures the read branch is always up to date with the remote branch, even after
996    // a history cutoff, by checking against the current upstream state.
997    let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
998
999    consolidate_write_branches_into(&current_upstream_oid, &temp_ref.ref_name, None)
1000        .map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
1001
1002    Ok(temp_ref)
1003}
1004
1005fn update_pending_read_branch() -> Result<TempRef> {
1006    let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
1007        .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
1008    // Create a read branch from ONLY the pending write branches (not the remote).
1009    // Start with empty tree and merge in all write refs.
1010    let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
1011        .map_err(|e| anyhow!("Failed to get write refs: {:?}", e))?;
1012
1013    for reference in &refs {
1014        reconcile_branch_with(&temp_ref.ref_name, &reference.oid)
1015            .map_err(|e| anyhow!("Failed to merge write ref: {:?}", e))?;
1016    }
1017
1018    Ok(temp_ref)
1019}
1020
1021/// Retrieves raw git notes data for commits starting from a specific commit.
1022///
1023/// This function performs a low-level git log operation to extract commit hashes
1024/// and their associated raw note lines from the performance notes ref. It updates
1025/// the local read branch, resolves the starting commit, and traverses up to
1026/// `num_commits` following the first-parent ancestry chain.
1027///
1028/// # Arguments
1029///
1030/// * `start_commit` - The committish reference to start walking from (e.g., "HEAD", "main", commit hash)
1031/// * `num_commits` - Maximum number of commits to retrieve
1032///
1033/// # Returns
1034///
1035/// Returns a vector of `CommitWithNotes`, where each entry contains:
1036/// - The commit SHA-1 hash
1037/// - The commit title (subject line)
1038/// - The commit author name
1039/// - A vector of raw note lines associated with that commit
1040///
1041/// # Errors
1042///
1043/// Returns an error if:
1044/// - The starting commit cannot be resolved or does not exist
1045/// - Git log operation fails
1046/// - The repository is a shallow clone (issues a warning but may still succeed)
1047/// - Git log output format is invalid
1048///
1049/// # Warnings
1050///
1051/// If a shallow clone is detected (grafted commits), a warning is issued as this
1052/// may result in incomplete history traversal.
1053///
1054/// # Examples
1055///
1056/// ```no_run
1057/// # use git_perf::git::git_interop::walk_commits_from;
1058/// let commits = walk_commits_from("HEAD", 5, None, None).unwrap();
1059/// for commit in commits {
1060///     println!("Commit: {} by {}: {}", commit.sha, commit.author, commit.title);
1061/// }
1062/// ```
1063pub fn walk_commits_from(
1064    start_commit: &str,
1065    num_commits: usize,
1066    since: Option<&str>,
1067    until: Option<&str>,
1068) -> Result<Vec<CommitWithNotes>> {
1069    // update local read branch
1070    let temp_ref = update_read_branch()?;
1071
1072    // Resolve and validate the starting commit to ensure it exists
1073    let resolved_commit = resolve_committish(start_commit)
1074        .context(format!("Failed to resolve commit '{}'", start_commit))?;
1075
1076    let num_commits_str = num_commits.to_string();
1077    let notes_ref = format!("--notes={}", temp_ref.ref_name);
1078    let since_arg = since.map(|s| format!("--since={}", s));
1079    let until_arg = until.map(|u| format!("--until={}", u));
1080
1081    let mut args = vec![
1082        "--no-pager",
1083        "log",
1084        "--no-color",
1085        "--ignore-missing",
1086        "-n",
1087        &num_commits_str,
1088        "--first-parent",
1089        "--pretty=--,%H,%s,%an,%D%n%N",
1090        "--decorate=full",
1091        &notes_ref,
1092    ];
1093    if let Some(ref s) = since_arg {
1094        args.push(s.as_str());
1095    }
1096    if let Some(ref u) = until_arg {
1097        args.push(u.as_str());
1098    }
1099    args.push(&resolved_commit);
1100
1101    let output = capture_git_output(&args, &None)
1102        .context(format!("Failed to retrieve commits from {}", start_commit))?;
1103
1104    let mut commits: Vec<CommitWithNotes> = Vec::new();
1105    let mut detected_shallow = false;
1106    let mut current_commit_sha: Option<String> = None;
1107
1108    for l in output.stdout.lines() {
1109        if l.starts_with("--") {
1110            // Parse format: --,<sha>,<title>,<author>,<decorations>
1111            let parts: Vec<&str> = l.splitn(5, ',').collect();
1112            if parts.len() < 5 {
1113                bail!(
1114                    "Invalid git log format: expected 5 fields, got {}",
1115                    parts.len()
1116                );
1117            }
1118
1119            let sha = parts[1].to_string();
1120            let title = if parts[2].is_empty() {
1121                "[no subject]".to_string()
1122            } else {
1123                parts[2].to_string()
1124            };
1125            let author = if parts[3].is_empty() {
1126                "[unknown]".to_string()
1127            } else {
1128                parts[3].to_string()
1129            };
1130            let decorations = parts[4];
1131
1132            detected_shallow |= decorations.contains("grafted");
1133            current_commit_sha = Some(sha.clone());
1134
1135            commits.push(CommitWithNotes {
1136                sha,
1137                title,
1138                author,
1139                note_lines: Vec::new(),
1140            });
1141        } else if current_commit_sha.is_some() {
1142            if let Some(last) = commits.last_mut() {
1143                last.note_lines.push(l.to_string());
1144            }
1145        }
1146    }
1147
1148    if detected_shallow && commits.len() < num_commits {
1149        bail!("Refusing to continue as commit log depth was limited by shallow clone");
1150    }
1151
1152    Ok(commits)
1153}
1154
1155/// Walk commits starting from HEAD (convenience wrapper)
1156pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
1157    walk_commits_from("HEAD", num_commits, None, None)
1158}
1159
1160/// Get commits that have notes in a specific notes ref.
1161/// This is much more efficient than walking all commits when you only need
1162/// commits with measurements.
1163///
1164/// Returns a vector of commit SHAs that have notes in the specified ref.
1165pub fn get_commits_with_notes(notes_ref: &str) -> Result<Vec<String>> {
1166    let output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1167        .context(format!("Failed to list notes in {}", notes_ref))?;
1168
1169    // git notes list outputs lines in format: <note-sha> <commit-sha>
1170    let commits: Vec<String> = output
1171        .stdout
1172        .lines()
1173        .filter(|line| !line.is_empty())
1174        .filter_map(|line| {
1175            let parts: Vec<&str> = line.split_whitespace().collect();
1176            if parts.len() >= 2 {
1177                Some(parts[1].to_string())
1178            } else {
1179                None
1180            }
1181        })
1182        .collect();
1183
1184    Ok(commits)
1185}
1186
1187/// Batch-fetch all commits that have notes in `notes_ref`, together with their
1188/// note content, commit subject, and author — in exactly 2 git calls regardless
1189/// of how many commits have notes.
1190///
1191/// Uses `git notes list` to discover which commits have notes, then a single
1192/// `git log --no-walk` with `--notes=<ref>` to fetch everything in one pass.
1193pub fn get_commits_with_notes_content(notes_ref: &str) -> Result<Vec<CommitWithNotes>> {
1194    // Call 1: get commit SHAs that have notes in this ref
1195    let list_output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1196        .context(format!("Failed to list notes in {}", notes_ref))?;
1197
1198    let commit_shas: Vec<String> = list_output
1199        .stdout
1200        .lines()
1201        .filter(|l| !l.is_empty())
1202        .filter_map(|line| {
1203            let parts: Vec<&str> = line.split_whitespace().collect();
1204            (parts.len() >= 2).then(|| parts[1].to_string())
1205        })
1206        .collect();
1207
1208    if commit_shas.is_empty() {
1209        return Ok(Vec::new());
1210    }
1211
1212    // Call 2: fetch commit metadata + note content in one git log --no-walk pass.
1213    // Format: each commit header is "<sha>\0<title>\0<author>" (null-separated to
1214    // handle commas/special chars in subjects), followed by note lines via %N.
1215    let notes_flag = format!("--notes={}", notes_ref);
1216    let mut args = vec![
1217        "--no-pager",
1218        "log",
1219        "--no-color",
1220        "--no-walk",
1221        "--pretty=format:%H%x00%s%x00%an%n%N",
1222        &notes_flag,
1223    ];
1224    args.extend(commit_shas.iter().map(|s| s.as_str()));
1225
1226    let output =
1227        capture_git_output(&args, &None).context("Failed to fetch commit details and notes")?;
1228
1229    // Parse output: lines containing '\0' are commit headers; all other lines
1230    // (until the next header) are note content for the preceding commit.
1231    let mut commits: Vec<CommitWithNotes> = Vec::new();
1232
1233    for line in output.stdout.lines() {
1234        if line.contains('\0') {
1235            let mut parts = line.splitn(3, '\0');
1236            let sha = parts.next().unwrap_or("").to_string();
1237            let title = parts
1238                .next()
1239                .map(|s| if s.is_empty() { "[no subject]" } else { s })
1240                .unwrap_or("[no subject]")
1241                .to_string();
1242            let author = parts
1243                .next()
1244                .map(|s| if s.is_empty() { "[unknown]" } else { s })
1245                .unwrap_or("[unknown]")
1246                .to_string();
1247            commits.push(CommitWithNotes {
1248                sha,
1249                title,
1250                author,
1251                note_lines: Vec::new(),
1252            });
1253        } else if let Some(last) = commits.last_mut() {
1254            last.note_lines.push(line.to_string());
1255        }
1256    }
1257
1258    Ok(commits)
1259}
1260
1261pub fn pull(work_dir: Option<&Path>) -> Result<()> {
1262    pull_internal(work_dir)?;
1263    Ok(())
1264}
1265
1266fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
1267    fetch(work_dir)?;
1268    Ok(())
1269}
1270
1271pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
1272    let op = || {
1273        raw_push(work_dir, remote)
1274            .map_err(map_git_error_for_backoff)
1275            .map_err(|e: ::backoff::Error<GitError>| match e {
1276                ::backoff::Error::Transient { .. } => {
1277                    // Attempt to pull to resolve conflicts
1278                    let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
1279
1280                    // A concurrent modification comes from a concurrent fetch.
1281                    // Don't fail for that - it's safe to assume we successfully pulled
1282                    // in the context of the retry logic.
1283                    let pull_succeeded = pull_result.is_ok()
1284                        || matches!(
1285                            pull_result,
1286                            Err(::backoff::Error::Permanent(
1287                                GitError::RefConcurrentModification { .. }
1288                                    | GitError::RefFailedToLock { .. }
1289                            ))
1290                        );
1291
1292                    if pull_succeeded {
1293                        // Pull succeeded or failed with expected concurrent errors,
1294                        // return the original push error to retry
1295                        e
1296                    } else {
1297                        // Pull failed with unexpected error, propagate it
1298                        pull_result.unwrap_err()
1299                    }
1300                }
1301                ::backoff::Error::Permanent { .. } => e,
1302            })
1303    };
1304
1305    let backoff = default_backoff();
1306
1307    ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
1308        ::backoff::Error::Permanent(err) => {
1309            anyhow!(err).context("Permanent failure while pushing refs")
1310        }
1311        ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
1312    })?;
1313
1314    Ok(())
1315}
1316
1317/// Get all write refs and return their names and OIDs
1318pub fn get_write_refs() -> Result<Vec<(String, String)>> {
1319    let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
1320        .map_err(|e| anyhow!("{:?}", e))?;
1321    Ok(refs.into_iter().map(|r| (r.refname, r.oid)).collect())
1322}
1323
1324/// Delete a git reference (wrapper that converts GitError to anyhow::Error)
1325pub fn delete_reference(ref_name: &str) -> Result<()> {
1326    remove_reference(ref_name).map_err(|e| anyhow!("{:?}", e))
1327}
1328
1329#[cfg(test)]
1330mod test {
1331    use super::*;
1332    use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
1333    use std::process::Command;
1334    use std::sync::{
1335        atomic::{AtomicBool, Ordering},
1336        Arc,
1337    };
1338
1339    use httptest::{
1340        http::{header::AUTHORIZATION, Uri},
1341        matchers::{self, request},
1342        responders::status_code,
1343        Expectation, Server,
1344    };
1345
1346    fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
1347        let url = origin_url.to_string();
1348
1349        run_git_command(&["remote", "add", "origin", &url], dir);
1350        run_git_command(
1351            &[
1352                "config",
1353                "--add",
1354                format!("http.{}.extraHeader", url).as_str(),
1355                extra_header,
1356            ],
1357            dir,
1358        );
1359    }
1360
1361    #[test]
1362    fn test_customheader_pull() {
1363        with_isolated_cwd_git(|git_dir| {
1364            let mut test_server = Server::run();
1365            add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
1366
1367            test_server.expect(
1368                Expectation::matching(request::headers(matchers::contains((
1369                    AUTHORIZATION.as_str(),
1370                    "sometoken",
1371                ))))
1372                .times(1..)
1373                .respond_with(status_code(200)),
1374            );
1375
1376            // The pull operation will fail because the mock server doesn't provide a valid git
1377            // response, but we verify that the authorization header was sent by checking that
1378            // the server's expectations are met (httptest will panic on drop if not).
1379            let _ = pull(None); // Ignore result - we only care that auth header was sent
1380
1381            // Explicitly verify server expectations were met
1382            test_server.verify_and_clear();
1383        });
1384    }
1385
1386    #[test]
1387    fn test_customheader_push() {
1388        with_isolated_cwd_git(|git_dir| {
1389            let test_server = Server::run();
1390            add_server_remote(
1391                test_server.url(""),
1392                "AUTHORIZATION: someothertoken",
1393                git_dir,
1394            );
1395
1396            test_server.expect(
1397                Expectation::matching(request::headers(matchers::contains((
1398                    AUTHORIZATION.as_str(),
1399                    "someothertoken",
1400                ))))
1401                .times(1..)
1402                .respond_with(status_code(200)),
1403            );
1404
1405            // Must add a single write as a push without pending local writes just succeeds
1406            ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
1407            add_note_line_to_head("test note line").expect("Failed to add note line");
1408
1409            let error = push(None, None);
1410            error
1411                .as_ref()
1412                .expect_err("We have no valid git http server setup -> should fail");
1413            dbg!(&error);
1414        });
1415    }
1416
1417    #[test]
1418    fn test_random_suffix() {
1419        for _ in 1..1000 {
1420            let first = random_suffix();
1421            let second = random_suffix();
1422
1423            // Format: {pid:08x}-{random:08x}
1424            let is_valid = |s: &String| {
1425                let parts: Vec<&str> = s.splitn(2, '-').collect();
1426                parts.len() == 2
1427                    && parts[0].len() == 8
1428                    && parts[0].chars().all(|c| c.is_ascii_hexdigit())
1429                    && parts[1].len() == 8
1430                    && parts[1].chars().all(|c| c.is_ascii_hexdigit())
1431            };
1432
1433            assert_ne!(first, second);
1434            assert!(is_valid(&first), "Invalid suffix format: {}", first);
1435            assert!(is_valid(&second), "Invalid suffix format: {}", second);
1436        }
1437    }
1438
1439    #[test]
1440    fn test_empty_or_never_pushed_remote_error_for_fetch() {
1441        with_isolated_cwd_git(|git_dir| {
1442            // Add a dummy remote so the code can check for empty remote
1443            let git_dir_url = format!("file://{}", git_dir.display());
1444            run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
1445
1446            // NOTE: GIT_TRACE is required for this test to function correctly
1447            std::env::set_var("GIT_TRACE", "true");
1448
1449            // Do not add any notes/measurements or push anything
1450            let result = super::fetch(Some(git_dir));
1451            match result {
1452                Err(GitError::NoRemoteMeasurements { output }) => {
1453                    assert!(
1454                        output.stderr.contains(GIT_PERF_REMOTE),
1455                        "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
1456                        output.stderr
1457                    )
1458                }
1459                other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
1460            }
1461        });
1462    }
1463
1464    #[test]
1465    fn test_empty_or_never_pushed_remote_error_for_push() {
1466        with_isolated_cwd_git(|git_dir| {
1467            run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
1468
1469            // NOTE: GIT_TRACE is required for this test to function correctly
1470            std::env::set_var("GIT_TRACE", "true");
1471
1472            add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
1473
1474            let result = super::raw_push(Some(git_dir), None);
1475            match result {
1476                Err(GitError::RefFailedToPush { output }) => {
1477                    assert!(
1478                        output.stderr.contains(GIT_PERF_REMOTE),
1479                        "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
1480                        output.stderr
1481                    )
1482                }
1483                other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1484            }
1485        });
1486    }
1487
1488    /// Verifies the push lock file lives inside the repository's common git
1489    /// dir, targeting mutants that could point it at an arbitrary location
1490    /// (which would silently defeat serialization between two repos, or
1491    /// between a repo and an unrelated directory).
1492    #[test]
1493    fn test_acquire_push_lock_creates_lock_file_in_git_common_dir() {
1494        with_isolated_cwd_git(|git_dir| {
1495            let lock = acquire_push_lock(&None).expect("should acquire push lock");
1496            drop(lock);
1497
1498            let expected_path = git_dir.join(".git").join(PUSH_LOCK_FILE_NAME);
1499            assert!(
1500                expected_path.exists(),
1501                "Expected lock file at {:?}",
1502                expected_path
1503            );
1504        });
1505    }
1506
1507    /// Verifies the lock actually excludes concurrent access: a second,
1508    /// independent file handle cannot acquire it while the first is held,
1509    /// and can once the first is released. This is the core property the
1510    /// fix for #732 relies on to prevent two `git push` invocations from
1511    /// racing inside receive-pack's object quarantine.
1512    #[test]
1513    fn test_acquire_push_lock_excludes_concurrent_access() {
1514        with_isolated_cwd_git(|_git_dir| {
1515            let first = acquire_push_lock(&None).expect("first lock should succeed");
1516
1517            let lock_path = git_common_dir(&None)
1518                .expect("git common dir")
1519                .join(PUSH_LOCK_FILE_NAME);
1520            let second = OpenOptions::new()
1521                .create(true)
1522                .write(true)
1523                .truncate(false)
1524                .open(&lock_path)
1525                .expect("open lock file for contention probe");
1526            assert!(
1527                second.try_lock().is_err(),
1528                "Second handle should not be able to lock while the first is held"
1529            );
1530
1531            drop(first);
1532
1533            second
1534                .try_lock()
1535                .expect("Second handle should acquire the lock once the first is released");
1536        });
1537    }
1538
1539    /// Verifies a blocked contender actually wakes up and proceeds once the
1540    /// lock is released, rather than deadlocking - the liveness property the
1541    /// fix needs so that at least one of two concurrent pushers always makes
1542    /// progress instead of both repeatedly corrupting each other's pack
1543    /// transfer (see #732 / #745).
1544    #[test]
1545    fn test_acquire_push_lock_blocks_until_released() {
1546        with_isolated_cwd_git(|git_dir| {
1547            let git_dir = git_dir.to_path_buf();
1548            let first =
1549                acquire_push_lock(&Some(git_dir.as_path())).expect("first lock should succeed");
1550
1551            let acquired = Arc::new(AtomicBool::new(false));
1552            let acquired_writer = Arc::clone(&acquired);
1553            let waiter_git_dir = git_dir.clone();
1554            let waiter = thread::spawn(move || {
1555                let _second = acquire_push_lock(&Some(waiter_git_dir.as_path()))
1556                    .expect("second lock should eventually succeed");
1557                acquired_writer.store(true, Ordering::SeqCst);
1558            });
1559
1560            thread::sleep(Duration::from_millis(200));
1561            assert!(
1562                !acquired.load(Ordering::SeqCst),
1563                "Contender should still be blocked while the first lock is held"
1564            );
1565
1566            drop(first);
1567            waiter.join().expect("waiter thread should not panic");
1568            assert!(
1569                acquired.load(Ordering::SeqCst),
1570                "Contender should acquire the lock after it is released"
1571            );
1572        });
1573    }
1574
1575    /// Test that new_symbolic_write_ref returns valid, non-empty reference names
1576    /// Targets missed mutants:
1577    /// - Could return Ok(String::new()) - empty string
1578    /// - Could return Ok("xyzzy".into()) - arbitrary invalid string
1579    #[test]
1580    fn test_new_symbolic_write_ref_returns_valid_ref() {
1581        with_isolated_cwd_git(|_git_dir| {
1582            // Test the private function directly since we're in the same module
1583            let result = new_symbolic_write_ref();
1584            assert!(
1585                result.is_ok(),
1586                "Should create symbolic write ref: {:?}",
1587                result
1588            );
1589
1590            let ref_name = result.unwrap();
1591
1592            // Mutation 1: Should not be empty string
1593            assert!(
1594                !ref_name.is_empty(),
1595                "Reference name should not be empty, got: '{}'",
1596                ref_name
1597            );
1598
1599            // Mutation 2: Should not be arbitrary string like "xyzzy"
1600            assert!(
1601                ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
1602                "Reference should start with {}, got: {}",
1603                REFS_NOTES_WRITE_TARGET_PREFIX,
1604                ref_name
1605            );
1606
1607            // Should have a suffix in format {pid:08x}-{random:08x}
1608            let suffix = ref_name
1609                .strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
1610                .expect("Should have prefix");
1611            let parts: Vec<&str> = suffix.splitn(2, '-').collect();
1612            let is_valid_suffix = parts.len() == 2
1613                && parts[0].len() == 8
1614                && parts[0].chars().all(|c| c.is_ascii_hexdigit())
1615                && parts[1].len() == 8
1616                && parts[1].chars().all(|c| c.is_ascii_hexdigit());
1617            assert!(
1618                is_valid_suffix,
1619                "Suffix should be in format {{pid:08x}}-{{random:08x}}, got: {}",
1620                suffix
1621            );
1622        });
1623    }
1624
1625    /// Test that notes can be added successfully via add_note_line_to_head
1626    /// Verifies end-to-end note operations work correctly
1627    #[test]
1628    fn test_add_and_retrieve_notes() {
1629        with_isolated_cwd_git(|_git_dir| {
1630            // Add first note - this calls ensure_symbolic_write_ref_exists -> new_symbolic_write_ref
1631            let result = add_note_line_to_head("test: 100");
1632            assert!(
1633                result.is_ok(),
1634                "Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
1635                result
1636            );
1637
1638            // Add second note to ensure ref operations continue to work
1639            let result2 = add_note_line_to_head("test: 200");
1640            assert!(result2.is_ok(), "Should add second note: {:?}", result2);
1641
1642            // Verify notes were actually added by walking commits
1643            let commits = walk_commits(10);
1644            assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
1645
1646            let commits = commits.unwrap();
1647            assert!(!commits.is_empty(), "Should have commits");
1648
1649            // Check that HEAD commit has notes
1650            let commit_with_notes = &commits[0];
1651            assert!(
1652                !commit_with_notes.note_lines.is_empty(),
1653                "HEAD should have notes"
1654            );
1655            assert!(
1656                commit_with_notes
1657                    .note_lines
1658                    .iter()
1659                    .any(|n| n.contains("test:")),
1660                "Notes should contain our test data"
1661            );
1662        });
1663    }
1664
1665    /// Test walk_commits with shallow repository containing multiple grafted commits
1666    /// Targets missed mutant at line 725: detected_shallow |= vs ^=
1667    /// The XOR operator would toggle instead of OR, failing with multiple grafts
1668    #[test]
1669    fn test_walk_commits_shallow_repo_detection() {
1670        use std::env::set_current_dir;
1671
1672        with_isolated_cwd_git(|git_dir| {
1673            // Create multiple commits
1674            for i in 2..=5 {
1675                run_git_command(
1676                    &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1677                    git_dir,
1678                );
1679            }
1680
1681            // Create a shallow clone (depth 2) which will have grafted commits
1682            let shallow_dir = git_dir.join("shallow");
1683            let output = Command::new("git")
1684                .args([
1685                    "clone",
1686                    "--depth",
1687                    "2",
1688                    git_dir.to_str().unwrap(),
1689                    shallow_dir.to_str().unwrap(),
1690                ])
1691                .output()
1692                .unwrap();
1693
1694            assert!(
1695                output.status.success(),
1696                "Shallow clone failed: {}",
1697                String::from_utf8_lossy(&output.stderr)
1698            );
1699
1700            // Change to shallow clone directory
1701            set_current_dir(&shallow_dir).unwrap();
1702
1703            // Add a note to enable walk_commits
1704            add_note_line_to_head("test: 100").expect("Should add note");
1705
1706            // Walk commits - should detect as shallow
1707            let result = walk_commits(10);
1708            assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
1709
1710            let commits = result.unwrap();
1711
1712            // In a shallow repo, git log --boundary shows grafted markers
1713            // The |= operator correctly sets detected_shallow to true
1714            // The ^= mutant would toggle the flag, potentially giving wrong result
1715
1716            // Verify we got commits (the function works)
1717            assert!(
1718                !commits.is_empty(),
1719                "Should have found commits in shallow repo"
1720            );
1721        });
1722    }
1723
1724    /// Test walk_commits correctly identifies normal (non-shallow) repos
1725    #[test]
1726    fn test_walk_commits_normal_repo_not_shallow() {
1727        with_isolated_cwd_git(|git_dir| {
1728            // Create a few commits
1729            for i in 2..=3 {
1730                run_git_command(
1731                    &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1732                    git_dir,
1733                );
1734            }
1735
1736            // Add a note to enable walk_commits
1737            add_note_line_to_head("test: 100").expect("Should add note");
1738
1739            let result = walk_commits(10);
1740            assert!(result.is_ok(), "walk_commits should succeed");
1741
1742            let commits = result.unwrap();
1743
1744            // Should have commits
1745            assert!(!commits.is_empty(), "Should have found commits");
1746        });
1747    }
1748
1749    #[test]
1750    fn test_extract_pid_from_staging_ref() {
1751        // Valid new-format suffix: {pid:08x}-{random:08x}
1752        let refname = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "deadbeef-01234567");
1753        assert_eq!(
1754            extract_pid_from_staging_ref(&refname, REFS_NOTES_ADD_TARGET_PREFIX),
1755            Some(0xdeadbeef),
1756        );
1757
1758        // Old-format suffix: 8-hex-char only, no dash — must return None
1759        // (would overflow to negative i32 and call kill(-N, 0) on a process group)
1760        let old_refname = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "deadbeef");
1761        assert_eq!(
1762            extract_pid_from_staging_ref(&old_refname, REFS_NOTES_ADD_TARGET_PREFIX),
1763            None,
1764            "Old-format refs without a dash must return None to avoid kill(-N, 0) on a process group",
1765        );
1766
1767        // Wrong prefix — strip_prefix returns None
1768        let wrong_prefix = format!("{}{}", REFS_NOTES_WRITE_TARGET_PREFIX, "deadbeef-01234567");
1769        assert_eq!(
1770            extract_pid_from_staging_ref(&wrong_prefix, REFS_NOTES_ADD_TARGET_PREFIX),
1771            None,
1772        );
1773
1774        // Non-hex characters in pid field — from_str_radix returns Err
1775        let bad_hex = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "xyz00000-01234567");
1776        assert_eq!(
1777            extract_pid_from_staging_ref(&bad_hex, REFS_NOTES_ADD_TARGET_PREFIX),
1778            None,
1779        );
1780    }
1781
1782    #[test]
1783    fn test_is_process_alive_returns_true_for_current_process() {
1784        assert!(
1785            is_process_alive(std::process::id()),
1786            "Current process must be alive",
1787        );
1788    }
1789
1790    #[cfg(unix)]
1791    #[test]
1792    fn test_is_process_alive_returns_false_for_dead_process() {
1793        let mut child = std::process::Command::new("true")
1794            .spawn()
1795            .expect("Failed to spawn 'true'");
1796        let pid = child.id();
1797        child.wait().expect("Failed to wait for child");
1798        // PID is freed after wait(); extremely unlikely to be reused before the next line
1799        assert!(
1800            !is_process_alive(pid),
1801            "PID {pid} should be dead after process exited",
1802        );
1803    }
1804
1805    /// Test that raw_add_note_line succeeds under normal conditions (symref present).
1806    /// This directly exercises the ok_or_else path without the 60-second backoff
1807    /// wrapper, so any mutant that makes the function always return an error is
1808    /// caught immediately rather than after a full backoff cycle.
1809    #[test]
1810    fn test_raw_add_note_line_succeeds_in_normal_repo() {
1811        with_isolated_cwd_git(|_git_dir| {
1812            let result = raw_add_note_line("HEAD", "test_measurement=1.0");
1813            assert!(
1814                result.is_ok(),
1815                "raw_add_note_line should succeed in a freshly initialised repo: {:?}",
1816                result
1817            );
1818        });
1819    }
1820
1821    /// Test that execute_notes_operation skips the upstream push when the operation
1822    /// makes no changes (no-op prune/remove). Targets the early-return branch:
1823    ///   if git_rev_parse(&target)? == current_notes_head { return Ok(()); }
1824    /// A mutant that flips `==` to `!=` would reach compact_head and push,
1825    /// changing the upstream hash — which this test detects.
1826    #[test]
1827    fn test_execute_notes_operation_noop_skips_upstream_push() {
1828        use tempfile::tempdir;
1829
1830        with_isolated_cwd_git(|git_dir| {
1831            // Set up a bare upstream repo and add it as origin
1832            let upstream = tempdir().unwrap();
1833            run_git_command(&["init", "--bare"], upstream.path());
1834            let upstream_url = format!("file://{}", upstream.path().display());
1835            run_git_command(&["remote", "add", "origin", &upstream_url], git_dir);
1836            run_git_command(&["push", "origin", "master"], git_dir);
1837
1838            // Add a measurement and push to create the upstream notes branch
1839            add_note_line_to_head("test: 1").expect("add note");
1840            push(None, None).expect("push notes");
1841
1842            let head_before = git_rev_parse(REFS_NOTES_BRANCH).expect("notes branch exists");
1843
1844            // A closure that does nothing to the target ref is a no-op
1845            execute_notes_operation(|_target| Ok(()))
1846                .expect("no-op execute_notes_operation should succeed");
1847
1848            // The notes branch must be unchanged — no spurious push occurred
1849            let head_after = git_rev_parse(REFS_NOTES_BRANCH).expect("notes branch still exists");
1850            assert_eq!(
1851                head_before, head_after,
1852                "no-op execute_notes_operation must not advance the notes branch"
1853            );
1854
1855            // All temp rewrite refs must be cleaned up
1856            let rewrite_refs = get_refs(vec![format!("{REFS_NOTES_REWRITE_TARGET_PREFIX}*")])
1857                .expect("list rewrite refs");
1858            assert!(
1859                rewrite_refs.is_empty(),
1860                "temp rewrite refs must be cleaned up after no-op: {:?}",
1861                rewrite_refs
1862            );
1863        });
1864    }
1865}