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, Rng};
19
20use crate::config;
21
22use super::git_definitions::{
23 GIT_ORIGIN, GIT_PERF_REMOTE, REFS_NOTES_ADD_TARGET_PREFIX, REFS_NOTES_BRANCH,
24 REFS_NOTES_MERGE_BRANCH_PREFIX, REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX,
25 REFS_NOTES_WRITE_SYMBOLIC_REF, REFS_NOTES_WRITE_TARGET_PREFIX,
26};
27use super::git_lowlevel::{
28 capture_git_output, get_git_perf_remote, git_rev_parse, git_rev_parse_symbolic_ref,
29 git_symbolic_ref_create_or_update, git_update_ref, internal_get_head_revision, is_shallow_repo,
30 map_git_error, set_git_perf_remote, spawn_git_command,
31};
32use super::git_types::GitError;
33use super::git_types::GitOutput;
34use super::git_types::Reference;
35
36pub use super::git_lowlevel::get_head_revision;
37
38pub use super::git_lowlevel::check_git_version;
39
40pub use super::git_lowlevel::get_repository_root;
41
42pub use super::git_lowlevel::resolve_committish;
43
44#[derive(Debug, Clone, PartialEq)]
52pub struct CommitWithNotes {
53 pub sha: String,
54 pub title: String,
55 pub author: String,
56 pub note_lines: Vec<String>,
57}
58
59pub fn is_shallow_repository() -> Result<bool> {
61 super::git_lowlevel::is_shallow_repo()
62 .map_err(|e| anyhow!("Failed to check if repository is shallow: {}", e))
63}
64
65fn map_git_error_for_backoff(e: GitError) -> ::backoff::Error<GitError> {
66 match e {
67 GitError::RefFailedToPush { .. }
68 | GitError::RefFailedToLock { .. }
69 | GitError::RefConcurrentModification { .. }
70 | GitError::BadObject { .. } => ::backoff::Error::transient(e),
71 GitError::ExecError { .. }
72 | GitError::IoError(..)
73 | GitError::ShallowRepository
74 | GitError::MissingHead { .. }
75 | GitError::NoRemoteMeasurements { .. }
76 | GitError::NoUpstream { .. }
77 | GitError::MissingMeasurements => ::backoff::Error::permanent(e),
78 }
79}
80
81fn default_backoff() -> ExponentialBackoff {
83 let max_elapsed = config::backoff_max_elapsed_seconds();
84 ExponentialBackoffBuilder::default()
85 .with_max_elapsed_time(Some(Duration::from_secs(max_elapsed)))
86 .build()
87}
88
89pub fn add_note_line(commit: &str, line: &str) -> Result<()> {
120 let op = || -> Result<(), ::backoff::Error<GitError>> {
121 raw_add_note_line(commit, line).map_err(map_git_error_for_backoff)
122 };
123
124 let backoff = default_backoff();
125
126 ::backoff::retry(backoff, op).map_err(|e| match e {
127 ::backoff::Error::Permanent(err) => anyhow!(err).context(format!(
128 "Permanent failure while adding note line to commit {}",
129 commit
130 )),
131 ::backoff::Error::Transient { err, .. } => anyhow!(err).context(format!(
132 "Timed out while adding note line to commit {}",
133 commit
134 )),
135 })?;
136
137 Ok(())
138}
139
140pub fn add_note_line_to_head(line: &str) -> Result<()> {
142 let head = internal_get_head_revision()
143 .map_err(|e| anyhow!(e).context("Failed to get HEAD revision"))?;
144 add_note_line(&head, line)
145}
146
147fn raw_add_note_line(commit: &str, line: &str) -> Result<(), GitError> {
148 ensure_symbolic_write_ref_exists()?;
149
150 let current_note_head =
154 git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).unwrap_or(EMPTY_OID.to_string());
155 let current_symbolic_ref_target = git_rev_parse_symbolic_ref(REFS_NOTES_WRITE_SYMBOLIC_REF)
156 .expect("Missing symbolic-ref for target");
157 let temp_target = create_temp_add_head(¤t_note_head)?;
158
159 defer!(remove_reference(&temp_target)
160 .expect("Deleting our own temp ref for adding should never fail"));
161
162 let resolved_commit = git_rev_parse(commit)?;
164
165 capture_git_output(
166 &[
167 "notes",
168 "--ref",
169 &temp_target,
170 "append",
171 "-m",
172 line,
173 &resolved_commit,
174 ],
175 &None,
176 )?;
177
178 git_update_ref(unindent(
184 format!(
185 r#"
186 start
187 update {current_symbolic_ref_target} {temp_target} {current_note_head}
188 commit
189 "#
190 )
191 .as_str(),
192 ))?;
193
194 Ok(())
195}
196
197fn ensure_remote_exists() -> Result<(), GitError> {
198 if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
199 return Ok(());
200 }
201
202 if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
203 return set_git_perf_remote(GIT_PERF_REMOTE, &x);
204 }
205
206 Err(GitError::NoUpstream {})
207}
208
209fn create_temp_ref_name(prefix: &str) -> String {
211 let suffix = random_suffix();
212 format!("{prefix}{suffix}")
213}
214
215fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
216 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
217 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
218
219 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
223 |err| {
224 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
226 Ok(())
227 } else {
228 Err(err)
229 }
230 },
231 )?;
232 }
233 Ok(())
234}
235
236fn random_suffix() -> String {
237 let suffix: u32 = rng().random::<u32>();
238 format!("{suffix:08x}")
239}
240
241fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
242 ensure_remote_exists()?;
243
244 let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
245 capture_git_output(
247 &[
248 "fetch",
249 "--atomic",
250 "--no-write-fetch-head",
251 GIT_PERF_REMOTE,
252 format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
256 ],
257 &work_dir,
258 )
259 .map_err(map_git_error)?;
260
261 let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
262
263 if ref_before == ref_after {
264 println!("Already up to date");
265 }
266
267 Ok(())
268}
269
270fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
271 _ = capture_git_output(
272 &[
273 "notes",
274 "--ref",
275 target,
276 "merge",
277 "-s",
278 "cat_sort_uniq",
279 branch,
280 ],
281 &None,
282 )?;
283 Ok(())
284}
285
286fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
287 let target = create_temp_ref_name(prefix);
288 if current_head != EMPTY_OID {
289 git_update_ref(unindent(
290 format!(
291 r#"
292 start
293 create {target} {current_head}
294 commit
295 "#
296 )
297 .as_str(),
298 ))?;
299 }
300 Ok(target)
301}
302
303fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
304 create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
305}
306
307fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
308 create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
309}
310
311fn compact_head(target: &str) -> Result<(), GitError> {
312 let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
313
314 let compaction_head = capture_git_output(
316 &["commit-tree", "-m", "cutoff history", &new_removal_head],
317 &None,
318 )?
319 .stdout;
320
321 let compaction_head = compaction_head.trim();
322
323 git_update_ref(unindent(
324 format!(
325 r#"
326 start
327 update {target} {compaction_head}
328 commit
329 "#
330 )
331 .as_str(),
332 ))?;
333
334 Ok(())
335}
336
337fn retry_notify(err: GitError, dur: Duration) {
338 debug!("Error happened at {dur:?}: {err}");
339 warn!("Retrying...");
340}
341
342pub fn remove_measurements_from_commits(
343 older_than: DateTime<Utc>,
344 prune: bool,
345 dry_run: bool,
346) -> Result<()> {
347 if dry_run {
348 return raw_remove_measurements_from_commits(older_than, prune, dry_run)
350 .map_err(|e| anyhow!(e));
351 }
352
353 let op = || -> Result<(), ::backoff::Error<GitError>> {
354 raw_remove_measurements_from_commits(older_than, prune, dry_run)
355 .map_err(map_git_error_for_backoff)
356 };
357
358 let backoff = default_backoff();
359
360 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
361 ::backoff::Error::Permanent(err) => {
362 anyhow!(err).context("Permanent failure while removing measurements")
363 }
364 ::backoff::Error::Transient { err, .. } => {
365 anyhow!(err).context("Timed out while removing measurements")
366 }
367 })?;
368
369 Ok(())
370}
371
372fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
373where
374 F: FnOnce(&str) -> Result<(), GitError>,
375{
376 pull_internal(None)?;
377
378 let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
379 let target = create_temp_rewrite_head(¤t_notes_head)?;
380
381 operation(&target)?;
382
383 compact_head(&target)?;
384
385 git_push_notes_ref(¤t_notes_head, &target, &None, None)?;
386
387 git_update_ref(unindent(
388 format!(
389 r#"
390 start
391 update {REFS_NOTES_BRANCH} {target}
392 commit
393 "#
394 )
395 .as_str(),
396 ))?;
397
398 remove_reference(&target)?;
399
400 Ok(())
401}
402
403fn raw_remove_measurements_from_commits(
404 older_than: DateTime<Utc>,
405 prune: bool,
406 dry_run: bool,
407) -> Result<(), GitError> {
408 if prune && is_shallow_repo()? {
410 return Err(GitError::ShallowRepository);
411 }
412
413 if dry_run {
414 remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
416 if prune {
417 println!("[DRY-RUN] Would prune orphaned measurements after removal");
418 }
419 return Ok(());
420 }
421
422 execute_notes_operation(|target| {
423 remove_measurements_from_reference(target, older_than, dry_run)?;
425
426 if prune {
428 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
429 }
430
431 Ok(())
432 })
433}
434
435fn remove_measurements_from_reference(
437 reference: &str,
438 older_than: DateTime<Utc>,
439 dry_run: bool,
440) -> Result<(), GitError> {
441 let oldest_timestamp = older_than.timestamp();
442 let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
444 let notes_out = list_notes.stdout.take().unwrap();
445
446 let mut get_commit_dates = spawn_git_command(
447 &[
448 "log",
449 "--ignore-missing",
450 "--no-walk",
451 "--pretty=format:%H %ct",
452 "--stdin",
453 ],
454 &None,
455 Some(Stdio::piped()),
456 )?;
457 let dates_in = get_commit_dates.stdin.take().unwrap();
458 let dates_out = get_commit_dates.stdout.take().unwrap();
459
460 if dry_run {
461 let date_collection_handler = thread::spawn(move || {
463 let reader = BufReader::new(dates_out);
464 let mut results = Vec::new();
465 for line in reader.lines().map_while(Result::ok) {
466 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
467 if let Ok(timestamp) = timestamp.parse::<i64>() {
468 if timestamp <= oldest_timestamp {
469 results.push(commit.to_string());
470 }
471 }
472 }
473 }
474 results
475 });
476
477 {
478 let reader = BufReader::new(notes_out);
479 let mut writer = BufWriter::new(dates_in);
480
481 reader.lines().map_while(Result::ok).for_each(|line| {
482 if let Some(line) = line.split_whitespace().nth(1) {
483 writeln!(writer, "{line}").expect("Failed to write to pipe");
484 }
485 });
486 }
487
488 let commits_to_remove = date_collection_handler
489 .join()
490 .expect("Failed to join date collection thread");
491 let count = commits_to_remove.len();
492
493 list_notes.wait()?;
494 get_commit_dates.wait()?;
495
496 if count == 0 {
497 println!(
498 "[DRY-RUN] No measurements older than {} would be removed",
499 older_than
500 );
501 } else {
502 println!(
503 "[DRY-RUN] Would remove measurements from {} commits older than {}",
504 count, older_than
505 );
506 for commit in &commits_to_remove {
507 println!(" {}", commit);
508 }
509 }
510
511 return Ok(());
512 }
513
514 let mut remove_measurements = spawn_git_command(
516 &[
517 "notes",
518 "--ref",
519 reference,
520 "remove",
521 "--stdin",
522 "--ignore-missing",
523 ],
524 &None,
525 Some(Stdio::piped()),
526 )?;
527 let removal_in = remove_measurements.stdin.take().unwrap();
528 let removal_out = remove_measurements.stdout.take().unwrap();
529
530 let removal_handler = thread::spawn(move || {
531 let reader = BufReader::new(dates_out);
532 let mut writer = BufWriter::new(removal_in);
533 for line in reader.lines().map_while(Result::ok) {
534 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
535 if let Ok(timestamp) = timestamp.parse::<i64>() {
536 if timestamp <= oldest_timestamp {
537 writeln!(writer, "{commit}").expect("Could not write to stream");
538 }
539 }
540 }
541 }
542 });
543
544 let debugging_handler = thread::spawn(move || {
545 let reader = BufReader::new(removal_out);
546 reader
547 .lines()
548 .map_while(Result::ok)
549 .for_each(|l| println!("{l}"))
550 });
551
552 {
553 let reader = BufReader::new(notes_out);
554 let mut writer = BufWriter::new(dates_in);
555
556 reader.lines().map_while(Result::ok).for_each(|line| {
557 if let Some(line) = line.split_whitespace().nth(1) {
558 writeln!(writer, "{line}").expect("Failed to write to pipe");
559 }
560 });
561 }
562
563 removal_handler.join().expect("Failed to join");
564 debugging_handler.join().expect("Failed to join");
565
566 list_notes.wait()?;
567 get_commit_dates.wait()?;
568 remove_measurements.wait()?;
569
570 Ok(())
571}
572
573fn new_symbolic_write_ref() -> Result<String, GitError> {
574 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
575
576 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
580 Ok(target)
581}
582
583const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
584
585fn consolidate_write_branches_into(
586 current_upstream_oid: &str,
587 target: &str,
588 except_ref: Option<&str>,
589) -> Result<Vec<Reference>, GitError> {
590 git_update_ref(unindent(
593 format!(
594 r#"
595 start
596 verify {REFS_NOTES_BRANCH} {current_upstream_oid}
597 update {target} {current_upstream_oid} {EMPTY_OID}
598 commit
599 "#
600 )
601 .as_str(),
602 ))?;
603
604 let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
609 let refs = get_refs(additional_args)?
610 .into_iter()
611 .filter(|r| r.refname != except_ref.unwrap_or_default())
612 .collect_vec();
613
614 for reference in &refs {
615 reconcile_branch_with(target, &reference.oid)?;
616 }
617
618 Ok(refs)
619}
620
621fn remove_reference(ref_name: &str) -> Result<(), GitError> {
622 git_update_ref(unindent(
623 format!(
624 r#"
625 start
626 delete {ref_name}
627 commit
628 "#
629 )
630 .as_str(),
631 ))
632}
633
634fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
635 ensure_remote_exists()?;
636 let new_write_ref = new_symbolic_write_ref()?;
644
645 let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
646
647 defer!(remove_reference(&merge_ref).expect("Deleting our own branch should never fail"));
648
649 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
656 let refs =
657 consolidate_write_branches_into(¤t_upstream_oid, &merge_ref, Some(&new_write_ref))?;
658
659 if refs.is_empty() && current_upstream_oid == EMPTY_OID {
660 return Err(GitError::MissingMeasurements);
661 }
662
663 git_push_notes_ref(¤t_upstream_oid, &merge_ref, &work_dir, remote)?;
664
665 fetch(None)?;
667
668 let mut commands = Vec::new();
670 commands.push(String::from("start"));
671 for Reference { refname, oid } in &refs {
672 commands.push(format!("delete {refname} {oid}"));
673 }
674 commands.push(String::from("commit"));
675 commands.push(String::new());
677 let commands = commands.join("\n");
678 git_update_ref(commands)?;
679
680 Ok(())
681}
682
683fn git_push_notes_ref(
684 expected_upstream: &str,
685 push_ref: &str,
686 working_dir: &Option<&Path>,
687 remote: Option<&str>,
688) -> Result<(), GitError> {
689 let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
692 let output = capture_git_output(
693 &[
694 "push",
695 "--porcelain",
696 format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
697 remote_name,
698 format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
699 ],
700 working_dir,
701 );
702
703 match output {
707 Ok(output) => {
708 print!("{}", &output.stdout);
709 Ok(())
710 }
711 Err(GitError::ExecError { output, .. }) => {
712 let successful_push = output.stdout.lines().any(|l| {
713 l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
714 });
715 if successful_push {
716 Ok(())
717 } else {
718 Err(GitError::RefFailedToPush { output })
719 }
720 }
721 Err(e) => Err(e),
722 }?;
723
724 Ok(())
725}
726
727pub fn prune() -> Result<()> {
728 let op = || -> Result<(), ::backoff::Error<GitError>> {
729 raw_prune().map_err(map_git_error_for_backoff)
730 };
731
732 let backoff = default_backoff();
733
734 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
735 ::backoff::Error::Permanent(err) => {
736 anyhow!(err).context("Permanent failure while pruning refs")
737 }
738 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
739 })?;
740
741 Ok(())
742}
743
744fn raw_prune() -> Result<(), GitError> {
745 if is_shallow_repo()? {
746 return Err(GitError::ShallowRepository);
747 }
748
749 execute_notes_operation(|target| {
750 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
751 })
752}
753
754pub fn list_commits_with_measurements() -> Result<Vec<String>> {
759 let temp_ref = update_read_branch()?;
761
762 let mut list_notes =
765 spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
766
767 let stdout = list_notes
768 .stdout
769 .take()
770 .ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
771
772 let commits: Vec<String> = BufReader::new(stdout)
776 .lines()
777 .filter_map(|line_result| {
778 line_result
779 .ok()
780 .and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
781 })
782 .collect();
783
784 Ok(commits)
785}
786
787pub struct ReadBranchGuard {
790 temp_ref: TempRef,
791}
792
793impl ReadBranchGuard {
794 #[must_use]
796 pub fn ref_name(&self) -> &str {
797 &self.temp_ref.ref_name
798 }
799}
800
801pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
805 let temp_ref = update_read_branch()?;
806 Ok(ReadBranchGuard { temp_ref })
807}
808
809fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
810 let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
811 args.extend(additional_args.iter().map(|s| s.as_str()));
812
813 let output = capture_git_output(&args, &None)?;
814 let refs: Result<Vec<Reference>, _> = output
815 .stdout
816 .lines()
817 .filter(|s| !s.is_empty())
818 .map(|s| {
819 let items = s.split('\0').take(2).collect_vec();
820 if items.len() != 2 {
821 return Err(GitError::ExecError {
822 command: format!("git {}", args.join(" ")),
823 output: GitOutput {
824 stdout: format!("Unexpected git for-each-ref output format: {}", s),
825 stderr: String::new(),
826 },
827 });
828 }
829 Ok(Reference {
830 refname: items[0].to_string(),
831 oid: items[1].to_string(),
832 })
833 })
834 .collect();
835 refs
836}
837
838struct TempRef {
839 ref_name: String,
840}
841
842impl TempRef {
843 fn new(prefix: &str) -> Result<Self, GitError> {
844 Ok(TempRef {
845 ref_name: create_temp_ref(prefix, EMPTY_OID)?,
846 })
847 }
848}
849
850impl Drop for TempRef {
851 fn drop(&mut self) {
852 remove_reference(&self.ref_name)
853 .unwrap_or_else(|_| panic!("Failed to remove reference: {}", self.ref_name))
854 }
855}
856
857fn update_read_branch() -> Result<TempRef> {
858 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
859 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
860 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
864
865 consolidate_write_branches_into(¤t_upstream_oid, &temp_ref.ref_name, None)
866 .map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
867
868 Ok(temp_ref)
869}
870
871pub fn walk_commits_from(start_commit: &str, num_commits: usize) -> Result<Vec<CommitWithNotes>> {
914 let temp_ref = update_read_branch()?;
916
917 let resolved_commit = resolve_committish(start_commit)
919 .context(format!("Failed to resolve commit '{}'", start_commit))?;
920
921 let output = capture_git_output(
922 &[
923 "--no-pager",
924 "log",
925 "--no-color",
926 "--ignore-missing",
927 "-n",
928 num_commits.to_string().as_str(),
929 "--first-parent",
930 "--pretty=--,%H,%s,%an,%D%n%N",
931 "--decorate=full",
932 format!("--notes={}", temp_ref.ref_name).as_str(),
933 &resolved_commit,
934 ],
935 &None,
936 )
937 .context(format!("Failed to retrieve commits from {}", start_commit))?;
938
939 let mut commits: Vec<CommitWithNotes> = Vec::new();
940 let mut detected_shallow = false;
941 let mut current_commit_sha: Option<String> = None;
942
943 for l in output.stdout.lines() {
944 if l.starts_with("--") {
945 let parts: Vec<&str> = l.splitn(5, ',').collect();
947 if parts.len() < 5 {
948 bail!(
949 "Invalid git log format: expected 5 fields, got {}",
950 parts.len()
951 );
952 }
953
954 let sha = parts[1].to_string();
955 let title = if parts[2].is_empty() {
956 "[no subject]".to_string()
957 } else {
958 parts[2].to_string()
959 };
960 let author = if parts[3].is_empty() {
961 "[unknown]".to_string()
962 } else {
963 parts[3].to_string()
964 };
965 let decorations = parts[4];
966
967 detected_shallow |= decorations.contains("grafted");
968 current_commit_sha = Some(sha.clone());
969
970 commits.push(CommitWithNotes {
971 sha,
972 title,
973 author,
974 note_lines: Vec::new(),
975 });
976 } else if current_commit_sha.is_some() {
977 if let Some(last) = commits.last_mut() {
978 last.note_lines.push(l.to_string());
979 }
980 }
981 }
982
983 if detected_shallow && commits.len() < num_commits {
984 bail!("Refusing to continue as commit log depth was limited by shallow clone");
985 }
986
987 Ok(commits)
988}
989
990pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
992 walk_commits_from("HEAD", num_commits)
993}
994
995pub fn pull(work_dir: Option<&Path>) -> Result<()> {
996 pull_internal(work_dir)?;
997 Ok(())
998}
999
1000fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
1001 fetch(work_dir)?;
1002 Ok(())
1003}
1004
1005pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
1006 let op = || {
1007 raw_push(work_dir, remote)
1008 .map_err(map_git_error_for_backoff)
1009 .map_err(|e: ::backoff::Error<GitError>| match e {
1010 ::backoff::Error::Transient { .. } => {
1011 let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
1013
1014 let pull_succeeded = pull_result.is_ok()
1018 || matches!(
1019 pull_result,
1020 Err(::backoff::Error::Permanent(
1021 GitError::RefConcurrentModification { .. }
1022 | GitError::RefFailedToLock { .. }
1023 ))
1024 );
1025
1026 if pull_succeeded {
1027 e
1030 } else {
1031 pull_result.unwrap_err()
1033 }
1034 }
1035 ::backoff::Error::Permanent { .. } => e,
1036 })
1037 };
1038
1039 let backoff = default_backoff();
1040
1041 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
1042 ::backoff::Error::Permanent(err) => {
1043 anyhow!(err).context("Permanent failure while pushing refs")
1044 }
1045 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
1046 })?;
1047
1048 Ok(())
1049}
1050
1051#[cfg(test)]
1052mod test {
1053 use super::*;
1054 use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
1055 use std::process::Command;
1056
1057 use httptest::{
1058 http::{header::AUTHORIZATION, Uri},
1059 matchers::{self, request},
1060 responders::status_code,
1061 Expectation, Server,
1062 };
1063
1064 fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
1065 let url = origin_url.to_string();
1066
1067 run_git_command(&["remote", "add", "origin", &url], dir);
1068 run_git_command(
1069 &[
1070 "config",
1071 "--add",
1072 format!("http.{}.extraHeader", url).as_str(),
1073 extra_header,
1074 ],
1075 dir,
1076 );
1077 }
1078
1079 #[test]
1080 fn test_customheader_pull() {
1081 with_isolated_cwd_git(|git_dir| {
1082 let mut test_server = Server::run();
1083 add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
1084
1085 test_server.expect(
1086 Expectation::matching(request::headers(matchers::contains((
1087 AUTHORIZATION.as_str(),
1088 "sometoken",
1089 ))))
1090 .times(1..)
1091 .respond_with(status_code(200)),
1092 );
1093
1094 let _ = pull(None); test_server.verify_and_clear();
1101 });
1102 }
1103
1104 #[test]
1105 fn test_customheader_push() {
1106 with_isolated_cwd_git(|git_dir| {
1107 let test_server = Server::run();
1108 add_server_remote(
1109 test_server.url(""),
1110 "AUTHORIZATION: someothertoken",
1111 git_dir,
1112 );
1113
1114 test_server.expect(
1115 Expectation::matching(request::headers(matchers::contains((
1116 AUTHORIZATION.as_str(),
1117 "someothertoken",
1118 ))))
1119 .times(1..)
1120 .respond_with(status_code(200)),
1121 );
1122
1123 ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
1125 add_note_line_to_head("test note line").expect("Failed to add note line");
1126
1127 let error = push(None, None);
1128 error
1129 .as_ref()
1130 .expect_err("We have no valid git http server setup -> should fail");
1131 dbg!(&error);
1132 });
1133 }
1134
1135 #[test]
1136 fn test_random_suffix() {
1137 for _ in 1..1000 {
1138 let first = random_suffix();
1139 dbg!(&first);
1140 let second = random_suffix();
1141 dbg!(&second);
1142
1143 let all_hex = |s: &String| s.chars().all(|c| c.is_ascii_hexdigit());
1144
1145 assert_ne!(first, second);
1146 assert_eq!(first.len(), 8);
1147 assert_eq!(second.len(), 8);
1148 assert!(all_hex(&first));
1149 assert!(all_hex(&second));
1150 }
1151 }
1152
1153 #[test]
1154 fn test_empty_or_never_pushed_remote_error_for_fetch() {
1155 with_isolated_cwd_git(|git_dir| {
1156 let git_dir_url = format!("file://{}", git_dir.display());
1158 run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
1159
1160 std::env::set_var("GIT_TRACE", "true");
1162
1163 let result = super::fetch(Some(git_dir));
1165 match result {
1166 Err(GitError::NoRemoteMeasurements { output }) => {
1167 assert!(
1168 output.stderr.contains(GIT_PERF_REMOTE),
1169 "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
1170 output.stderr
1171 )
1172 }
1173 other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
1174 }
1175 });
1176 }
1177
1178 #[test]
1179 fn test_empty_or_never_pushed_remote_error_for_push() {
1180 with_isolated_cwd_git(|git_dir| {
1181 run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
1182
1183 std::env::set_var("GIT_TRACE", "true");
1185
1186 add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
1187
1188 let result = super::raw_push(Some(git_dir), None);
1189 match result {
1190 Err(GitError::RefFailedToPush { output }) => {
1191 assert!(
1192 output.stderr.contains(GIT_PERF_REMOTE),
1193 "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
1194 output.stderr
1195 )
1196 }
1197 other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1198 }
1199 });
1200 }
1201
1202 #[test]
1207 fn test_new_symbolic_write_ref_returns_valid_ref() {
1208 with_isolated_cwd_git(|_git_dir| {
1209 let result = new_symbolic_write_ref();
1211 assert!(
1212 result.is_ok(),
1213 "Should create symbolic write ref: {:?}",
1214 result
1215 );
1216
1217 let ref_name = result.unwrap();
1218
1219 assert!(
1221 !ref_name.is_empty(),
1222 "Reference name should not be empty, got: '{}'",
1223 ref_name
1224 );
1225
1226 assert!(
1228 ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
1229 "Reference should start with {}, got: {}",
1230 REFS_NOTES_WRITE_TARGET_PREFIX,
1231 ref_name
1232 );
1233
1234 let suffix = ref_name
1236 .strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
1237 .expect("Should have prefix");
1238 assert!(
1239 !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()),
1240 "Suffix should be non-empty hex string, got: {}",
1241 suffix
1242 );
1243 });
1244 }
1245
1246 #[test]
1249 fn test_add_and_retrieve_notes() {
1250 with_isolated_cwd_git(|_git_dir| {
1251 let result = add_note_line_to_head("test: 100");
1253 assert!(
1254 result.is_ok(),
1255 "Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
1256 result
1257 );
1258
1259 let result2 = add_note_line_to_head("test: 200");
1261 assert!(result2.is_ok(), "Should add second note: {:?}", result2);
1262
1263 let commits = walk_commits(10);
1265 assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
1266
1267 let commits = commits.unwrap();
1268 assert!(!commits.is_empty(), "Should have commits");
1269
1270 let commit_with_notes = &commits[0];
1272 assert!(
1273 !commit_with_notes.note_lines.is_empty(),
1274 "HEAD should have notes"
1275 );
1276 assert!(
1277 commit_with_notes
1278 .note_lines
1279 .iter()
1280 .any(|n| n.contains("test:")),
1281 "Notes should contain our test data"
1282 );
1283 });
1284 }
1285
1286 #[test]
1290 fn test_walk_commits_shallow_repo_detection() {
1291 use std::env::set_current_dir;
1292
1293 with_isolated_cwd_git(|git_dir| {
1294 for i in 2..=5 {
1296 run_git_command(
1297 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1298 git_dir,
1299 );
1300 }
1301
1302 let shallow_dir = git_dir.join("shallow");
1304 let output = Command::new("git")
1305 .args([
1306 "clone",
1307 "--depth",
1308 "2",
1309 git_dir.to_str().unwrap(),
1310 shallow_dir.to_str().unwrap(),
1311 ])
1312 .output()
1313 .unwrap();
1314
1315 assert!(
1316 output.status.success(),
1317 "Shallow clone failed: {}",
1318 String::from_utf8_lossy(&output.stderr)
1319 );
1320
1321 set_current_dir(&shallow_dir).unwrap();
1323
1324 add_note_line_to_head("test: 100").expect("Should add note");
1326
1327 let result = walk_commits(10);
1329 assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
1330
1331 let commits = result.unwrap();
1332
1333 assert!(
1339 !commits.is_empty(),
1340 "Should have found commits in shallow repo"
1341 );
1342 });
1343 }
1344
1345 #[test]
1347 fn test_walk_commits_normal_repo_not_shallow() {
1348 with_isolated_cwd_git(|git_dir| {
1349 for i in 2..=3 {
1351 run_git_command(
1352 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1353 git_dir,
1354 );
1355 }
1356
1357 add_note_line_to_head("test: 100").expect("Should add note");
1359
1360 let result = walk_commits(10);
1361 assert!(result.is_ok(), "walk_commits should succeed");
1362
1363 let commits = result.unwrap();
1364
1365 assert!(!commits.is_empty(), "Should have found commits");
1367 });
1368 }
1369}