Skip to main content

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