1use std::{
2 fs::{File, OpenOptions},
3 io::{BufRead, BufReader, BufWriter, Write},
4 path::Path,
5 process::Stdio,
6 thread,
7 time::Duration,
8};
9
10use defer::defer;
11use log::{debug, info, warn};
12use unindent::unindent;
13
14use anyhow::{anyhow, bail, Context, Result};
15use backoff::{ExponentialBackoff, ExponentialBackoffBuilder};
16use itertools::Itertools;
17
18use chrono::prelude::*;
19use rand::{rng, RngExt};
20
21use crate::config;
22
23pub use super::git_definitions::REFS_NOTES_BRANCH;
24use super::git_definitions::{
25 GIT_ORIGIN, GIT_PERF_REMOTE, PUSH_LOCK_FILE_NAME, REFS_NOTES_ADD_TARGET_PREFIX,
26 REFS_NOTES_MERGE_BRANCH_PREFIX, REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX,
27 REFS_NOTES_WRITE_SYMBOLIC_REF, REFS_NOTES_WRITE_TARGET_PREFIX,
28};
29use super::git_lowlevel::{
30 capture_git_output, get_git_perf_remote, git_common_dir, git_rev_parse,
31 git_rev_parse_symbolic_ref, git_symbolic_ref_create_or_update, git_update_ref,
32 internal_get_head_revision, is_shallow_repo, map_git_error, set_git_perf_remote,
33 spawn_git_command,
34};
35use super::git_types::GitError;
36use super::git_types::GitOutput;
37use super::git_types::Reference;
38
39pub use super::git_lowlevel::get_head_revision;
40
41pub use super::git_lowlevel::check_git_version;
42
43pub use super::git_lowlevel::get_repository_root;
44
45pub use super::git_lowlevel::resolve_committish;
46
47#[derive(Debug, Clone, PartialEq)]
55pub struct CommitWithNotes {
56 pub sha: String,
57 pub title: String,
58 pub author: String,
59 pub note_lines: Vec<String>,
60}
61
62pub fn is_shallow_repository() -> Result<bool> {
64 super::git_lowlevel::is_shallow_repo()
65 .map_err(|e| anyhow!("Failed to check if repository is shallow: {}", e))
66}
67
68fn map_git_error_for_backoff(e: GitError) -> ::backoff::Error<GitError> {
69 match e {
70 GitError::RefFailedToPush { .. }
71 | GitError::RefFailedToLock { .. }
72 | GitError::RefConcurrentModification { .. }
73 | GitError::BadObject { .. } => ::backoff::Error::transient(e),
74 GitError::ExecError { .. }
75 | GitError::IoError(..)
76 | GitError::ShallowRepository
77 | GitError::MissingHead { .. }
78 | GitError::NoRemoteMeasurements { .. }
79 | GitError::NoUpstream { .. }
80 | GitError::MissingMeasurements => ::backoff::Error::permanent(e),
81 }
82}
83
84fn default_backoff() -> ExponentialBackoff {
86 let max_elapsed = config::backoff_max_elapsed_seconds();
87 ExponentialBackoffBuilder::default()
88 .with_max_elapsed_time(Some(Duration::from_secs(max_elapsed)))
89 .build()
90}
91
92pub fn add_note_line(commit: &str, line: &str) -> Result<()> {
123 let op = || -> Result<(), ::backoff::Error<GitError>> {
124 raw_add_note_line(commit, line).map_err(map_git_error_for_backoff)
125 };
126
127 let backoff = default_backoff();
128
129 ::backoff::retry(backoff, op).map_err(|e| match e {
130 ::backoff::Error::Permanent(err) => anyhow!(err).context(format!(
131 "Permanent failure while adding note line to commit {}",
132 commit
133 )),
134 ::backoff::Error::Transient { err, .. } => anyhow!(err).context(format!(
135 "Timed out while adding note line to commit {}",
136 commit
137 )),
138 })?;
139
140 Ok(())
141}
142
143pub fn add_note_line_to_head(line: &str) -> Result<()> {
145 let head = internal_get_head_revision()
146 .map_err(|e| anyhow!(e).context("Failed to get HEAD revision"))?;
147 add_note_line(&head, line)
148}
149
150fn raw_add_note_line(commit: &str, line: &str) -> Result<(), GitError> {
151 ensure_symbolic_write_ref_exists()?;
152
153 let current_note_head =
157 git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).unwrap_or(EMPTY_OID.to_string());
158 let current_symbolic_ref_target = git_rev_parse_symbolic_ref(REFS_NOTES_WRITE_SYMBOLIC_REF)
159 .ok_or_else(|| GitError::RefFailedToLock {
160 output: GitOutput {
161 stdout: String::new(),
162 stderr: format!(
163 "symbolic-ref {REFS_NOTES_WRITE_SYMBOLIC_REF} vanished after creation"
164 ),
165 },
166 })?;
167 let temp_target = create_temp_add_head(¤t_note_head)?;
168
169 defer!(if let Err(e) = remove_reference(&temp_target) {
170 warn!("Failed to delete temp add ref {temp_target}: {e:?}");
171 });
172
173 let resolved_commit = git_rev_parse(commit)?;
175
176 capture_git_output(
177 &[
178 "notes",
179 "--ref",
180 &temp_target,
181 "append",
182 "--no-separator",
183 "-m",
184 line,
185 &resolved_commit,
186 ],
187 &None,
188 )
189 .map_err(map_git_error)?;
190
191 git_update_ref(unindent(
197 format!(
198 r#"
199 start
200 update {current_symbolic_ref_target} {temp_target} {current_note_head}
201 commit
202 "#
203 )
204 .as_str(),
205 ))?;
206
207 Ok(())
208}
209
210fn ensure_remote_exists() -> Result<(), GitError> {
211 if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
212 return Ok(());
213 }
214
215 if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
216 return set_git_perf_remote(GIT_PERF_REMOTE, &x);
217 }
218
219 Err(GitError::NoUpstream {})
220}
221
222fn create_temp_ref_name(prefix: &str) -> String {
224 let suffix = random_suffix();
225 format!("{prefix}{suffix}")
226}
227
228fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
229 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
230 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
231
232 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
236 |err| {
237 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
239 Ok(())
240 } else {
241 Err(err)
242 }
243 },
244 )?;
245 }
246 Ok(())
247}
248
249fn random_suffix() -> String {
250 let pid = std::process::id();
251 let random: u32 = rng().random::<u32>();
252 format!("{pid:08x}-{random:08x}")
253}
254
255fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
256 ensure_remote_exists()?;
257
258 let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
259 capture_git_output(
261 &[
262 "fetch",
263 "--atomic",
264 "--no-write-fetch-head",
265 GIT_PERF_REMOTE,
266 format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
270 ],
271 &work_dir,
272 )
273 .map_err(map_git_error)?;
274
275 let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
276
277 if ref_before == ref_after {
278 println!("Already up to date");
279 }
280
281 Ok(())
282}
283
284fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
287 _ = capture_git_output(
288 &[
289 "notes",
290 "--ref",
291 target,
292 "merge",
293 "-s",
294 "cat_sort_uniq",
295 branch,
296 ],
297 &None,
298 )?;
299 Ok(())
300}
301
302fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
303 let target = create_temp_ref_name(prefix);
304 if current_head != EMPTY_OID {
305 git_update_ref(unindent(
306 format!(
307 r#"
308 start
309 create {target} {current_head}
310 commit
311 "#
312 )
313 .as_str(),
314 ))?;
315 }
316 Ok(target)
317}
318
319fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
320 create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
321}
322
323fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
324 create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
325}
326
327fn compact_head(target: &str) -> Result<(), GitError> {
328 let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
329
330 let compaction_head = capture_git_output(
332 &["commit-tree", "-m", "cutoff history", &new_removal_head],
333 &None,
334 )?
335 .stdout;
336
337 let compaction_head = compaction_head.trim();
338
339 git_update_ref(unindent(
340 format!(
341 r#"
342 start
343 update {target} {compaction_head}
344 commit
345 "#
346 )
347 .as_str(),
348 ))?;
349
350 Ok(())
351}
352
353fn retry_notify(err: GitError, dur: Duration) {
354 debug!("Error happened at {dur:?}: {err}");
355 warn!("Retrying...");
356}
357
358pub fn remove_measurements_from_commits(
359 older_than: DateTime<Utc>,
360 prune: bool,
361 dry_run: bool,
362) -> Result<()> {
363 if dry_run {
364 return raw_remove_measurements_from_commits(older_than, prune, dry_run)
366 .map_err(|e| anyhow!(e));
367 }
368
369 let op = || -> Result<(), ::backoff::Error<GitError>> {
370 raw_remove_measurements_from_commits(older_than, prune, dry_run)
371 .map_err(map_git_error_for_backoff)
372 };
373
374 let backoff = default_backoff();
375
376 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
377 ::backoff::Error::Permanent(err) => {
378 anyhow!(err).context("Permanent failure while removing measurements")
379 }
380 ::backoff::Error::Transient { err, .. } => {
381 anyhow!(err).context("Timed out while removing measurements")
382 }
383 })?;
384
385 Ok(())
386}
387
388fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
389where
390 F: FnOnce(&str) -> Result<(), GitError>,
391{
392 pull_internal(None)?;
393
394 let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
395 let target = create_temp_rewrite_head(¤t_notes_head)?;
396
397 operation(&target)?;
398
399 if git_rev_parse(&target)? == current_notes_head {
402 if let Err(e) = remove_reference(&target) {
403 warn!("Failed to delete temp notes ref {target}: {e:?}");
404 }
405 return Ok(());
406 }
407
408 compact_head(&target)?;
409
410 git_push_notes_ref(¤t_notes_head, &target, &None, None)?;
411
412 git_update_ref(unindent(
413 format!(
414 r#"
415 start
416 update {REFS_NOTES_BRANCH} {target}
417 commit
418 "#
419 )
420 .as_str(),
421 ))?;
422
423 if let Err(e) = remove_reference(&target) {
424 warn!("Failed to delete temp notes ref {target}: {e:?}");
425 }
426
427 Ok(())
428}
429
430fn raw_remove_measurements_from_commits(
431 older_than: DateTime<Utc>,
432 prune: bool,
433 dry_run: bool,
434) -> Result<(), GitError> {
435 if prune && is_shallow_repo()? {
437 return Err(GitError::ShallowRepository);
438 }
439
440 if dry_run {
441 remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
443 if prune {
444 println!("[DRY-RUN] Would prune orphaned measurements after removal");
445 }
446 return Ok(());
447 }
448
449 execute_notes_operation(|target| {
450 remove_measurements_from_reference(target, older_than, dry_run)?;
452
453 if prune {
455 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
456 }
457
458 Ok(())
459 })
460}
461
462fn remove_measurements_from_reference(
464 reference: &str,
465 older_than: DateTime<Utc>,
466 dry_run: bool,
467) -> Result<(), GitError> {
468 let oldest_timestamp = older_than.timestamp();
469 let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
471 let notes_out = list_notes.stdout.take().unwrap();
472
473 let mut get_commit_dates = spawn_git_command(
474 &[
475 "log",
476 "--ignore-missing",
477 "--no-walk",
478 "--pretty=format:%H %ct",
479 "--stdin",
480 ],
481 &None,
482 Some(Stdio::piped()),
483 )?;
484 let dates_in = get_commit_dates.stdin.take().unwrap();
485 let dates_out = get_commit_dates.stdout.take().unwrap();
486
487 if dry_run {
488 let date_collection_handler = thread::spawn(move || {
490 let reader = BufReader::new(dates_out);
491 let mut results = Vec::new();
492 for line in reader.lines().map_while(Result::ok) {
493 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
494 if let Ok(timestamp) = timestamp.parse::<i64>() {
495 if timestamp <= oldest_timestamp {
496 results.push(commit.to_string());
497 }
498 }
499 }
500 }
501 results
502 });
503
504 {
505 let reader = BufReader::new(notes_out);
506 let mut writer = BufWriter::new(dates_in);
507
508 reader.lines().map_while(Result::ok).for_each(|line| {
509 if let Some(line) = line.split_whitespace().nth(1) {
510 writeln!(writer, "{line}").expect("Failed to write to pipe");
511 }
512 });
513 }
514
515 let commits_to_remove = date_collection_handler
516 .join()
517 .expect("Failed to join date collection thread");
518 let count = commits_to_remove.len();
519
520 list_notes.wait()?;
521 get_commit_dates.wait()?;
522
523 if count == 0 {
524 println!(
525 "[DRY-RUN] No measurements older than {} would be removed",
526 older_than
527 );
528 } else {
529 println!(
530 "[DRY-RUN] Would remove measurements from {} commits older than {}",
531 count, older_than
532 );
533 for commit in &commits_to_remove {
534 println!(" {}", commit);
535 }
536 }
537
538 return Ok(());
539 }
540
541 let mut remove_measurements = spawn_git_command(
543 &[
544 "notes",
545 "--ref",
546 reference,
547 "remove",
548 "--stdin",
549 "--ignore-missing",
550 ],
551 &None,
552 Some(Stdio::piped()),
553 )?;
554 let removal_in = remove_measurements.stdin.take().unwrap();
555 let removal_out = remove_measurements.stdout.take().unwrap();
556
557 let removal_handler = thread::spawn(move || {
558 let reader = BufReader::new(dates_out);
559 let mut writer = BufWriter::new(removal_in);
560 for line in reader.lines().map_while(Result::ok) {
561 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
562 if let Ok(timestamp) = timestamp.parse::<i64>() {
563 if timestamp <= oldest_timestamp {
564 writeln!(writer, "{commit}").expect("Could not write to stream");
565 }
566 }
567 }
568 }
569 });
570
571 let debugging_handler = thread::spawn(move || {
572 let reader = BufReader::new(removal_out);
573 reader
574 .lines()
575 .map_while(Result::ok)
576 .for_each(|l| println!("{l}"))
577 });
578
579 {
580 let reader = BufReader::new(notes_out);
581 let mut writer = BufWriter::new(dates_in);
582
583 reader.lines().map_while(Result::ok).for_each(|line| {
584 if let Some(line) = line.split_whitespace().nth(1) {
585 writeln!(writer, "{line}").expect("Failed to write to pipe");
586 }
587 });
588 }
589
590 removal_handler.join().expect("Failed to join");
591 debugging_handler.join().expect("Failed to join");
592
593 list_notes.wait()?;
594 get_commit_dates.wait()?;
595 remove_measurements.wait()?;
596
597 Ok(())
598}
599
600fn new_symbolic_write_ref() -> Result<String, GitError> {
605 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
606
607 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
611 Ok(target)
612}
613
614pub fn create_new_write_ref() -> Result<String> {
618 new_symbolic_write_ref().map_err(|e| anyhow!("{:?}", e))
619}
620
621const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
622
623fn consolidate_write_branches_into(
624 current_upstream_oid: &str,
625 target: &str,
626 except_ref: Option<&str>,
627) -> Result<Vec<Reference>, GitError> {
628 git_update_ref(unindent(
631 format!(
632 r#"
633 start
634 verify {REFS_NOTES_BRANCH} {current_upstream_oid}
635 update {target} {current_upstream_oid} {EMPTY_OID}
636 commit
637 "#
638 )
639 .as_str(),
640 ))?;
641
642 let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
647 let refs = get_refs(additional_args)?
648 .into_iter()
649 .filter(|r| r.refname != except_ref.unwrap_or_default())
650 .collect_vec();
651
652 for reference in &refs {
653 reconcile_branch_with(target, &reference.oid)?;
654 }
655
656 Ok(refs)
657}
658
659fn remove_reference(ref_name: &str) -> Result<(), GitError> {
660 git_update_ref(unindent(
661 format!(
662 r#"
663 start
664 delete {ref_name}
665 commit
666 "#
667 )
668 .as_str(),
669 ))
670}
671
672fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
673 ensure_remote_exists()?;
674 let new_write_ref = new_symbolic_write_ref()?;
682
683 let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
684
685 defer!(if let Err(e) = remove_reference(&merge_ref) {
686 warn!("Failed to delete temp merge ref {merge_ref}: {e:?}");
687 });
688
689 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
696 let refs =
697 consolidate_write_branches_into(¤t_upstream_oid, &merge_ref, Some(&new_write_ref))?;
698
699 if refs.is_empty() && current_upstream_oid == EMPTY_OID {
700 return Err(GitError::MissingMeasurements);
701 }
702
703 git_push_notes_ref(¤t_upstream_oid, &merge_ref, &work_dir, remote)?;
704
705 fetch(None)?;
707
708 let mut commands = Vec::new();
710 commands.push(String::from("start"));
711 for Reference { refname, oid } in &refs {
712 commands.push(format!("delete {refname} {oid}"));
713 }
714 commands.push(String::from("commit"));
715 commands.push(String::new());
717 let commands = commands.join("\n");
718 git_update_ref(commands)?;
719
720 Ok(())
721}
722
723fn acquire_push_lock(working_dir: &Option<&Path>) -> Result<File, GitError> {
740 let lock_path = git_common_dir(working_dir)?.join(PUSH_LOCK_FILE_NAME);
741 let lock_file = OpenOptions::new()
742 .create(true)
743 .write(true)
744 .truncate(false)
745 .open(lock_path)?;
746 lock_file.lock()?;
747 Ok(lock_file)
748}
749
750fn git_push_notes_ref(
751 expected_upstream: &str,
752 push_ref: &str,
753 working_dir: &Option<&Path>,
754 remote: Option<&str>,
755) -> Result<(), GitError> {
756 let _push_lock = acquire_push_lock(working_dir)?;
761
762 let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
763 let output = capture_git_output(
764 &[
765 "push",
766 "--porcelain",
767 format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
768 remote_name,
769 format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
770 ],
771 working_dir,
772 );
773
774 match output {
778 Ok(output) => {
779 print!("{}", output.stdout);
780 Ok(())
781 }
782 Err(GitError::ExecError { output, .. }) => {
783 let successful_push = output.stdout.lines().any(|l| {
784 l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
785 });
786 if successful_push {
787 Ok(())
788 } else {
789 Err(GitError::RefFailedToPush { output })
790 }
791 }
792 Err(e) => Err(e),
793 }?;
794
795 Ok(())
796}
797
798pub fn prune() -> Result<()> {
799 let op = || -> Result<(), ::backoff::Error<GitError>> {
800 raw_prune().map_err(map_git_error_for_backoff)
801 };
802
803 let backoff = default_backoff();
804
805 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
806 ::backoff::Error::Permanent(err) => {
807 anyhow!(err).context("Permanent failure while pruning refs")
808 }
809 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
810 })?;
811
812 Ok(())
813}
814
815fn extract_pid_from_staging_ref(refname: &str, prefix: &str) -> Option<u32> {
816 let suffix = refname.strip_prefix(prefix)?;
817 if !suffix.contains('-') {
819 return None;
820 }
821 let pid_hex = suffix.split('-').next()?;
822 u32::from_str_radix(pid_hex, 16).ok()
823}
824
825fn is_process_alive(pid: u32) -> bool {
826 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
827 let pid = Pid::from(pid as usize);
828 let mut system = System::new();
829 system.refresh_processes_specifics(
830 ProcessesToUpdate::Some(&[pid]),
831 false,
832 ProcessRefreshKind::nothing(),
833 );
834 system.process(pid).is_some()
835}
836
837fn cleanup_orphan_staging_refs() {
838 let prefixes = [
842 REFS_NOTES_ADD_TARGET_PREFIX,
843 REFS_NOTES_MERGE_BRANCH_PREFIX,
844 REFS_NOTES_READ_PREFIX,
845 REFS_NOTES_REWRITE_TARGET_PREFIX,
846 ];
847 for prefix in prefixes {
848 let refs = get_refs(vec![format!("{prefix}*")]).unwrap_or_default();
849 for r in refs {
850 let Some(pid) = extract_pid_from_staging_ref(&r.refname, prefix) else {
851 continue;
854 };
855 if is_process_alive(pid) {
856 continue;
857 }
858 info!("Cleaning up orphan staging ref {}", r.refname);
859 if let Err(e) = remove_reference(&r.refname) {
860 warn!("Failed to clean up orphan ref {}: {e:?}", r.refname);
861 }
862 }
863 }
864}
865
866fn raw_prune() -> Result<(), GitError> {
867 if is_shallow_repo()? {
868 return Err(GitError::ShallowRepository);
869 }
870
871 cleanup_orphan_staging_refs();
872
873 execute_notes_operation(|target| {
874 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
875 })
876}
877
878pub fn list_commits_with_measurements() -> Result<Vec<String>> {
883 let temp_ref = update_read_branch()?;
885
886 let mut list_notes =
889 spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
890
891 let stdout = list_notes
892 .stdout
893 .take()
894 .ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
895
896 let commits: Vec<String> = BufReader::new(stdout)
900 .lines()
901 .filter_map(|line_result| {
902 line_result
903 .ok()
904 .and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
905 })
906 .collect();
907
908 Ok(commits)
909}
910
911pub struct ReadBranchGuard {
914 temp_ref: TempRef,
915}
916
917impl ReadBranchGuard {
918 #[must_use]
920 pub fn ref_name(&self) -> &str {
921 &self.temp_ref.ref_name
922 }
923}
924
925pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
929 let temp_ref = update_read_branch()?;
930 Ok(ReadBranchGuard { temp_ref })
931}
932
933pub fn create_consolidated_pending_read_branch() -> Result<ReadBranchGuard> {
938 let temp_ref = update_pending_read_branch()?;
939 Ok(ReadBranchGuard { temp_ref })
940}
941
942fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
943 let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
944 args.extend(additional_args.iter().map(|s| s.as_str()));
945
946 let output = capture_git_output(&args, &None)?;
947 let refs: Result<Vec<Reference>, _> = output
948 .stdout
949 .lines()
950 .filter(|s| !s.is_empty())
951 .map(|s| {
952 let items = s.split('\0').take(2).collect_vec();
953 if items.len() != 2 {
954 return Err(GitError::ExecError {
955 command: format!("git {}", args.join(" ")),
956 output: GitOutput {
957 stdout: format!("Unexpected git for-each-ref output format: {}", s),
958 stderr: String::new(),
959 },
960 });
961 }
962 Ok(Reference {
963 refname: items[0].to_string(),
964 oid: items[1].to_string(),
965 })
966 })
967 .collect();
968 refs
969}
970
971struct TempRef {
972 ref_name: String,
973}
974
975impl TempRef {
976 fn new(prefix: &str) -> Result<Self, GitError> {
977 Ok(TempRef {
978 ref_name: create_temp_ref(prefix, EMPTY_OID)?,
979 })
980 }
981}
982
983impl Drop for TempRef {
984 fn drop(&mut self) {
985 if let Err(e) = remove_reference(&self.ref_name) {
986 warn!("Failed to remove reference {}: {e:?}", self.ref_name);
987 }
988 }
989}
990
991fn update_read_branch() -> Result<TempRef> {
992 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
993 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
994 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
998
999 consolidate_write_branches_into(¤t_upstream_oid, &temp_ref.ref_name, None)
1000 .map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
1001
1002 Ok(temp_ref)
1003}
1004
1005fn update_pending_read_branch() -> Result<TempRef> {
1006 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
1007 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
1008 let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
1011 .map_err(|e| anyhow!("Failed to get write refs: {:?}", e))?;
1012
1013 for reference in &refs {
1014 reconcile_branch_with(&temp_ref.ref_name, &reference.oid)
1015 .map_err(|e| anyhow!("Failed to merge write ref: {:?}", e))?;
1016 }
1017
1018 Ok(temp_ref)
1019}
1020
1021pub fn walk_commits_from(
1064 start_commit: &str,
1065 num_commits: usize,
1066 since: Option<&str>,
1067 until: Option<&str>,
1068) -> Result<Vec<CommitWithNotes>> {
1069 let temp_ref = update_read_branch()?;
1071
1072 let resolved_commit = resolve_committish(start_commit)
1074 .context(format!("Failed to resolve commit '{}'", start_commit))?;
1075
1076 let num_commits_str = num_commits.to_string();
1077 let notes_ref = format!("--notes={}", temp_ref.ref_name);
1078 let since_arg = since.map(|s| format!("--since={}", s));
1079 let until_arg = until.map(|u| format!("--until={}", u));
1080
1081 let mut args = vec![
1082 "--no-pager",
1083 "log",
1084 "--no-color",
1085 "--ignore-missing",
1086 "-n",
1087 &num_commits_str,
1088 "--first-parent",
1089 "--pretty=--,%H,%s,%an,%D%n%N",
1090 "--decorate=full",
1091 ¬es_ref,
1092 ];
1093 if let Some(ref s) = since_arg {
1094 args.push(s.as_str());
1095 }
1096 if let Some(ref u) = until_arg {
1097 args.push(u.as_str());
1098 }
1099 args.push(&resolved_commit);
1100
1101 let output = capture_git_output(&args, &None)
1102 .context(format!("Failed to retrieve commits from {}", start_commit))?;
1103
1104 let mut commits: Vec<CommitWithNotes> = Vec::new();
1105 let mut detected_shallow = false;
1106 let mut current_commit_sha: Option<String> = None;
1107
1108 for l in output.stdout.lines() {
1109 if l.starts_with("--") {
1110 let parts: Vec<&str> = l.splitn(5, ',').collect();
1112 if parts.len() < 5 {
1113 bail!(
1114 "Invalid git log format: expected 5 fields, got {}",
1115 parts.len()
1116 );
1117 }
1118
1119 let sha = parts[1].to_string();
1120 let title = if parts[2].is_empty() {
1121 "[no subject]".to_string()
1122 } else {
1123 parts[2].to_string()
1124 };
1125 let author = if parts[3].is_empty() {
1126 "[unknown]".to_string()
1127 } else {
1128 parts[3].to_string()
1129 };
1130 let decorations = parts[4];
1131
1132 detected_shallow |= decorations.contains("grafted");
1133 current_commit_sha = Some(sha.clone());
1134
1135 commits.push(CommitWithNotes {
1136 sha,
1137 title,
1138 author,
1139 note_lines: Vec::new(),
1140 });
1141 } else if current_commit_sha.is_some() {
1142 if let Some(last) = commits.last_mut() {
1143 last.note_lines.push(l.to_string());
1144 }
1145 }
1146 }
1147
1148 if detected_shallow && commits.len() < num_commits {
1149 bail!("Refusing to continue as commit log depth was limited by shallow clone");
1150 }
1151
1152 Ok(commits)
1153}
1154
1155pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
1157 walk_commits_from("HEAD", num_commits, None, None)
1158}
1159
1160pub fn get_commits_with_notes(notes_ref: &str) -> Result<Vec<String>> {
1166 let output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1167 .context(format!("Failed to list notes in {}", notes_ref))?;
1168
1169 let commits: Vec<String> = output
1171 .stdout
1172 .lines()
1173 .filter(|line| !line.is_empty())
1174 .filter_map(|line| {
1175 let parts: Vec<&str> = line.split_whitespace().collect();
1176 if parts.len() >= 2 {
1177 Some(parts[1].to_string())
1178 } else {
1179 None
1180 }
1181 })
1182 .collect();
1183
1184 Ok(commits)
1185}
1186
1187pub fn get_commits_with_notes_content(notes_ref: &str) -> Result<Vec<CommitWithNotes>> {
1194 let list_output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1196 .context(format!("Failed to list notes in {}", notes_ref))?;
1197
1198 let commit_shas: Vec<String> = list_output
1199 .stdout
1200 .lines()
1201 .filter(|l| !l.is_empty())
1202 .filter_map(|line| {
1203 let parts: Vec<&str> = line.split_whitespace().collect();
1204 (parts.len() >= 2).then(|| parts[1].to_string())
1205 })
1206 .collect();
1207
1208 if commit_shas.is_empty() {
1209 return Ok(Vec::new());
1210 }
1211
1212 let notes_flag = format!("--notes={}", notes_ref);
1216 let mut args = vec![
1217 "--no-pager",
1218 "log",
1219 "--no-color",
1220 "--no-walk",
1221 "--pretty=format:%H%x00%s%x00%an%n%N",
1222 ¬es_flag,
1223 ];
1224 args.extend(commit_shas.iter().map(|s| s.as_str()));
1225
1226 let output =
1227 capture_git_output(&args, &None).context("Failed to fetch commit details and notes")?;
1228
1229 let mut commits: Vec<CommitWithNotes> = Vec::new();
1232
1233 for line in output.stdout.lines() {
1234 if line.contains('\0') {
1235 let mut parts = line.splitn(3, '\0');
1236 let sha = parts.next().unwrap_or("").to_string();
1237 let title = parts
1238 .next()
1239 .map(|s| if s.is_empty() { "[no subject]" } else { s })
1240 .unwrap_or("[no subject]")
1241 .to_string();
1242 let author = parts
1243 .next()
1244 .map(|s| if s.is_empty() { "[unknown]" } else { s })
1245 .unwrap_or("[unknown]")
1246 .to_string();
1247 commits.push(CommitWithNotes {
1248 sha,
1249 title,
1250 author,
1251 note_lines: Vec::new(),
1252 });
1253 } else if let Some(last) = commits.last_mut() {
1254 last.note_lines.push(line.to_string());
1255 }
1256 }
1257
1258 Ok(commits)
1259}
1260
1261pub fn pull(work_dir: Option<&Path>) -> Result<()> {
1262 pull_internal(work_dir)?;
1263 Ok(())
1264}
1265
1266fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
1267 fetch(work_dir)?;
1268 Ok(())
1269}
1270
1271pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
1272 let op = || {
1273 raw_push(work_dir, remote)
1274 .map_err(map_git_error_for_backoff)
1275 .map_err(|e: ::backoff::Error<GitError>| match e {
1276 ::backoff::Error::Transient { .. } => {
1277 let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
1279
1280 let pull_succeeded = pull_result.is_ok()
1284 || matches!(
1285 pull_result,
1286 Err(::backoff::Error::Permanent(
1287 GitError::RefConcurrentModification { .. }
1288 | GitError::RefFailedToLock { .. }
1289 ))
1290 );
1291
1292 if pull_succeeded {
1293 e
1296 } else {
1297 pull_result.unwrap_err()
1299 }
1300 }
1301 ::backoff::Error::Permanent { .. } => e,
1302 })
1303 };
1304
1305 let backoff = default_backoff();
1306
1307 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
1308 ::backoff::Error::Permanent(err) => {
1309 anyhow!(err).context("Permanent failure while pushing refs")
1310 }
1311 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
1312 })?;
1313
1314 Ok(())
1315}
1316
1317pub fn get_write_refs() -> Result<Vec<(String, String)>> {
1319 let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
1320 .map_err(|e| anyhow!("{:?}", e))?;
1321 Ok(refs.into_iter().map(|r| (r.refname, r.oid)).collect())
1322}
1323
1324pub fn delete_reference(ref_name: &str) -> Result<()> {
1326 remove_reference(ref_name).map_err(|e| anyhow!("{:?}", e))
1327}
1328
1329#[cfg(test)]
1330mod test {
1331 use super::*;
1332 use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
1333 use std::process::Command;
1334 use std::sync::{
1335 atomic::{AtomicBool, Ordering},
1336 Arc,
1337 };
1338
1339 use httptest::{
1340 http::{header::AUTHORIZATION, Uri},
1341 matchers::{self, request},
1342 responders::status_code,
1343 Expectation, Server,
1344 };
1345
1346 fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
1347 let url = origin_url.to_string();
1348
1349 run_git_command(&["remote", "add", "origin", &url], dir);
1350 run_git_command(
1351 &[
1352 "config",
1353 "--add",
1354 format!("http.{}.extraHeader", url).as_str(),
1355 extra_header,
1356 ],
1357 dir,
1358 );
1359 }
1360
1361 #[test]
1362 fn test_customheader_pull() {
1363 with_isolated_cwd_git(|git_dir| {
1364 let mut test_server = Server::run();
1365 add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
1366
1367 test_server.expect(
1368 Expectation::matching(request::headers(matchers::contains((
1369 AUTHORIZATION.as_str(),
1370 "sometoken",
1371 ))))
1372 .times(1..)
1373 .respond_with(status_code(200)),
1374 );
1375
1376 let _ = pull(None); test_server.verify_and_clear();
1383 });
1384 }
1385
1386 #[test]
1387 fn test_customheader_push() {
1388 with_isolated_cwd_git(|git_dir| {
1389 let test_server = Server::run();
1390 add_server_remote(
1391 test_server.url(""),
1392 "AUTHORIZATION: someothertoken",
1393 git_dir,
1394 );
1395
1396 test_server.expect(
1397 Expectation::matching(request::headers(matchers::contains((
1398 AUTHORIZATION.as_str(),
1399 "someothertoken",
1400 ))))
1401 .times(1..)
1402 .respond_with(status_code(200)),
1403 );
1404
1405 ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
1407 add_note_line_to_head("test note line").expect("Failed to add note line");
1408
1409 let error = push(None, None);
1410 error
1411 .as_ref()
1412 .expect_err("We have no valid git http server setup -> should fail");
1413 dbg!(&error);
1414 });
1415 }
1416
1417 #[test]
1418 fn test_random_suffix() {
1419 for _ in 1..1000 {
1420 let first = random_suffix();
1421 let second = random_suffix();
1422
1423 let is_valid = |s: &String| {
1425 let parts: Vec<&str> = s.splitn(2, '-').collect();
1426 parts.len() == 2
1427 && parts[0].len() == 8
1428 && parts[0].chars().all(|c| c.is_ascii_hexdigit())
1429 && parts[1].len() == 8
1430 && parts[1].chars().all(|c| c.is_ascii_hexdigit())
1431 };
1432
1433 assert_ne!(first, second);
1434 assert!(is_valid(&first), "Invalid suffix format: {}", first);
1435 assert!(is_valid(&second), "Invalid suffix format: {}", second);
1436 }
1437 }
1438
1439 #[test]
1440 fn test_empty_or_never_pushed_remote_error_for_fetch() {
1441 with_isolated_cwd_git(|git_dir| {
1442 let git_dir_url = format!("file://{}", git_dir.display());
1444 run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
1445
1446 std::env::set_var("GIT_TRACE", "true");
1448
1449 let result = super::fetch(Some(git_dir));
1451 match result {
1452 Err(GitError::NoRemoteMeasurements { output }) => {
1453 assert!(
1454 output.stderr.contains(GIT_PERF_REMOTE),
1455 "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
1456 output.stderr
1457 )
1458 }
1459 other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
1460 }
1461 });
1462 }
1463
1464 #[test]
1465 fn test_empty_or_never_pushed_remote_error_for_push() {
1466 with_isolated_cwd_git(|git_dir| {
1467 run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
1468
1469 std::env::set_var("GIT_TRACE", "true");
1471
1472 add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
1473
1474 let result = super::raw_push(Some(git_dir), None);
1475 match result {
1476 Err(GitError::RefFailedToPush { output }) => {
1477 assert!(
1478 output.stderr.contains(GIT_PERF_REMOTE),
1479 "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
1480 output.stderr
1481 )
1482 }
1483 other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1484 }
1485 });
1486 }
1487
1488 #[test]
1493 fn test_acquire_push_lock_creates_lock_file_in_git_common_dir() {
1494 with_isolated_cwd_git(|git_dir| {
1495 let lock = acquire_push_lock(&None).expect("should acquire push lock");
1496 drop(lock);
1497
1498 let expected_path = git_dir.join(".git").join(PUSH_LOCK_FILE_NAME);
1499 assert!(
1500 expected_path.exists(),
1501 "Expected lock file at {:?}",
1502 expected_path
1503 );
1504 });
1505 }
1506
1507 #[test]
1513 fn test_acquire_push_lock_excludes_concurrent_access() {
1514 with_isolated_cwd_git(|_git_dir| {
1515 let first = acquire_push_lock(&None).expect("first lock should succeed");
1516
1517 let lock_path = git_common_dir(&None)
1518 .expect("git common dir")
1519 .join(PUSH_LOCK_FILE_NAME);
1520 let second = OpenOptions::new()
1521 .create(true)
1522 .write(true)
1523 .truncate(false)
1524 .open(&lock_path)
1525 .expect("open lock file for contention probe");
1526 assert!(
1527 second.try_lock().is_err(),
1528 "Second handle should not be able to lock while the first is held"
1529 );
1530
1531 drop(first);
1532
1533 second
1534 .try_lock()
1535 .expect("Second handle should acquire the lock once the first is released");
1536 });
1537 }
1538
1539 #[test]
1545 fn test_acquire_push_lock_blocks_until_released() {
1546 with_isolated_cwd_git(|git_dir| {
1547 let git_dir = git_dir.to_path_buf();
1548 let first =
1549 acquire_push_lock(&Some(git_dir.as_path())).expect("first lock should succeed");
1550
1551 let acquired = Arc::new(AtomicBool::new(false));
1552 let acquired_writer = Arc::clone(&acquired);
1553 let waiter_git_dir = git_dir.clone();
1554 let waiter = thread::spawn(move || {
1555 let _second = acquire_push_lock(&Some(waiter_git_dir.as_path()))
1556 .expect("second lock should eventually succeed");
1557 acquired_writer.store(true, Ordering::SeqCst);
1558 });
1559
1560 thread::sleep(Duration::from_millis(200));
1561 assert!(
1562 !acquired.load(Ordering::SeqCst),
1563 "Contender should still be blocked while the first lock is held"
1564 );
1565
1566 drop(first);
1567 waiter.join().expect("waiter thread should not panic");
1568 assert!(
1569 acquired.load(Ordering::SeqCst),
1570 "Contender should acquire the lock after it is released"
1571 );
1572 });
1573 }
1574
1575 #[test]
1580 fn test_new_symbolic_write_ref_returns_valid_ref() {
1581 with_isolated_cwd_git(|_git_dir| {
1582 let result = new_symbolic_write_ref();
1584 assert!(
1585 result.is_ok(),
1586 "Should create symbolic write ref: {:?}",
1587 result
1588 );
1589
1590 let ref_name = result.unwrap();
1591
1592 assert!(
1594 !ref_name.is_empty(),
1595 "Reference name should not be empty, got: '{}'",
1596 ref_name
1597 );
1598
1599 assert!(
1601 ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
1602 "Reference should start with {}, got: {}",
1603 REFS_NOTES_WRITE_TARGET_PREFIX,
1604 ref_name
1605 );
1606
1607 let suffix = ref_name
1609 .strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
1610 .expect("Should have prefix");
1611 let parts: Vec<&str> = suffix.splitn(2, '-').collect();
1612 let is_valid_suffix = parts.len() == 2
1613 && parts[0].len() == 8
1614 && parts[0].chars().all(|c| c.is_ascii_hexdigit())
1615 && parts[1].len() == 8
1616 && parts[1].chars().all(|c| c.is_ascii_hexdigit());
1617 assert!(
1618 is_valid_suffix,
1619 "Suffix should be in format {{pid:08x}}-{{random:08x}}, got: {}",
1620 suffix
1621 );
1622 });
1623 }
1624
1625 #[test]
1628 fn test_add_and_retrieve_notes() {
1629 with_isolated_cwd_git(|_git_dir| {
1630 let result = add_note_line_to_head("test: 100");
1632 assert!(
1633 result.is_ok(),
1634 "Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
1635 result
1636 );
1637
1638 let result2 = add_note_line_to_head("test: 200");
1640 assert!(result2.is_ok(), "Should add second note: {:?}", result2);
1641
1642 let commits = walk_commits(10);
1644 assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
1645
1646 let commits = commits.unwrap();
1647 assert!(!commits.is_empty(), "Should have commits");
1648
1649 let commit_with_notes = &commits[0];
1651 assert!(
1652 !commit_with_notes.note_lines.is_empty(),
1653 "HEAD should have notes"
1654 );
1655 assert!(
1656 commit_with_notes
1657 .note_lines
1658 .iter()
1659 .any(|n| n.contains("test:")),
1660 "Notes should contain our test data"
1661 );
1662 });
1663 }
1664
1665 #[test]
1669 fn test_walk_commits_shallow_repo_detection() {
1670 use std::env::set_current_dir;
1671
1672 with_isolated_cwd_git(|git_dir| {
1673 for i in 2..=5 {
1675 run_git_command(
1676 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1677 git_dir,
1678 );
1679 }
1680
1681 let shallow_dir = git_dir.join("shallow");
1683 let output = Command::new("git")
1684 .args([
1685 "clone",
1686 "--depth",
1687 "2",
1688 git_dir.to_str().unwrap(),
1689 shallow_dir.to_str().unwrap(),
1690 ])
1691 .output()
1692 .unwrap();
1693
1694 assert!(
1695 output.status.success(),
1696 "Shallow clone failed: {}",
1697 String::from_utf8_lossy(&output.stderr)
1698 );
1699
1700 set_current_dir(&shallow_dir).unwrap();
1702
1703 add_note_line_to_head("test: 100").expect("Should add note");
1705
1706 let result = walk_commits(10);
1708 assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
1709
1710 let commits = result.unwrap();
1711
1712 assert!(
1718 !commits.is_empty(),
1719 "Should have found commits in shallow repo"
1720 );
1721 });
1722 }
1723
1724 #[test]
1726 fn test_walk_commits_normal_repo_not_shallow() {
1727 with_isolated_cwd_git(|git_dir| {
1728 for i in 2..=3 {
1730 run_git_command(
1731 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1732 git_dir,
1733 );
1734 }
1735
1736 add_note_line_to_head("test: 100").expect("Should add note");
1738
1739 let result = walk_commits(10);
1740 assert!(result.is_ok(), "walk_commits should succeed");
1741
1742 let commits = result.unwrap();
1743
1744 assert!(!commits.is_empty(), "Should have found commits");
1746 });
1747 }
1748
1749 #[test]
1750 fn test_extract_pid_from_staging_ref() {
1751 let refname = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "deadbeef-01234567");
1753 assert_eq!(
1754 extract_pid_from_staging_ref(&refname, REFS_NOTES_ADD_TARGET_PREFIX),
1755 Some(0xdeadbeef),
1756 );
1757
1758 let old_refname = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "deadbeef");
1761 assert_eq!(
1762 extract_pid_from_staging_ref(&old_refname, REFS_NOTES_ADD_TARGET_PREFIX),
1763 None,
1764 "Old-format refs without a dash must return None to avoid kill(-N, 0) on a process group",
1765 );
1766
1767 let wrong_prefix = format!("{}{}", REFS_NOTES_WRITE_TARGET_PREFIX, "deadbeef-01234567");
1769 assert_eq!(
1770 extract_pid_from_staging_ref(&wrong_prefix, REFS_NOTES_ADD_TARGET_PREFIX),
1771 None,
1772 );
1773
1774 let bad_hex = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "xyz00000-01234567");
1776 assert_eq!(
1777 extract_pid_from_staging_ref(&bad_hex, REFS_NOTES_ADD_TARGET_PREFIX),
1778 None,
1779 );
1780 }
1781
1782 #[test]
1783 fn test_is_process_alive_returns_true_for_current_process() {
1784 assert!(
1785 is_process_alive(std::process::id()),
1786 "Current process must be alive",
1787 );
1788 }
1789
1790 #[cfg(unix)]
1791 #[test]
1792 fn test_is_process_alive_returns_false_for_dead_process() {
1793 let mut child = std::process::Command::new("true")
1794 .spawn()
1795 .expect("Failed to spawn 'true'");
1796 let pid = child.id();
1797 child.wait().expect("Failed to wait for child");
1798 assert!(
1800 !is_process_alive(pid),
1801 "PID {pid} should be dead after process exited",
1802 );
1803 }
1804
1805 #[test]
1810 fn test_raw_add_note_line_succeeds_in_normal_repo() {
1811 with_isolated_cwd_git(|_git_dir| {
1812 let result = raw_add_note_line("HEAD", "test_measurement=1.0");
1813 assert!(
1814 result.is_ok(),
1815 "raw_add_note_line should succeed in a freshly initialised repo: {:?}",
1816 result
1817 );
1818 });
1819 }
1820
1821 #[test]
1827 fn test_execute_notes_operation_noop_skips_upstream_push() {
1828 use tempfile::tempdir;
1829
1830 with_isolated_cwd_git(|git_dir| {
1831 let upstream = tempdir().unwrap();
1833 run_git_command(&["init", "--bare"], upstream.path());
1834 let upstream_url = format!("file://{}", upstream.path().display());
1835 run_git_command(&["remote", "add", "origin", &upstream_url], git_dir);
1836 run_git_command(&["push", "origin", "master"], git_dir);
1837
1838 add_note_line_to_head("test: 1").expect("add note");
1840 push(None, None).expect("push notes");
1841
1842 let head_before = git_rev_parse(REFS_NOTES_BRANCH).expect("notes branch exists");
1843
1844 execute_notes_operation(|_target| Ok(()))
1846 .expect("no-op execute_notes_operation should succeed");
1847
1848 let head_after = git_rev_parse(REFS_NOTES_BRANCH).expect("notes branch still exists");
1850 assert_eq!(
1851 head_before, head_after,
1852 "no-op execute_notes_operation must not advance the notes branch"
1853 );
1854
1855 let rewrite_refs = get_refs(vec![format!("{REFS_NOTES_REWRITE_TARGET_PREFIX}*")])
1857 .expect("list rewrite refs");
1858 assert!(
1859 rewrite_refs.is_empty(),
1860 "temp rewrite refs must be cleaned up after no-op: {:?}",
1861 rewrite_refs
1862 );
1863 });
1864 }
1865}