1use std::{
2 io::{BufRead, BufReader, BufWriter, Write},
3 path::Path,
4 process::Stdio,
5 thread,
6 time::Duration,
7};
8
9use defer::defer;
10use log::{debug, warn};
11use unindent::unindent;
12
13use anyhow::{anyhow, bail, Context, Result};
14use backoff::{ExponentialBackoff, ExponentialBackoffBuilder};
15use itertools::Itertools;
16
17use chrono::prelude::*;
18use rand::{rng, RngExt};
19
20use crate::config;
21
22pub use super::git_definitions::REFS_NOTES_BRANCH;
23use super::git_definitions::{
24 GIT_ORIGIN, GIT_PERF_REMOTE, REFS_NOTES_ADD_TARGET_PREFIX, REFS_NOTES_MERGE_BRANCH_PREFIX,
25 REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX, REFS_NOTES_WRITE_SYMBOLIC_REF,
26 REFS_NOTES_WRITE_TARGET_PREFIX,
27};
28use super::git_lowlevel::{
29 capture_git_output, get_git_perf_remote, git_rev_parse, git_rev_parse_symbolic_ref,
30 git_symbolic_ref_create_or_update, git_update_ref, internal_get_head_revision, is_shallow_repo,
31 map_git_error, set_git_perf_remote, spawn_git_command,
32};
33use super::git_types::GitError;
34use super::git_types::GitOutput;
35use super::git_types::Reference;
36
37pub use super::git_lowlevel::get_head_revision;
38
39pub use super::git_lowlevel::check_git_version;
40
41pub use super::git_lowlevel::get_repository_root;
42
43pub use super::git_lowlevel::resolve_committish;
44
45#[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!(remove_reference(&temp_target)
161 .expect("Deleting our own temp ref for adding should never fail"));
162
163 let resolved_commit = git_rev_parse(commit)?;
165
166 capture_git_output(
167 &[
168 "notes",
169 "--ref",
170 &temp_target,
171 "append",
172 "-m",
173 line,
174 &resolved_commit,
175 ],
176 &None,
177 )?;
178
179 git_update_ref(unindent(
185 format!(
186 r#"
187 start
188 update {current_symbolic_ref_target} {temp_target} {current_note_head}
189 commit
190 "#
191 )
192 .as_str(),
193 ))?;
194
195 Ok(())
196}
197
198fn ensure_remote_exists() -> Result<(), GitError> {
199 if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
200 return Ok(());
201 }
202
203 if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
204 return set_git_perf_remote(GIT_PERF_REMOTE, &x);
205 }
206
207 Err(GitError::NoUpstream {})
208}
209
210fn create_temp_ref_name(prefix: &str) -> String {
212 let suffix = random_suffix();
213 format!("{prefix}{suffix}")
214}
215
216fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
217 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
218 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
219
220 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
224 |err| {
225 if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
227 Ok(())
228 } else {
229 Err(err)
230 }
231 },
232 )?;
233 }
234 Ok(())
235}
236
237fn random_suffix() -> String {
238 let suffix: u32 = rng().random::<u32>();
239 format!("{suffix:08x}")
240}
241
242fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
243 ensure_remote_exists()?;
244
245 let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
246 capture_git_output(
248 &[
249 "fetch",
250 "--atomic",
251 "--no-write-fetch-head",
252 GIT_PERF_REMOTE,
253 format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
257 ],
258 &work_dir,
259 )
260 .map_err(map_git_error)?;
261
262 let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
263
264 if ref_before == ref_after {
265 println!("Already up to date");
266 }
267
268 Ok(())
269}
270
271fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
274 _ = capture_git_output(
275 &[
276 "notes",
277 "--ref",
278 target,
279 "merge",
280 "-s",
281 "cat_sort_uniq",
282 branch,
283 ],
284 &None,
285 )?;
286 Ok(())
287}
288
289fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
290 let target = create_temp_ref_name(prefix);
291 if current_head != EMPTY_OID {
292 git_update_ref(unindent(
293 format!(
294 r#"
295 start
296 create {target} {current_head}
297 commit
298 "#
299 )
300 .as_str(),
301 ))?;
302 }
303 Ok(target)
304}
305
306fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
307 create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
308}
309
310fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
311 create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
312}
313
314fn compact_head(target: &str) -> Result<(), GitError> {
315 let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
316
317 let compaction_head = capture_git_output(
319 &["commit-tree", "-m", "cutoff history", &new_removal_head],
320 &None,
321 )?
322 .stdout;
323
324 let compaction_head = compaction_head.trim();
325
326 git_update_ref(unindent(
327 format!(
328 r#"
329 start
330 update {target} {compaction_head}
331 commit
332 "#
333 )
334 .as_str(),
335 ))?;
336
337 Ok(())
338}
339
340fn retry_notify(err: GitError, dur: Duration) {
341 debug!("Error happened at {dur:?}: {err}");
342 warn!("Retrying...");
343}
344
345pub fn remove_measurements_from_commits(
346 older_than: DateTime<Utc>,
347 prune: bool,
348 dry_run: bool,
349) -> Result<()> {
350 if dry_run {
351 return raw_remove_measurements_from_commits(older_than, prune, dry_run)
353 .map_err(|e| anyhow!(e));
354 }
355
356 let op = || -> Result<(), ::backoff::Error<GitError>> {
357 raw_remove_measurements_from_commits(older_than, prune, dry_run)
358 .map_err(map_git_error_for_backoff)
359 };
360
361 let backoff = default_backoff();
362
363 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
364 ::backoff::Error::Permanent(err) => {
365 anyhow!(err).context("Permanent failure while removing measurements")
366 }
367 ::backoff::Error::Transient { err, .. } => {
368 anyhow!(err).context("Timed out while removing measurements")
369 }
370 })?;
371
372 Ok(())
373}
374
375fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
376where
377 F: FnOnce(&str) -> Result<(), GitError>,
378{
379 pull_internal(None)?;
380
381 let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
382 let target = create_temp_rewrite_head(¤t_notes_head)?;
383
384 operation(&target)?;
385
386 compact_head(&target)?;
387
388 git_push_notes_ref(¤t_notes_head, &target, &None, None)?;
389
390 git_update_ref(unindent(
391 format!(
392 r#"
393 start
394 update {REFS_NOTES_BRANCH} {target}
395 commit
396 "#
397 )
398 .as_str(),
399 ))?;
400
401 remove_reference(&target)?;
402
403 Ok(())
404}
405
406fn raw_remove_measurements_from_commits(
407 older_than: DateTime<Utc>,
408 prune: bool,
409 dry_run: bool,
410) -> Result<(), GitError> {
411 if prune && is_shallow_repo()? {
413 return Err(GitError::ShallowRepository);
414 }
415
416 if dry_run {
417 remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
419 if prune {
420 println!("[DRY-RUN] Would prune orphaned measurements after removal");
421 }
422 return Ok(());
423 }
424
425 execute_notes_operation(|target| {
426 remove_measurements_from_reference(target, older_than, dry_run)?;
428
429 if prune {
431 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
432 }
433
434 Ok(())
435 })
436}
437
438fn remove_measurements_from_reference(
440 reference: &str,
441 older_than: DateTime<Utc>,
442 dry_run: bool,
443) -> Result<(), GitError> {
444 let oldest_timestamp = older_than.timestamp();
445 let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
447 let notes_out = list_notes.stdout.take().unwrap();
448
449 let mut get_commit_dates = spawn_git_command(
450 &[
451 "log",
452 "--ignore-missing",
453 "--no-walk",
454 "--pretty=format:%H %ct",
455 "--stdin",
456 ],
457 &None,
458 Some(Stdio::piped()),
459 )?;
460 let dates_in = get_commit_dates.stdin.take().unwrap();
461 let dates_out = get_commit_dates.stdout.take().unwrap();
462
463 if dry_run {
464 let date_collection_handler = thread::spawn(move || {
466 let reader = BufReader::new(dates_out);
467 let mut results = Vec::new();
468 for line in reader.lines().map_while(Result::ok) {
469 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
470 if let Ok(timestamp) = timestamp.parse::<i64>() {
471 if timestamp <= oldest_timestamp {
472 results.push(commit.to_string());
473 }
474 }
475 }
476 }
477 results
478 });
479
480 {
481 let reader = BufReader::new(notes_out);
482 let mut writer = BufWriter::new(dates_in);
483
484 reader.lines().map_while(Result::ok).for_each(|line| {
485 if let Some(line) = line.split_whitespace().nth(1) {
486 writeln!(writer, "{line}").expect("Failed to write to pipe");
487 }
488 });
489 }
490
491 let commits_to_remove = date_collection_handler
492 .join()
493 .expect("Failed to join date collection thread");
494 let count = commits_to_remove.len();
495
496 list_notes.wait()?;
497 get_commit_dates.wait()?;
498
499 if count == 0 {
500 println!(
501 "[DRY-RUN] No measurements older than {} would be removed",
502 older_than
503 );
504 } else {
505 println!(
506 "[DRY-RUN] Would remove measurements from {} commits older than {}",
507 count, older_than
508 );
509 for commit in &commits_to_remove {
510 println!(" {}", commit);
511 }
512 }
513
514 return Ok(());
515 }
516
517 let mut remove_measurements = spawn_git_command(
519 &[
520 "notes",
521 "--ref",
522 reference,
523 "remove",
524 "--stdin",
525 "--ignore-missing",
526 ],
527 &None,
528 Some(Stdio::piped()),
529 )?;
530 let removal_in = remove_measurements.stdin.take().unwrap();
531 let removal_out = remove_measurements.stdout.take().unwrap();
532
533 let removal_handler = thread::spawn(move || {
534 let reader = BufReader::new(dates_out);
535 let mut writer = BufWriter::new(removal_in);
536 for line in reader.lines().map_while(Result::ok) {
537 if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
538 if let Ok(timestamp) = timestamp.parse::<i64>() {
539 if timestamp <= oldest_timestamp {
540 writeln!(writer, "{commit}").expect("Could not write to stream");
541 }
542 }
543 }
544 }
545 });
546
547 let debugging_handler = thread::spawn(move || {
548 let reader = BufReader::new(removal_out);
549 reader
550 .lines()
551 .map_while(Result::ok)
552 .for_each(|l| println!("{l}"))
553 });
554
555 {
556 let reader = BufReader::new(notes_out);
557 let mut writer = BufWriter::new(dates_in);
558
559 reader.lines().map_while(Result::ok).for_each(|line| {
560 if let Some(line) = line.split_whitespace().nth(1) {
561 writeln!(writer, "{line}").expect("Failed to write to pipe");
562 }
563 });
564 }
565
566 removal_handler.join().expect("Failed to join");
567 debugging_handler.join().expect("Failed to join");
568
569 list_notes.wait()?;
570 get_commit_dates.wait()?;
571 remove_measurements.wait()?;
572
573 Ok(())
574}
575
576fn new_symbolic_write_ref() -> Result<String, GitError> {
581 let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
582
583 git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
587 Ok(target)
588}
589
590pub fn create_new_write_ref() -> Result<String> {
594 new_symbolic_write_ref().map_err(|e| anyhow!("{:?}", e))
595}
596
597const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
598
599fn consolidate_write_branches_into(
600 current_upstream_oid: &str,
601 target: &str,
602 except_ref: Option<&str>,
603) -> Result<Vec<Reference>, GitError> {
604 git_update_ref(unindent(
607 format!(
608 r#"
609 start
610 verify {REFS_NOTES_BRANCH} {current_upstream_oid}
611 update {target} {current_upstream_oid} {EMPTY_OID}
612 commit
613 "#
614 )
615 .as_str(),
616 ))?;
617
618 let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
623 let refs = get_refs(additional_args)?
624 .into_iter()
625 .filter(|r| r.refname != except_ref.unwrap_or_default())
626 .collect_vec();
627
628 for reference in &refs {
629 reconcile_branch_with(target, &reference.oid)?;
630 }
631
632 Ok(refs)
633}
634
635fn remove_reference(ref_name: &str) -> Result<(), GitError> {
636 git_update_ref(unindent(
637 format!(
638 r#"
639 start
640 delete {ref_name}
641 commit
642 "#
643 )
644 .as_str(),
645 ))
646}
647
648fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
649 ensure_remote_exists()?;
650 let new_write_ref = new_symbolic_write_ref()?;
658
659 let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
660
661 defer!(remove_reference(&merge_ref).expect("Deleting our own branch should never fail"));
662
663 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
670 let refs =
671 consolidate_write_branches_into(¤t_upstream_oid, &merge_ref, Some(&new_write_ref))?;
672
673 if refs.is_empty() && current_upstream_oid == EMPTY_OID {
674 return Err(GitError::MissingMeasurements);
675 }
676
677 git_push_notes_ref(¤t_upstream_oid, &merge_ref, &work_dir, remote)?;
678
679 fetch(None)?;
681
682 let mut commands = Vec::new();
684 commands.push(String::from("start"));
685 for Reference { refname, oid } in &refs {
686 commands.push(format!("delete {refname} {oid}"));
687 }
688 commands.push(String::from("commit"));
689 commands.push(String::new());
691 let commands = commands.join("\n");
692 git_update_ref(commands)?;
693
694 Ok(())
695}
696
697fn git_push_notes_ref(
698 expected_upstream: &str,
699 push_ref: &str,
700 working_dir: &Option<&Path>,
701 remote: Option<&str>,
702) -> Result<(), GitError> {
703 let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
706 let output = capture_git_output(
707 &[
708 "push",
709 "--porcelain",
710 format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
711 remote_name,
712 format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
713 ],
714 working_dir,
715 );
716
717 match output {
721 Ok(output) => {
722 print!("{}", &output.stdout);
723 Ok(())
724 }
725 Err(GitError::ExecError { output, .. }) => {
726 let successful_push = output.stdout.lines().any(|l| {
727 l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
728 });
729 if successful_push {
730 Ok(())
731 } else {
732 Err(GitError::RefFailedToPush { output })
733 }
734 }
735 Err(e) => Err(e),
736 }?;
737
738 Ok(())
739}
740
741pub fn prune() -> Result<()> {
742 let op = || -> Result<(), ::backoff::Error<GitError>> {
743 raw_prune().map_err(map_git_error_for_backoff)
744 };
745
746 let backoff = default_backoff();
747
748 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
749 ::backoff::Error::Permanent(err) => {
750 anyhow!(err).context("Permanent failure while pruning refs")
751 }
752 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
753 })?;
754
755 Ok(())
756}
757
758fn raw_prune() -> Result<(), GitError> {
759 if is_shallow_repo()? {
760 return Err(GitError::ShallowRepository);
761 }
762
763 execute_notes_operation(|target| {
764 capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
765 })
766}
767
768pub fn list_commits_with_measurements() -> Result<Vec<String>> {
773 let temp_ref = update_read_branch()?;
775
776 let mut list_notes =
779 spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
780
781 let stdout = list_notes
782 .stdout
783 .take()
784 .ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
785
786 let commits: Vec<String> = BufReader::new(stdout)
790 .lines()
791 .filter_map(|line_result| {
792 line_result
793 .ok()
794 .and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
795 })
796 .collect();
797
798 Ok(commits)
799}
800
801pub struct ReadBranchGuard {
804 temp_ref: TempRef,
805}
806
807impl ReadBranchGuard {
808 #[must_use]
810 pub fn ref_name(&self) -> &str {
811 &self.temp_ref.ref_name
812 }
813}
814
815pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
819 let temp_ref = update_read_branch()?;
820 Ok(ReadBranchGuard { temp_ref })
821}
822
823pub fn create_consolidated_pending_read_branch() -> Result<ReadBranchGuard> {
828 let temp_ref = update_pending_read_branch()?;
829 Ok(ReadBranchGuard { temp_ref })
830}
831
832fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
833 let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
834 args.extend(additional_args.iter().map(|s| s.as_str()));
835
836 let output = capture_git_output(&args, &None)?;
837 let refs: Result<Vec<Reference>, _> = output
838 .stdout
839 .lines()
840 .filter(|s| !s.is_empty())
841 .map(|s| {
842 let items = s.split('\0').take(2).collect_vec();
843 if items.len() != 2 {
844 return Err(GitError::ExecError {
845 command: format!("git {}", args.join(" ")),
846 output: GitOutput {
847 stdout: format!("Unexpected git for-each-ref output format: {}", s),
848 stderr: String::new(),
849 },
850 });
851 }
852 Ok(Reference {
853 refname: items[0].to_string(),
854 oid: items[1].to_string(),
855 })
856 })
857 .collect();
858 refs
859}
860
861struct TempRef {
862 ref_name: String,
863}
864
865impl TempRef {
866 fn new(prefix: &str) -> Result<Self, GitError> {
867 Ok(TempRef {
868 ref_name: create_temp_ref(prefix, EMPTY_OID)?,
869 })
870 }
871}
872
873impl Drop for TempRef {
874 fn drop(&mut self) {
875 remove_reference(&self.ref_name)
876 .unwrap_or_else(|_| panic!("Failed to remove reference: {}", self.ref_name))
877 }
878}
879
880fn update_read_branch() -> Result<TempRef> {
881 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
882 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
883 let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
887
888 consolidate_write_branches_into(¤t_upstream_oid, &temp_ref.ref_name, None)
889 .map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
890
891 Ok(temp_ref)
892}
893
894fn update_pending_read_branch() -> Result<TempRef> {
895 let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
896 .map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
897 let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
900 .map_err(|e| anyhow!("Failed to get write refs: {:?}", e))?;
901
902 for reference in &refs {
903 reconcile_branch_with(&temp_ref.ref_name, &reference.oid)
904 .map_err(|e| anyhow!("Failed to merge write ref: {:?}", e))?;
905 }
906
907 Ok(temp_ref)
908}
909
910pub fn walk_commits_from(start_commit: &str, num_commits: usize) -> Result<Vec<CommitWithNotes>> {
953 let temp_ref = update_read_branch()?;
955
956 let resolved_commit = resolve_committish(start_commit)
958 .context(format!("Failed to resolve commit '{}'", start_commit))?;
959
960 let output = capture_git_output(
961 &[
962 "--no-pager",
963 "log",
964 "--no-color",
965 "--ignore-missing",
966 "-n",
967 num_commits.to_string().as_str(),
968 "--first-parent",
969 "--pretty=--,%H,%s,%an,%D%n%N",
970 "--decorate=full",
971 format!("--notes={}", temp_ref.ref_name).as_str(),
972 &resolved_commit,
973 ],
974 &None,
975 )
976 .context(format!("Failed to retrieve commits from {}", start_commit))?;
977
978 let mut commits: Vec<CommitWithNotes> = Vec::new();
979 let mut detected_shallow = false;
980 let mut current_commit_sha: Option<String> = None;
981
982 for l in output.stdout.lines() {
983 if l.starts_with("--") {
984 let parts: Vec<&str> = l.splitn(5, ',').collect();
986 if parts.len() < 5 {
987 bail!(
988 "Invalid git log format: expected 5 fields, got {}",
989 parts.len()
990 );
991 }
992
993 let sha = parts[1].to_string();
994 let title = if parts[2].is_empty() {
995 "[no subject]".to_string()
996 } else {
997 parts[2].to_string()
998 };
999 let author = if parts[3].is_empty() {
1000 "[unknown]".to_string()
1001 } else {
1002 parts[3].to_string()
1003 };
1004 let decorations = parts[4];
1005
1006 detected_shallow |= decorations.contains("grafted");
1007 current_commit_sha = Some(sha.clone());
1008
1009 commits.push(CommitWithNotes {
1010 sha,
1011 title,
1012 author,
1013 note_lines: Vec::new(),
1014 });
1015 } else if current_commit_sha.is_some() {
1016 if let Some(last) = commits.last_mut() {
1017 last.note_lines.push(l.to_string());
1018 }
1019 }
1020 }
1021
1022 if detected_shallow && commits.len() < num_commits {
1023 bail!("Refusing to continue as commit log depth was limited by shallow clone");
1024 }
1025
1026 Ok(commits)
1027}
1028
1029pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
1031 walk_commits_from("HEAD", num_commits)
1032}
1033
1034pub fn get_commits_with_notes(notes_ref: &str) -> Result<Vec<String>> {
1040 let output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
1041 .context(format!("Failed to list notes in {}", notes_ref))?;
1042
1043 let commits: Vec<String> = output
1045 .stdout
1046 .lines()
1047 .filter(|line| !line.is_empty())
1048 .filter_map(|line| {
1049 let parts: Vec<&str> = line.split_whitespace().collect();
1050 if parts.len() >= 2 {
1051 Some(parts[1].to_string())
1052 } else {
1053 None
1054 }
1055 })
1056 .collect();
1057
1058 Ok(commits)
1059}
1060
1061pub fn get_commit_details(commit_shas: &[String]) -> Result<Vec<CommitWithNotes>> {
1064 if commit_shas.is_empty() {
1065 return Ok(Vec::new());
1066 }
1067
1068 let mut commits = Vec::new();
1069
1070 for sha in commit_shas {
1071 let output =
1072 capture_git_output(&["show", "--no-patch", "--format=%H%n%s%n%an", sha], &None)
1073 .context(format!("Failed to get commit details for {}", sha))?;
1074
1075 let lines: Vec<&str> = output.stdout.lines().collect();
1076 if lines.len() >= 3 {
1077 commits.push(CommitWithNotes {
1078 sha: lines[0].to_string(),
1079 title: if lines[1].is_empty() {
1080 "[no subject]".to_string()
1081 } else {
1082 lines[1].to_string()
1083 },
1084 author: if lines[2].is_empty() {
1085 "[unknown]".to_string()
1086 } else {
1087 lines[2].to_string()
1088 },
1089 note_lines: Vec::new(), });
1091 }
1092 }
1093
1094 Ok(commits)
1095}
1096
1097pub fn get_notes_for_commit(notes_ref: &str, commit_sha: &str) -> Result<Vec<String>> {
1100 let output = capture_git_output(&["notes", "--ref", notes_ref, "show", commit_sha], &None);
1101
1102 match output {
1103 Ok(output) => {
1104 let note_lines: Vec<String> = output.stdout.lines().map(|s| s.to_string()).collect();
1105 Ok(note_lines)
1106 }
1107 Err(_) => {
1108 Ok(Vec::new())
1110 }
1111 }
1112}
1113
1114pub fn pull(work_dir: Option<&Path>) -> Result<()> {
1115 pull_internal(work_dir)?;
1116 Ok(())
1117}
1118
1119fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
1120 fetch(work_dir)?;
1121 Ok(())
1122}
1123
1124pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
1125 let op = || {
1126 raw_push(work_dir, remote)
1127 .map_err(map_git_error_for_backoff)
1128 .map_err(|e: ::backoff::Error<GitError>| match e {
1129 ::backoff::Error::Transient { .. } => {
1130 let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
1132
1133 let pull_succeeded = pull_result.is_ok()
1137 || matches!(
1138 pull_result,
1139 Err(::backoff::Error::Permanent(
1140 GitError::RefConcurrentModification { .. }
1141 | GitError::RefFailedToLock { .. }
1142 ))
1143 );
1144
1145 if pull_succeeded {
1146 e
1149 } else {
1150 pull_result.unwrap_err()
1152 }
1153 }
1154 ::backoff::Error::Permanent { .. } => e,
1155 })
1156 };
1157
1158 let backoff = default_backoff();
1159
1160 ::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
1161 ::backoff::Error::Permanent(err) => {
1162 anyhow!(err).context("Permanent failure while pushing refs")
1163 }
1164 ::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
1165 })?;
1166
1167 Ok(())
1168}
1169
1170pub fn get_write_refs() -> Result<Vec<(String, String)>> {
1172 let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
1173 .map_err(|e| anyhow!("{:?}", e))?;
1174 Ok(refs.into_iter().map(|r| (r.refname, r.oid)).collect())
1175}
1176
1177pub fn delete_reference(ref_name: &str) -> Result<()> {
1179 remove_reference(ref_name).map_err(|e| anyhow!("{:?}", e))
1180}
1181
1182#[cfg(test)]
1183mod test {
1184 use super::*;
1185 use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
1186 use std::process::Command;
1187
1188 use httptest::{
1189 http::{header::AUTHORIZATION, Uri},
1190 matchers::{self, request},
1191 responders::status_code,
1192 Expectation, Server,
1193 };
1194
1195 fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
1196 let url = origin_url.to_string();
1197
1198 run_git_command(&["remote", "add", "origin", &url], dir);
1199 run_git_command(
1200 &[
1201 "config",
1202 "--add",
1203 format!("http.{}.extraHeader", url).as_str(),
1204 extra_header,
1205 ],
1206 dir,
1207 );
1208 }
1209
1210 #[test]
1211 fn test_customheader_pull() {
1212 with_isolated_cwd_git(|git_dir| {
1213 let mut test_server = Server::run();
1214 add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
1215
1216 test_server.expect(
1217 Expectation::matching(request::headers(matchers::contains((
1218 AUTHORIZATION.as_str(),
1219 "sometoken",
1220 ))))
1221 .times(1..)
1222 .respond_with(status_code(200)),
1223 );
1224
1225 let _ = pull(None); test_server.verify_and_clear();
1232 });
1233 }
1234
1235 #[test]
1236 fn test_customheader_push() {
1237 with_isolated_cwd_git(|git_dir| {
1238 let test_server = Server::run();
1239 add_server_remote(
1240 test_server.url(""),
1241 "AUTHORIZATION: someothertoken",
1242 git_dir,
1243 );
1244
1245 test_server.expect(
1246 Expectation::matching(request::headers(matchers::contains((
1247 AUTHORIZATION.as_str(),
1248 "someothertoken",
1249 ))))
1250 .times(1..)
1251 .respond_with(status_code(200)),
1252 );
1253
1254 ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
1256 add_note_line_to_head("test note line").expect("Failed to add note line");
1257
1258 let error = push(None, None);
1259 error
1260 .as_ref()
1261 .expect_err("We have no valid git http server setup -> should fail");
1262 dbg!(&error);
1263 });
1264 }
1265
1266 #[test]
1267 fn test_random_suffix() {
1268 for _ in 1..1000 {
1269 let first = random_suffix();
1270 dbg!(&first);
1271 let second = random_suffix();
1272 dbg!(&second);
1273
1274 let all_hex = |s: &String| s.chars().all(|c| c.is_ascii_hexdigit());
1275
1276 assert_ne!(first, second);
1277 assert_eq!(first.len(), 8);
1278 assert_eq!(second.len(), 8);
1279 assert!(all_hex(&first));
1280 assert!(all_hex(&second));
1281 }
1282 }
1283
1284 #[test]
1285 fn test_empty_or_never_pushed_remote_error_for_fetch() {
1286 with_isolated_cwd_git(|git_dir| {
1287 let git_dir_url = format!("file://{}", git_dir.display());
1289 run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
1290
1291 std::env::set_var("GIT_TRACE", "true");
1293
1294 let result = super::fetch(Some(git_dir));
1296 match result {
1297 Err(GitError::NoRemoteMeasurements { output }) => {
1298 assert!(
1299 output.stderr.contains(GIT_PERF_REMOTE),
1300 "Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
1301 output.stderr
1302 )
1303 }
1304 other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
1305 }
1306 });
1307 }
1308
1309 #[test]
1310 fn test_empty_or_never_pushed_remote_error_for_push() {
1311 with_isolated_cwd_git(|git_dir| {
1312 run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
1313
1314 std::env::set_var("GIT_TRACE", "true");
1316
1317 add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
1318
1319 let result = super::raw_push(Some(git_dir), None);
1320 match result {
1321 Err(GitError::RefFailedToPush { output }) => {
1322 assert!(
1323 output.stderr.contains(GIT_PERF_REMOTE),
1324 "Expected output to contain {GIT_PERF_REMOTE}, got: {}",
1325 output.stderr
1326 )
1327 }
1328 other => panic!("Expected RefFailedToPush error, got: {:?}", other),
1329 }
1330 });
1331 }
1332
1333 #[test]
1338 fn test_new_symbolic_write_ref_returns_valid_ref() {
1339 with_isolated_cwd_git(|_git_dir| {
1340 let result = new_symbolic_write_ref();
1342 assert!(
1343 result.is_ok(),
1344 "Should create symbolic write ref: {:?}",
1345 result
1346 );
1347
1348 let ref_name = result.unwrap();
1349
1350 assert!(
1352 !ref_name.is_empty(),
1353 "Reference name should not be empty, got: '{}'",
1354 ref_name
1355 );
1356
1357 assert!(
1359 ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
1360 "Reference should start with {}, got: {}",
1361 REFS_NOTES_WRITE_TARGET_PREFIX,
1362 ref_name
1363 );
1364
1365 let suffix = ref_name
1367 .strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
1368 .expect("Should have prefix");
1369 assert!(
1370 !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()),
1371 "Suffix should be non-empty hex string, got: {}",
1372 suffix
1373 );
1374 });
1375 }
1376
1377 #[test]
1380 fn test_add_and_retrieve_notes() {
1381 with_isolated_cwd_git(|_git_dir| {
1382 let result = add_note_line_to_head("test: 100");
1384 assert!(
1385 result.is_ok(),
1386 "Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
1387 result
1388 );
1389
1390 let result2 = add_note_line_to_head("test: 200");
1392 assert!(result2.is_ok(), "Should add second note: {:?}", result2);
1393
1394 let commits = walk_commits(10);
1396 assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
1397
1398 let commits = commits.unwrap();
1399 assert!(!commits.is_empty(), "Should have commits");
1400
1401 let commit_with_notes = &commits[0];
1403 assert!(
1404 !commit_with_notes.note_lines.is_empty(),
1405 "HEAD should have notes"
1406 );
1407 assert!(
1408 commit_with_notes
1409 .note_lines
1410 .iter()
1411 .any(|n| n.contains("test:")),
1412 "Notes should contain our test data"
1413 );
1414 });
1415 }
1416
1417 #[test]
1421 fn test_walk_commits_shallow_repo_detection() {
1422 use std::env::set_current_dir;
1423
1424 with_isolated_cwd_git(|git_dir| {
1425 for i in 2..=5 {
1427 run_git_command(
1428 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1429 git_dir,
1430 );
1431 }
1432
1433 let shallow_dir = git_dir.join("shallow");
1435 let output = Command::new("git")
1436 .args([
1437 "clone",
1438 "--depth",
1439 "2",
1440 git_dir.to_str().unwrap(),
1441 shallow_dir.to_str().unwrap(),
1442 ])
1443 .output()
1444 .unwrap();
1445
1446 assert!(
1447 output.status.success(),
1448 "Shallow clone failed: {}",
1449 String::from_utf8_lossy(&output.stderr)
1450 );
1451
1452 set_current_dir(&shallow_dir).unwrap();
1454
1455 add_note_line_to_head("test: 100").expect("Should add note");
1457
1458 let result = walk_commits(10);
1460 assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
1461
1462 let commits = result.unwrap();
1463
1464 assert!(
1470 !commits.is_empty(),
1471 "Should have found commits in shallow repo"
1472 );
1473 });
1474 }
1475
1476 #[test]
1478 fn test_walk_commits_normal_repo_not_shallow() {
1479 with_isolated_cwd_git(|git_dir| {
1480 for i in 2..=3 {
1482 run_git_command(
1483 &["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
1484 git_dir,
1485 );
1486 }
1487
1488 add_note_line_to_head("test: 100").expect("Should add note");
1490
1491 let result = walk_commits(10);
1492 assert!(result.is_ok(), "walk_commits should succeed");
1493
1494 let commits = result.unwrap();
1495
1496 assert!(!commits.is_empty(), "Should have found commits");
1498 });
1499 }
1500}