1use std::{
2 io::{BufRead, BufReader, BufWriter, Write},
3 path::Path,
4 process::Stdio,
5 thread,
6 time::Duration,
7};
8
9use defer::defer;
10use log::{debug, info, warn};
11use unindent::unindent;
12
13use anyhow::{anyhow, bail, Context, Result};
14use backoff::{ExponentialBackoff, ExponentialBackoffBuilder};
15use itertools::Itertools;
16
17use chrono::prelude::*;
18use rand::{rng, RngExt};
19
20use crate::config;
21
22pub use super::git_definitions::REFS_NOTES_BRANCH;
23use super::git_definitions::{
24 GIT_ORIGIN, GIT_PERF_REMOTE, REFS_NOTES_ADD_TARGET_PREFIX, REFS_NOTES_MERGE_BRANCH_PREFIX,
25 REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX, REFS_NOTES_WRITE_SYMBOLIC_REF,
26 REFS_NOTES_WRITE_TARGET_PREFIX,
27};
28use super::git_lowlevel::{
29 capture_git_output, get_git_perf_remote, git_rev_parse, git_rev_parse_symbolic_ref,
30 git_symbolic_ref_create_or_update, git_update_ref, internal_get_head_revision, is_shallow_repo,
31 map_git_error, set_git_perf_remote, spawn_git_command,
32};
33use super::git_types::GitError;
34use super::git_types::GitOutput;
35use super::git_types::Reference;
36
37pub use super::git_lowlevel::get_head_revision;
38
39pub use super::git_lowlevel::check_git_version;
40
41pub use super::git_lowlevel::get_repository_root;
42
43pub use super::git_lowlevel::resolve_committish;
44
45#[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
60pub 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
82fn 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
90pub 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
141pub 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 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(¤t_note_head)?;
159
160 defer!(if let Err(e) = remove_reference(&temp_target) {
161 warn!("Failed to delete temp add ref {temp_target}: {e:?}");
162 });
163
164 let resolved_commit = git_rev_parse(commit)?;
166
167 capture_git_output(
168 &[
169 "notes",
170 "--ref",
171 &temp_target,
172 "append",
173 "-m",
174 line,
175 &resolved_commit,
176 ],
177 &None,
178 )?;
179
180 git_update_ref(unindent(
186 format!(
187 r#"
188 start
189 update {current_symbolic_ref_target} {temp_target} {current_note_head}
190 commit
191 "#
192 )
193 .as_str(),
194 ))?;
195
196 Ok(())
197}
198
199fn ensure_remote_exists() -> Result<(), GitError> {
200 if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
201 return Ok(());
202 }
203
204 if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
205 return set_git_perf_remote(GIT_PERF_REMOTE, &x);
206 }
207
208 Err(GitError::NoUpstream {})
209}
210
211fn create_temp_ref_name(prefix: &str) -> String {
213 let suffix = random_suffix();
214 format!("{prefix}{suffix}")
215}
216
217fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
218 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
219 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
220
221 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
225 |err| {
226 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
228 Ok(())
229 } else {
230 Err(err)
231 }
232 },
233 )?;
234 }
235 Ok(())
236}
237
238fn random_suffix() -> String {
239 let pid = std::process::id();
240 let random: u32 = rng().random::<u32>();
241 format!("{pid:08x}-{random:08x}")
242}
243
244fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
245 ensure_remote_exists()?;
246
247 let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
248 capture_git_output(
250 &[
251 "fetch",
252 "--atomic",
253 "--no-write-fetch-head",
254 GIT_PERF_REMOTE,
255 format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
259 ],
260 &work_dir,
261 )
262 .map_err(map_git_error)?;
263
264 let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
265
266 if ref_before == ref_after {
267 println!("Already up to date");
268 }
269
270 Ok(())
271}
272
273fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
276 _ = capture_git_output(
277 &[
278 "notes",
279 "--ref",
280 target,
281 "merge",
282 "-s",
283 "cat_sort_uniq",
284 branch,
285 ],
286 &None,
287 )?;
288 Ok(())
289}
290
291fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
292 let target = create_temp_ref_name(prefix);
293 if current_head != EMPTY_OID {
294 git_update_ref(unindent(
295 format!(
296 r#"
297 start
298 create {target} {current_head}
299 commit
300 "#
301 )
302 .as_str(),
303 ))?;
304 }
305 Ok(target)
306}
307
308fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
309 create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
310}
311
312fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
313 create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
314}
315
316fn compact_head(target: &str) -> Result<(), GitError> {
317 let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
318
319 let compaction_head = capture_git_output(
321 &["commit-tree", "-m", "cutoff history", &new_removal_head],
322 &None,
323 )?
324 .stdout;
325
326 let compaction_head = compaction_head.trim();
327
328 git_update_ref(unindent(
329 format!(
330 r#"
331 start
332 update {target} {compaction_head}
333 commit
334 "#
335 )
336 .as_str(),
337 ))?;
338
339 Ok(())
340}
341
342fn retry_notify(err: GitError, dur: Duration) {
343 debug!("Error happened at {dur:?}: {err}");
344 warn!("Retrying...");
345}
346
347pub fn remove_measurements_from_commits(
348 older_than: DateTime<Utc>,
349 prune: bool,
350 dry_run: bool,
351) -> Result<()> {
352 if dry_run {
353 return raw_remove_measurements_from_commits(older_than, prune, dry_run)
355 .map_err(|e| anyhow!(e));
356 }
357
358 let op = || -> Result<(), ::backoff::Error<GitError>> {
359 raw_remove_measurements_from_commits(older_than, prune, dry_run)
360 .map_err(map_git_error_for_backoff)
361 };
362
363 let backoff = default_backoff();
364
365 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
366 ::backoff::Error::Permanent(err) => {
367 anyhow!(err).context("Permanent failure while removing measurements")
368 }
369 ::backoff::Error::Transient { err, .. } => {
370 anyhow!(err).context("Timed out while removing measurements")
371 }
372 })?;
373
374 Ok(())
375}
376
377fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
378where
379 F: FnOnce(&str) -> Result<(), GitError>,
380{
381 pull_internal(None)?;
382
383 let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
384 let target = create_temp_rewrite_head(¤t_notes_head)?;
385
386 operation(&target)?;
387
388 compact_head(&target)?;
389
390 git_push_notes_ref(¤t_notes_head, &target, &None, None)?;
391
392 git_update_ref(unindent(
393 format!(
394 r#"
395 start
396 update {REFS_NOTES_BRANCH} {target}
397 commit
398 "#
399 )
400 .as_str(),
401 ))?;
402
403 if let Err(e) = remove_reference(&target) {
404 warn!("Failed to delete temp notes ref {target}: {e:?}");
405 }
406
407 Ok(())
408}
409
410fn raw_remove_measurements_from_commits(
411 older_than: DateTime<Utc>,
412 prune: bool,
413 dry_run: bool,
414) -> Result<(), GitError> {
415 if prune && is_shallow_repo()? {
417 return Err(GitError::ShallowRepository);
418 }
419
420 if dry_run {
421 remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
423 if prune {
424 println!("[DRY-RUN] Would prune orphaned measurements after removal");
425 }
426 return Ok(());
427 }
428
429 execute_notes_operation(|target| {
430 remove_measurements_from_reference(target, older_than, dry_run)?;
432
433 if prune {
435 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
436 }
437
438 Ok(())
439 })
440}
441
442fn remove_measurements_from_reference(
444 reference: &str,
445 older_than: DateTime<Utc>,
446 dry_run: bool,
447) -> Result<(), GitError> {
448 let oldest_timestamp = older_than.timestamp();
449 let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
451 let notes_out = list_notes.stdout.take().unwrap();
452
453 let mut get_commit_dates = spawn_git_command(
454 &[
455 "log",
456 "--ignore-missing",
457 "--no-walk",
458 "--pretty=format:%H %ct",
459 "--stdin",
460 ],
461 &None,
462 Some(Stdio::piped()),
463 )?;
464 let dates_in = get_commit_dates.stdin.take().unwrap();
465 let dates_out = get_commit_dates.stdout.take().unwrap();
466
467 if dry_run {
468 let date_collection_handler = thread::spawn(move || {
470 let reader = BufReader::new(dates_out);
471 let mut results = Vec::new();
472 for line in reader.lines().map_while(Result::ok) {
473 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
474 if let Ok(timestamp) = timestamp.parse::<i64>() {
475 if timestamp <= oldest_timestamp {
476 results.push(commit.to_string());
477 }
478 }
479 }
480 }
481 results
482 });
483
484 {
485 let reader = BufReader::new(notes_out);
486 let mut writer = BufWriter::new(dates_in);
487
488 reader.lines().map_while(Result::ok).for_each(|line| {
489 if let Some(line) = line.split_whitespace().nth(1) {
490 writeln!(writer, "{line}").expect("Failed to write to pipe");
491 }
492 });
493 }
494
495 let commits_to_remove = date_collection_handler
496 .join()
497 .expect("Failed to join date collection thread");
498 let count = commits_to_remove.len();
499
500 list_notes.wait()?;
501 get_commit_dates.wait()?;
502
503 if count == 0 {
504 println!(
505 "[DRY-RUN] No measurements older than {} would be removed",
506 older_than
507 );
508 } else {
509 println!(
510 "[DRY-RUN] Would remove measurements from {} commits older than {}",
511 count, older_than
512 );
513 for commit in &commits_to_remove {
514 println!(" {}", commit);
515 }
516 }
517
518 return Ok(());
519 }
520
521 let mut remove_measurements = spawn_git_command(
523 &[
524 "notes",
525 "--ref",
526 reference,
527 "remove",
528 "--stdin",
529 "--ignore-missing",
530 ],
531 &None,
532 Some(Stdio::piped()),
533 )?;
534 let removal_in = remove_measurements.stdin.take().unwrap();
535 let removal_out = remove_measurements.stdout.take().unwrap();
536
537 let removal_handler = thread::spawn(move || {
538 let reader = BufReader::new(dates_out);
539 let mut writer = BufWriter::new(removal_in);
540 for line in reader.lines().map_while(Result::ok) {
541 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
542 if let Ok(timestamp) = timestamp.parse::<i64>() {
543 if timestamp <= oldest_timestamp {
544 writeln!(writer, "{commit}").expect("Could not write to stream");
545 }
546 }
547 }
548 }
549 });
550
551 let debugging_handler = thread::spawn(move || {
552 let reader = BufReader::new(removal_out);
553 reader
554 .lines()
555 .map_while(Result::ok)
556 .for_each(|l| println!("{l}"))
557 });
558
559 {
560 let reader = BufReader::new(notes_out);
561 let mut writer = BufWriter::new(dates_in);
562
563 reader.lines().map_while(Result::ok).for_each(|line| {
564 if let Some(line) = line.split_whitespace().nth(1) {
565 writeln!(writer, "{line}").expect("Failed to write to pipe");
566 }
567 });
568 }
569
570 removal_handler.join().expect("Failed to join");
571 debugging_handler.join().expect("Failed to join");
572
573 list_notes.wait()?;
574 get_commit_dates.wait()?;
575 remove_measurements.wait()?;
576
577 Ok(())
578}
579
580fn new_symbolic_write_ref() -> Result<String, GitError> {
585 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
586
587 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
591 Ok(target)
592}
593
594pub fn create_new_write_ref() -> Result<String> {
598 new_symbolic_write_ref().map_err(|e| anyhow!("{:?}", e))
599}
600
601const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
602
603fn consolidate_write_branches_into(
604 current_upstream_oid: &str,
605 target: &str,
606 except_ref: Option<&str>,
607) -> Result<Vec<Reference>, GitError> {
608 git_update_ref(unindent(
611 format!(
612 r#"
613 start
614 verify {REFS_NOTES_BRANCH} {current_upstream_oid}
615 update {target} {current_upstream_oid} {EMPTY_OID}
616 commit
617 "#
618 )
619 .as_str(),
620 ))?;
621
622 let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
627 let refs = get_refs(additional_args)?
628 .into_iter()
629 .filter(|r| r.refname != except_ref.unwrap_or_default())
630 .collect_vec();
631
632 for reference in &refs {
633 reconcile_branch_with(target, &reference.oid)?;
634 }
635
636 Ok(refs)
637}
638
639fn remove_reference(ref_name: &str) -> Result<(), GitError> {
640 git_update_ref(unindent(
641 format!(
642 r#"
643 start
644 delete {ref_name}
645 commit
646 "#
647 )
648 .as_str(),
649 ))
650}
651
652fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
653 ensure_remote_exists()?;
654 let new_write_ref = new_symbolic_write_ref()?;
662
663 let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
664
665 defer!(if let Err(e) = remove_reference(&merge_ref) {
666 warn!("Failed to delete temp merge ref {merge_ref}: {e:?}");
667 });
668
669 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
676 let refs =
677 consolidate_write_branches_into(¤t_upstream_oid, &merge_ref, Some(&new_write_ref))?;
678
679 if refs.is_empty() && current_upstream_oid == EMPTY_OID {
680 return Err(GitError::MissingMeasurements);
681 }
682
683 git_push_notes_ref(¤t_upstream_oid, &merge_ref, &work_dir, remote)?;
684
685 fetch(None)?;
687
688 let mut commands = Vec::new();
690 commands.push(String::from("start"));
691 for Reference { refname, oid } in &refs {
692 commands.push(format!("delete {refname} {oid}"));
693 }
694 commands.push(String::from("commit"));
695 commands.push(String::new());
697 let commands = commands.join("\n");
698 git_update_ref(commands)?;
699
700 Ok(())
701}
702
703fn git_push_notes_ref(
704 expected_upstream: &str,
705 push_ref: &str,
706 working_dir: &Option<&Path>,
707 remote: Option<&str>,
708) -> Result<(), GitError> {
709 let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
712 let output = capture_git_output(
713 &[
714 "push",
715 "--porcelain",
716 format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
717 remote_name,
718 format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
719 ],
720 working_dir,
721 );
722
723 match output {
727 Ok(output) => {
728 print!("{}", &output.stdout);
729 Ok(())
730 }
731 Err(GitError::ExecError { output, .. }) => {
732 let successful_push = output.stdout.lines().any(|l| {
733 l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
734 });
735 if successful_push {
736 Ok(())
737 } else {
738 Err(GitError::RefFailedToPush { output })
739 }
740 }
741 Err(e) => Err(e),
742 }?;
743
744 Ok(())
745}
746
747pub fn prune() -> Result<()> {
748 let op = || -> Result<(), ::backoff::Error<GitError>> {
749 raw_prune().map_err(map_git_error_for_backoff)
750 };
751
752 let backoff = default_backoff();
753
754 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
755 ::backoff::Error::Permanent(err) => {
756 anyhow!(err).context("Permanent failure while pruning refs")
757 }
758 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
759 })?;
760
761 Ok(())
762}
763
764fn extract_pid_from_staging_ref(refname: &str, prefix: &str) -> Option<u32> {
765 let suffix = refname.strip_prefix(prefix)?;
766 if !suffix.contains('-') {
768 return None;
769 }
770 let pid_hex = suffix.split('-').next()?;
771 u32::from_str_radix(pid_hex, 16).ok()
772}
773
774fn is_process_alive(pid: u32) -> bool {
775 use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
776 let pid = Pid::from(pid as usize);
777 let mut system = System::new();
778 system.refresh_processes_specifics(
779 ProcessesToUpdate::Some(&[pid]),
780 false,
781 ProcessRefreshKind::nothing(),
782 );
783 system.process(pid).is_some()
784}
785
786fn cleanup_orphan_staging_refs() {
787 let prefixes = [
791 REFS_NOTES_ADD_TARGET_PREFIX,
792 REFS_NOTES_MERGE_BRANCH_PREFIX,
793 REFS_NOTES_READ_PREFIX,
794 REFS_NOTES_REWRITE_TARGET_PREFIX,
795 ];
796 for prefix in prefixes {
797 let refs = get_refs(vec![format!("{prefix}*")]).unwrap_or_default();
798 for r in refs {
799 let Some(pid) = extract_pid_from_staging_ref(&r.refname, prefix) else {
800 continue;
803 };
804 if is_process_alive(pid) {
805 continue;
806 }
807 info!("Cleaning up orphan staging ref {}", r.refname);
808 if let Err(e) = remove_reference(&r.refname) {
809 warn!("Failed to clean up orphan ref {}: {e:?}", r.refname);
810 }
811 }
812 }
813}
814
815fn raw_prune() -> Result<(), GitError> {
816 if is_shallow_repo()? {
817 return Err(GitError::ShallowRepository);
818 }
819
820 cleanup_orphan_staging_refs();
821
822 execute_notes_operation(|target| {
823 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
824 })
825}
826
827pub fn list_commits_with_measurements() -> Result<Vec<String>> {
832 let temp_ref = update_read_branch()?;
834
835 let mut list_notes =
838 spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
839
840 let stdout = list_notes
841 .stdout
842 .take()
843 .ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
844
845 let commits: Vec<String> = BufReader::new(stdout)
849 .lines()
850 .filter_map(|line_result| {
851 line_result
852 .ok()
853 .and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
854 })
855 .collect();
856
857 Ok(commits)
858}
859
860pub struct ReadBranchGuard {
863 temp_ref: TempRef,
864}
865
866impl ReadBranchGuard {
867 #[must_use]
869 pub fn ref_name(&self) -> &str {
870 &self.temp_ref.ref_name
871 }
872}
873
874pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
878 let temp_ref = update_read_branch()?;
879 Ok(ReadBranchGuard { temp_ref })
880}
881
882pub fn create_consolidated_pending_read_branch() -> Result<ReadBranchGuard> {
887 let temp_ref = update_pending_read_branch()?;
888 Ok(ReadBranchGuard { temp_ref })
889}
890
891fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
892 let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
893 args.extend(additional_args.iter().map(|s| s.as_str()));
894
895 let output = capture_git_output(&args, &None)?;
896 let refs: Result<Vec<Reference>, _> = output
897 .stdout
898 .lines()
899 .filter(|s| !s.is_empty())
900 .map(|s| {
901 let items = s.split('\0').take(2).collect_vec();
902 if items.len() != 2 {
903 return Err(GitError::ExecError {
904 command: format!("git {}", args.join(" ")),
905 output: GitOutput {
906 stdout: format!("Unexpected git for-each-ref output format: {}", s),
907 stderr: String::new(),
908 },
909 });
910 }
911 Ok(Reference {
912 refname: items[0].to_string(),
913 oid: items[1].to_string(),
914 })
915 })
916 .collect();
917 refs
918}
919
920struct TempRef {
921 ref_name: String,
922}
923
924impl TempRef {
925 fn new(prefix: &str) -> Result<Self, GitError> {
926 Ok(TempRef {
927 ref_name: create_temp_ref(prefix, EMPTY_OID)?,
928 })
929 }
930}
931
932impl Drop for TempRef {
933 fn drop(&mut self) {
934 if let Err(e) = remove_reference(&self.ref_name) {
935 warn!("Failed to remove reference {}: {e:?}", self.ref_name);
936 }
937 }
938}
939
940fn update_read_branch() -> Result<TempRef> {
941 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
942 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
943 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
947
948 consolidate_write_branches_into(¤t_upstream_oid, &temp_ref.ref_name, None)
949 .map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
950
951 Ok(temp_ref)
952}
953
954fn update_pending_read_branch() -> Result<TempRef> {
955 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
956 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
957 let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
960 .map_err(|e| anyhow!("Failed to get write refs: {:?}", e))?;
961
962 for reference in &refs {
963 reconcile_branch_with(&temp_ref.ref_name, &reference.oid)
964 .map_err(|e| anyhow!("Failed to merge write ref: {:?}", e))?;
965 }
966
967 Ok(temp_ref)
968}
969
970pub fn walk_commits_from(
1013 start_commit: &str,
1014 num_commits: usize,
1015 since: Option<&str>,
1016 until: Option<&str>,
1017) -> Result<Vec<CommitWithNotes>> {
1018 let temp_ref = update_read_branch()?;
1020
1021 let resolved_commit = resolve_committish(start_commit)
1023 .context(format!("Failed to resolve commit '{}'", start_commit))?;
1024
1025 let num_commits_str = num_commits.to_string();
1026 let notes_ref = format!("--notes={}", temp_ref.ref_name);
1027 let since_arg = since.map(|s| format!("--since={}", s));
1028 let until_arg = until.map(|u| format!("--until={}", u));
1029
1030 let mut args = vec![
1031 "--no-pager",
1032 "log",
1033 "--no-color",
1034 "--ignore-missing",
1035 "-n",
1036 &num_commits_str,
1037 "--first-parent",
1038 "--pretty=--,%H,%s,%an,%D%n%N",
1039 "--decorate=full",
1040 ¬es_ref,
1041 ];
1042 if let Some(ref s) = since_arg {
1043 args.push(s.as_str());
1044 }
1045 if let Some(ref u) = until_arg {
1046 args.push(u.as_str());
1047 }
1048 args.push(&resolved_commit);
1049
1050 let output = capture_git_output(&args, &None)
1051 .context(format!("Failed to retrieve commits from {}", start_commit))?;
1052
1053 let mut commits: Vec<CommitWithNotes> = Vec::new();
1054 let mut detected_shallow = false;
1055 let mut current_commit_sha: Option<String> = None;
1056
1057 for l in output.stdout.lines() {
1058 if l.starts_with("--") {
1059 let parts: Vec<&str> = l.splitn(5, ',').collect();
1061 if parts.len() < 5 {
1062 bail!(
1063 "Invalid git log format: expected 5 fields, got {}",
1064 parts.len()
1065 );
1066 }
1067
1068 let sha = parts[1].to_string();
1069 let title = if parts[2].is_empty() {
1070 "[no subject]".to_string()
1071 } else {
1072 parts[2].to_string()
1073 };
1074 let author = if parts[3].is_empty() {
1075 "[unknown]".to_string()
1076 } else {
1077 parts[3].to_string()
1078 };
1079 let decorations = parts[4];
1080
1081 detected_shallow |= decorations.contains("grafted");
1082 current_commit_sha = Some(sha.clone());
1083
1084 commits.push(CommitWithNotes {
1085 sha,
1086 title,
1087 author,
1088 note_lines: Vec::new(),
1089 });
1090 } else if current_commit_sha.is_some() {
1091 if let Some(last) = commits.last_mut() {
1092 last.note_lines.push(l.to_string());
1093 }
1094 }
1095 }
1096
1097 if detected_shallow && commits.len() < num_commits {
1098 bail!("Refusing to continue as commit log depth was limited by shallow clone");
1099 }
1100
1101 Ok(commits)
1102}
1103
1104pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
1106 walk_commits_from("HEAD", num_commits, None, None)
1107}
1108
1109pub fn get_commits_with_notes(notes_ref: &str) -> Result<Vec<String>> {
1115 let output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1116 .context(format!("Failed to list notes in {}", notes_ref))?;
1117
1118 let commits: Vec<String> = output
1120 .stdout
1121 .lines()
1122 .filter(|line| !line.is_empty())
1123 .filter_map(|line| {
1124 let parts: Vec<&str> = line.split_whitespace().collect();
1125 if parts.len() >= 2 {
1126 Some(parts[1].to_string())
1127 } else {
1128 None
1129 }
1130 })
1131 .collect();
1132
1133 Ok(commits)
1134}
1135
1136pub fn get_commits_with_notes_content(notes_ref: &str) -> Result<Vec<CommitWithNotes>> {
1143 let list_output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1145 .context(format!("Failed to list notes in {}", notes_ref))?;
1146
1147 let commit_shas: Vec<String> = list_output
1148 .stdout
1149 .lines()
1150 .filter(|l| !l.is_empty())
1151 .filter_map(|line| {
1152 let parts: Vec<&str> = line.split_whitespace().collect();
1153 (parts.len() >= 2).then(|| parts[1].to_string())
1154 })
1155 .collect();
1156
1157 if commit_shas.is_empty() {
1158 return Ok(Vec::new());
1159 }
1160
1161 let notes_flag = format!("--notes={}", notes_ref);
1165 let mut args = vec![
1166 "--no-pager",
1167 "log",
1168 "--no-color",
1169 "--no-walk",
1170 "--pretty=format:%H%x00%s%x00%an%n%N",
1171 ¬es_flag,
1172 ];
1173 args.extend(commit_shas.iter().map(|s| s.as_str()));
1174
1175 let output =
1176 capture_git_output(&args, &None).context("Failed to fetch commit details and notes")?;
1177
1178 let mut commits: Vec<CommitWithNotes> = Vec::new();
1181
1182 for line in output.stdout.lines() {
1183 if line.contains('\0') {
1184 let mut parts = line.splitn(3, '\0');
1185 let sha = parts.next().unwrap_or("").to_string();
1186 let title = parts
1187 .next()
1188 .map(|s| if s.is_empty() { "[no subject]" } else { s })
1189 .unwrap_or("[no subject]")
1190 .to_string();
1191 let author = parts
1192 .next()
1193 .map(|s| if s.is_empty() { "[unknown]" } else { s })
1194 .unwrap_or("[unknown]")
1195 .to_string();
1196 commits.push(CommitWithNotes {
1197 sha,
1198 title,
1199 author,
1200 note_lines: Vec::new(),
1201 });
1202 } else if let Some(last) = commits.last_mut() {
1203 last.note_lines.push(line.to_string());
1204 }
1205 }
1206
1207 Ok(commits)
1208}
1209
1210pub fn pull(work_dir: Option<&Path>) -> Result<()> {
1211 pull_internal(work_dir)?;
1212 Ok(())
1213}
1214
1215fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
1216 fetch(work_dir)?;
1217 Ok(())
1218}
1219
1220pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
1221 let op = || {
1222 raw_push(work_dir, remote)
1223 .map_err(map_git_error_for_backoff)
1224 .map_err(|e: ::backoff::Error<GitError>| match e {
1225 ::backoff::Error::Transient { .. } => {
1226 let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
1228
1229 let pull_succeeded = pull_result.is_ok()
1233 || matches!(
1234 pull_result,
1235 Err(::backoff::Error::Permanent(
1236 GitError::RefConcurrentModification { .. }
1237 | GitError::RefFailedToLock { .. }
1238 ))
1239 );
1240
1241 if pull_succeeded {
1242 e
1245 } else {
1246 pull_result.unwrap_err()
1248 }
1249 }
1250 ::backoff::Error::Permanent { .. } => e,
1251 })
1252 };
1253
1254 let backoff = default_backoff();
1255
1256 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
1257 ::backoff::Error::Permanent(err) => {
1258 anyhow!(err).context("Permanent failure while pushing refs")
1259 }
1260 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
1261 })?;
1262
1263 Ok(())
1264}
1265
1266pub fn get_write_refs() -> Result<Vec<(String, String)>> {
1268 let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
1269 .map_err(|e| anyhow!("{:?}", e))?;
1270 Ok(refs.into_iter().map(|r| (r.refname, r.oid)).collect())
1271}
1272
1273pub fn delete_reference(ref_name: &str) -> Result<()> {
1275 remove_reference(ref_name).map_err(|e| anyhow!("{:?}", e))
1276}
1277
1278#[cfg(test)]
1279mod test {
1280 use super::*;
1281 use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
1282 use std::process::Command;
1283
1284 use httptest::{
1285 http::{header::AUTHORIZATION, Uri},
1286 matchers::{self, request},
1287 responders::status_code,
1288 Expectation, Server,
1289 };
1290
1291 fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
1292 let url = origin_url.to_string();
1293
1294 run_git_command(&["remote", "add", "origin", &url], dir);
1295 run_git_command(
1296 &[
1297 "config",
1298 "--add",
1299 format!("http.{}.extraHeader", url).as_str(),
1300 extra_header,
1301 ],
1302 dir,
1303 );
1304 }
1305
1306 #[test]
1307 fn test_customheader_pull() {
1308 with_isolated_cwd_git(|git_dir| {
1309 let mut test_server = Server::run();
1310 add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
1311
1312 test_server.expect(
1313 Expectation::matching(request::headers(matchers::contains((
1314 AUTHORIZATION.as_str(),
1315 "sometoken",
1316 ))))
1317 .times(1..)
1318 .respond_with(status_code(200)),
1319 );
1320
1321 let _ = pull(None); test_server.verify_and_clear();
1328 });
1329 }
1330
1331 #[test]
1332 fn test_customheader_push() {
1333 with_isolated_cwd_git(|git_dir| {
1334 let test_server = Server::run();
1335 add_server_remote(
1336 test_server.url(""),
1337 "AUTHORIZATION: someothertoken",
1338 git_dir,
1339 );
1340
1341 test_server.expect(
1342 Expectation::matching(request::headers(matchers::contains((
1343 AUTHORIZATION.as_str(),
1344 "someothertoken",
1345 ))))
1346 .times(1..)
1347 .respond_with(status_code(200)),
1348 );
1349
1350 ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
1352 add_note_line_to_head("test note line").expect("Failed to add note line");
1353
1354 let error = push(None, None);
1355 error
1356 .as_ref()
1357 .expect_err("We have no valid git http server setup -> should fail");
1358 dbg!(&error);
1359 });
1360 }
1361
1362 #[test]
1363 fn test_random_suffix() {
1364 for _ in 1..1000 {
1365 let first = random_suffix();
1366 let second = random_suffix();
1367
1368 let is_valid = |s: &String| {
1370 let parts: Vec<&str> = s.splitn(2, '-').collect();
1371 parts.len() == 2
1372 && parts[0].len() == 8
1373 && parts[0].chars().all(|c| c.is_ascii_hexdigit())
1374 && parts[1].len() == 8
1375 && parts[1].chars().all(|c| c.is_ascii_hexdigit())
1376 };
1377
1378 assert_ne!(first, second);
1379 assert!(is_valid(&first), "Invalid suffix format: {}", first);
1380 assert!(is_valid(&second), "Invalid suffix format: {}", second);
1381 }
1382 }
1383
1384 #[test]
1385 fn test_empty_or_never_pushed_remote_error_for_fetch() {
1386 with_isolated_cwd_git(|git_dir| {
1387 let git_dir_url = format!("file://{}", git_dir.display());
1389 run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
1390
1391 std::env::set_var("GIT_TRACE", "true");
1393
1394 let result = super::fetch(Some(git_dir));
1396 match result {
1397 Err(GitError::NoRemoteMeasurements { output }) => {
1398 assert!(
1399 output.stderr.contains(GIT_PERF_REMOTE),
1400 "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
1401 output.stderr
1402 )
1403 }
1404 other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
1405 }
1406 });
1407 }
1408
1409 #[test]
1410 fn test_empty_or_never_pushed_remote_error_for_push() {
1411 with_isolated_cwd_git(|git_dir| {
1412 run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
1413
1414 std::env::set_var("GIT_TRACE", "true");
1416
1417 add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
1418
1419 let result = super::raw_push(Some(git_dir), None);
1420 match result {
1421 Err(GitError::RefFailedToPush { output }) => {
1422 assert!(
1423 output.stderr.contains(GIT_PERF_REMOTE),
1424 "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
1425 output.stderr
1426 )
1427 }
1428 other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1429 }
1430 });
1431 }
1432
1433 #[test]
1438 fn test_new_symbolic_write_ref_returns_valid_ref() {
1439 with_isolated_cwd_git(|_git_dir| {
1440 let result = new_symbolic_write_ref();
1442 assert!(
1443 result.is_ok(),
1444 "Should create symbolic write ref: {:?}",
1445 result
1446 );
1447
1448 let ref_name = result.unwrap();
1449
1450 assert!(
1452 !ref_name.is_empty(),
1453 "Reference name should not be empty, got: '{}'",
1454 ref_name
1455 );
1456
1457 assert!(
1459 ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
1460 "Reference should start with {}, got: {}",
1461 REFS_NOTES_WRITE_TARGET_PREFIX,
1462 ref_name
1463 );
1464
1465 let suffix = ref_name
1467 .strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
1468 .expect("Should have prefix");
1469 let parts: Vec<&str> = suffix.splitn(2, '-').collect();
1470 let is_valid_suffix = parts.len() == 2
1471 && parts[0].len() == 8
1472 && parts[0].chars().all(|c| c.is_ascii_hexdigit())
1473 && parts[1].len() == 8
1474 && parts[1].chars().all(|c| c.is_ascii_hexdigit());
1475 assert!(
1476 is_valid_suffix,
1477 "Suffix should be in format {{pid:08x}}-{{random:08x}}, got: {}",
1478 suffix
1479 );
1480 });
1481 }
1482
1483 #[test]
1486 fn test_add_and_retrieve_notes() {
1487 with_isolated_cwd_git(|_git_dir| {
1488 let result = add_note_line_to_head("test: 100");
1490 assert!(
1491 result.is_ok(),
1492 "Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
1493 result
1494 );
1495
1496 let result2 = add_note_line_to_head("test: 200");
1498 assert!(result2.is_ok(), "Should add second note: {:?}", result2);
1499
1500 let commits = walk_commits(10);
1502 assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
1503
1504 let commits = commits.unwrap();
1505 assert!(!commits.is_empty(), "Should have commits");
1506
1507 let commit_with_notes = &commits[0];
1509 assert!(
1510 !commit_with_notes.note_lines.is_empty(),
1511 "HEAD should have notes"
1512 );
1513 assert!(
1514 commit_with_notes
1515 .note_lines
1516 .iter()
1517 .any(|n| n.contains("test:")),
1518 "Notes should contain our test data"
1519 );
1520 });
1521 }
1522
1523 #[test]
1527 fn test_walk_commits_shallow_repo_detection() {
1528 use std::env::set_current_dir;
1529
1530 with_isolated_cwd_git(|git_dir| {
1531 for i in 2..=5 {
1533 run_git_command(
1534 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1535 git_dir,
1536 );
1537 }
1538
1539 let shallow_dir = git_dir.join("shallow");
1541 let output = Command::new("git")
1542 .args([
1543 "clone",
1544 "--depth",
1545 "2",
1546 git_dir.to_str().unwrap(),
1547 shallow_dir.to_str().unwrap(),
1548 ])
1549 .output()
1550 .unwrap();
1551
1552 assert!(
1553 output.status.success(),
1554 "Shallow clone failed: {}",
1555 String::from_utf8_lossy(&output.stderr)
1556 );
1557
1558 set_current_dir(&shallow_dir).unwrap();
1560
1561 add_note_line_to_head("test: 100").expect("Should add note");
1563
1564 let result = walk_commits(10);
1566 assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
1567
1568 let commits = result.unwrap();
1569
1570 assert!(
1576 !commits.is_empty(),
1577 "Should have found commits in shallow repo"
1578 );
1579 });
1580 }
1581
1582 #[test]
1584 fn test_walk_commits_normal_repo_not_shallow() {
1585 with_isolated_cwd_git(|git_dir| {
1586 for i in 2..=3 {
1588 run_git_command(
1589 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1590 git_dir,
1591 );
1592 }
1593
1594 add_note_line_to_head("test: 100").expect("Should add note");
1596
1597 let result = walk_commits(10);
1598 assert!(result.is_ok(), "walk_commits should succeed");
1599
1600 let commits = result.unwrap();
1601
1602 assert!(!commits.is_empty(), "Should have found commits");
1604 });
1605 }
1606
1607 #[test]
1608 fn test_extract_pid_from_staging_ref() {
1609 let refname = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "deadbeef-01234567");
1611 assert_eq!(
1612 extract_pid_from_staging_ref(&refname, REFS_NOTES_ADD_TARGET_PREFIX),
1613 Some(0xdeadbeef),
1614 );
1615
1616 let old_refname = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "deadbeef");
1619 assert_eq!(
1620 extract_pid_from_staging_ref(&old_refname, REFS_NOTES_ADD_TARGET_PREFIX),
1621 None,
1622 "Old-format refs without a dash must return None to avoid kill(-N, 0) on a process group",
1623 );
1624
1625 let wrong_prefix = format!("{}{}", REFS_NOTES_WRITE_TARGET_PREFIX, "deadbeef-01234567");
1627 assert_eq!(
1628 extract_pid_from_staging_ref(&wrong_prefix, REFS_NOTES_ADD_TARGET_PREFIX),
1629 None,
1630 );
1631
1632 let bad_hex = format!("{}{}", REFS_NOTES_ADD_TARGET_PREFIX, "xyz00000-01234567");
1634 assert_eq!(
1635 extract_pid_from_staging_ref(&bad_hex, REFS_NOTES_ADD_TARGET_PREFIX),
1636 None,
1637 );
1638 }
1639
1640 #[test]
1641 fn test_is_process_alive_returns_true_for_current_process() {
1642 assert!(
1643 is_process_alive(std::process::id()),
1644 "Current process must be alive",
1645 );
1646 }
1647
1648 #[cfg(unix)]
1649 #[test]
1650 fn test_is_process_alive_returns_false_for_dead_process() {
1651 let mut child = std::process::Command::new("true")
1652 .spawn()
1653 .expect("Failed to spawn 'true'");
1654 let pid = child.id();
1655 child.wait().expect("Failed to wait for child");
1656 assert!(
1658 !is_process_alive(pid),
1659 "PID {pid} should be dead after process exited",
1660 );
1661 }
1662}