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