use std::{
io::{BufRead, BufReader, BufWriter, Write},
path::Path,
process::Stdio,
thread,
time::Duration,
};
use defer::defer;
use log::{debug, warn};
use unindent::unindent;
use anyhow::{anyhow, bail, Context, Result};
use backoff::{ExponentialBackoff, ExponentialBackoffBuilder};
use itertools::Itertools;
use chrono::prelude::*;
use rand::{rng, RngExt};
use crate::config;
pub use super::git_definitions::REFS_NOTES_BRANCH;
use super::git_definitions::{
GIT_ORIGIN, GIT_PERF_REMOTE, REFS_NOTES_ADD_TARGET_PREFIX, REFS_NOTES_MERGE_BRANCH_PREFIX,
REFS_NOTES_READ_PREFIX, REFS_NOTES_REWRITE_TARGET_PREFIX, REFS_NOTES_WRITE_SYMBOLIC_REF,
REFS_NOTES_WRITE_TARGET_PREFIX,
};
use super::git_lowlevel::{
capture_git_output, get_git_perf_remote, git_rev_parse, git_rev_parse_symbolic_ref,
git_symbolic_ref_create_or_update, git_update_ref, internal_get_head_revision, is_shallow_repo,
map_git_error, set_git_perf_remote, spawn_git_command,
};
use super::git_types::GitError;
use super::git_types::GitOutput;
use super::git_types::Reference;
pub use super::git_lowlevel::get_head_revision;
pub use super::git_lowlevel::check_git_version;
pub use super::git_lowlevel::get_repository_root;
pub use super::git_lowlevel::resolve_committish;
#[derive(Debug, Clone, PartialEq)]
pub struct CommitWithNotes {
pub sha: String,
pub title: String,
pub author: String,
pub note_lines: Vec<String>,
}
pub fn is_shallow_repository() -> Result<bool> {
super::git_lowlevel::is_shallow_repo()
.map_err(|e| anyhow!("Failed to check if repository is shallow: {}", e))
}
fn map_git_error_for_backoff(e: GitError) -> ::backoff::Error<GitError> {
match e {
GitError::RefFailedToPush { .. }
| GitError::RefFailedToLock { .. }
| GitError::RefConcurrentModification { .. }
| GitError::BadObject { .. } => ::backoff::Error::transient(e),
GitError::ExecError { .. }
| GitError::IoError(..)
| GitError::ShallowRepository
| GitError::MissingHead { .. }
| GitError::NoRemoteMeasurements { .. }
| GitError::NoUpstream { .. }
| GitError::MissingMeasurements => ::backoff::Error::permanent(e),
}
}
fn default_backoff() -> ExponentialBackoff {
let max_elapsed = config::backoff_max_elapsed_seconds();
ExponentialBackoffBuilder::default()
.with_max_elapsed_time(Some(Duration::from_secs(max_elapsed)))
.build()
}
pub fn add_note_line(commit: &str, line: &str) -> Result<()> {
let op = || -> Result<(), ::backoff::Error<GitError>> {
raw_add_note_line(commit, line).map_err(map_git_error_for_backoff)
};
let backoff = default_backoff();
::backoff::retry(backoff, op).map_err(|e| match e {
::backoff::Error::Permanent(err) => anyhow!(err).context(format!(
"Permanent failure while adding note line to commit {}",
commit
)),
::backoff::Error::Transient { err, .. } => anyhow!(err).context(format!(
"Timed out while adding note line to commit {}",
commit
)),
})?;
Ok(())
}
pub fn add_note_line_to_head(line: &str) -> Result<()> {
let head = internal_get_head_revision()
.map_err(|e| anyhow!(e).context("Failed to get HEAD revision"))?;
add_note_line(&head, line)
}
fn raw_add_note_line(commit: &str, line: &str) -> Result<(), GitError> {
ensure_symbolic_write_ref_exists()?;
let current_note_head =
git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).unwrap_or(EMPTY_OID.to_string());
let current_symbolic_ref_target = git_rev_parse_symbolic_ref(REFS_NOTES_WRITE_SYMBOLIC_REF)
.expect("Missing symbolic-ref for target");
let temp_target = create_temp_add_head(¤t_note_head)?;
defer!(remove_reference(&temp_target)
.expect("Deleting our own temp ref for adding should never fail"));
let resolved_commit = git_rev_parse(commit)?;
capture_git_output(
&[
"notes",
"--ref",
&temp_target,
"append",
"-m",
line,
&resolved_commit,
],
&None,
)?;
git_update_ref(unindent(
format!(
r#"
start
update {current_symbolic_ref_target} {temp_target} {current_note_head}
commit
"#
)
.as_str(),
))?;
Ok(())
}
fn ensure_remote_exists() -> Result<(), GitError> {
if get_git_perf_remote(GIT_PERF_REMOTE).is_some() {
return Ok(());
}
if let Some(x) = get_git_perf_remote(GIT_ORIGIN) {
return set_git_perf_remote(GIT_PERF_REMOTE, &x);
}
Err(GitError::NoUpstream {})
}
fn create_temp_ref_name(prefix: &str) -> String {
let suffix = random_suffix();
format!("{prefix}{suffix}")
}
fn ensure_symbolic_write_ref_exists() -> Result<(), GitError> {
if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_err() {
let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target).or_else(
|err| {
if git_rev_parse(REFS_NOTES_WRITE_SYMBOLIC_REF).is_ok() {
Ok(())
} else {
Err(err)
}
},
)?;
}
Ok(())
}
fn random_suffix() -> String {
let suffix: u32 = rng().random::<u32>();
format!("{suffix:08x}")
}
fn fetch(work_dir: Option<&Path>) -> Result<(), GitError> {
ensure_remote_exists()?;
let ref_before = git_rev_parse(REFS_NOTES_BRANCH).ok();
capture_git_output(
&[
"fetch",
"--atomic",
"--no-write-fetch-head",
GIT_PERF_REMOTE,
format!("+{REFS_NOTES_BRANCH}:{REFS_NOTES_BRANCH}").as_str(),
],
&work_dir,
)
.map_err(map_git_error)?;
let ref_after = git_rev_parse(REFS_NOTES_BRANCH).ok();
if ref_before == ref_after {
println!("Already up to date");
}
Ok(())
}
fn reconcile_branch_with(target: &str, branch: &str) -> Result<(), GitError> {
_ = capture_git_output(
&[
"notes",
"--ref",
target,
"merge",
"-s",
"cat_sort_uniq",
branch,
],
&None,
)?;
Ok(())
}
fn create_temp_ref(prefix: &str, current_head: &str) -> Result<String, GitError> {
let target = create_temp_ref_name(prefix);
if current_head != EMPTY_OID {
git_update_ref(unindent(
format!(
r#"
start
create {target} {current_head}
commit
"#
)
.as_str(),
))?;
}
Ok(target)
}
fn create_temp_rewrite_head(current_notes_head: &str) -> Result<String, GitError> {
create_temp_ref(REFS_NOTES_REWRITE_TARGET_PREFIX, current_notes_head)
}
fn create_temp_add_head(current_notes_head: &str) -> Result<String, GitError> {
create_temp_ref(REFS_NOTES_ADD_TARGET_PREFIX, current_notes_head)
}
fn compact_head(target: &str) -> Result<(), GitError> {
let new_removal_head = git_rev_parse(format!("{target}^{{tree}}").as_str())?;
let compaction_head = capture_git_output(
&["commit-tree", "-m", "cutoff history", &new_removal_head],
&None,
)?
.stdout;
let compaction_head = compaction_head.trim();
git_update_ref(unindent(
format!(
r#"
start
update {target} {compaction_head}
commit
"#
)
.as_str(),
))?;
Ok(())
}
fn retry_notify(err: GitError, dur: Duration) {
debug!("Error happened at {dur:?}: {err}");
warn!("Retrying...");
}
pub fn remove_measurements_from_commits(
older_than: DateTime<Utc>,
prune: bool,
dry_run: bool,
) -> Result<()> {
if dry_run {
return raw_remove_measurements_from_commits(older_than, prune, dry_run)
.map_err(|e| anyhow!(e));
}
let op = || -> Result<(), ::backoff::Error<GitError>> {
raw_remove_measurements_from_commits(older_than, prune, dry_run)
.map_err(map_git_error_for_backoff)
};
let backoff = default_backoff();
::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
::backoff::Error::Permanent(err) => {
anyhow!(err).context("Permanent failure while removing measurements")
}
::backoff::Error::Transient { err, .. } => {
anyhow!(err).context("Timed out while removing measurements")
}
})?;
Ok(())
}
fn execute_notes_operation<F>(operation: F) -> Result<(), GitError>
where
F: FnOnce(&str) -> Result<(), GitError>,
{
pull_internal(None)?;
let current_notes_head = git_rev_parse(REFS_NOTES_BRANCH)?;
let target = create_temp_rewrite_head(¤t_notes_head)?;
operation(&target)?;
compact_head(&target)?;
git_push_notes_ref(¤t_notes_head, &target, &None, None)?;
git_update_ref(unindent(
format!(
r#"
start
update {REFS_NOTES_BRANCH} {target}
commit
"#
)
.as_str(),
))?;
remove_reference(&target)?;
Ok(())
}
fn raw_remove_measurements_from_commits(
older_than: DateTime<Utc>,
prune: bool,
dry_run: bool,
) -> Result<(), GitError> {
if prune && is_shallow_repo()? {
return Err(GitError::ShallowRepository);
}
if dry_run {
remove_measurements_from_reference(REFS_NOTES_BRANCH, older_than, dry_run)?;
if prune {
println!("[DRY-RUN] Would prune orphaned measurements after removal");
}
return Ok(());
}
execute_notes_operation(|target| {
remove_measurements_from_reference(target, older_than, dry_run)?;
if prune {
capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())?;
}
Ok(())
})
}
fn remove_measurements_from_reference(
reference: &str,
older_than: DateTime<Utc>,
dry_run: bool,
) -> Result<(), GitError> {
let oldest_timestamp = older_than.timestamp();
let mut list_notes = spawn_git_command(&["notes", "--ref", reference, "list"], &None, None)?;
let notes_out = list_notes.stdout.take().unwrap();
let mut get_commit_dates = spawn_git_command(
&[
"log",
"--ignore-missing",
"--no-walk",
"--pretty=format:%H %ct",
"--stdin",
],
&None,
Some(Stdio::piped()),
)?;
let dates_in = get_commit_dates.stdin.take().unwrap();
let dates_out = get_commit_dates.stdout.take().unwrap();
if dry_run {
let date_collection_handler = thread::spawn(move || {
let reader = BufReader::new(dates_out);
let mut results = Vec::new();
for line in reader.lines().map_while(Result::ok) {
if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
if let Ok(timestamp) = timestamp.parse::<i64>() {
if timestamp <= oldest_timestamp {
results.push(commit.to_string());
}
}
}
}
results
});
{
let reader = BufReader::new(notes_out);
let mut writer = BufWriter::new(dates_in);
reader.lines().map_while(Result::ok).for_each(|line| {
if let Some(line) = line.split_whitespace().nth(1) {
writeln!(writer, "{line}").expect("Failed to write to pipe");
}
});
}
let commits_to_remove = date_collection_handler
.join()
.expect("Failed to join date collection thread");
let count = commits_to_remove.len();
list_notes.wait()?;
get_commit_dates.wait()?;
if count == 0 {
println!(
"[DRY-RUN] No measurements older than {} would be removed",
older_than
);
} else {
println!(
"[DRY-RUN] Would remove measurements from {} commits older than {}",
count, older_than
);
for commit in &commits_to_remove {
println!(" {}", commit);
}
}
return Ok(());
}
let mut remove_measurements = spawn_git_command(
&[
"notes",
"--ref",
reference,
"remove",
"--stdin",
"--ignore-missing",
],
&None,
Some(Stdio::piped()),
)?;
let removal_in = remove_measurements.stdin.take().unwrap();
let removal_out = remove_measurements.stdout.take().unwrap();
let removal_handler = thread::spawn(move || {
let reader = BufReader::new(dates_out);
let mut writer = BufWriter::new(removal_in);
for line in reader.lines().map_while(Result::ok) {
if let Some((commit, timestamp)) = line.split_whitespace().take(2).collect_tuple() {
if let Ok(timestamp) = timestamp.parse::<i64>() {
if timestamp <= oldest_timestamp {
writeln!(writer, "{commit}").expect("Could not write to stream");
}
}
}
}
});
let debugging_handler = thread::spawn(move || {
let reader = BufReader::new(removal_out);
reader
.lines()
.map_while(Result::ok)
.for_each(|l| println!("{l}"))
});
{
let reader = BufReader::new(notes_out);
let mut writer = BufWriter::new(dates_in);
reader.lines().map_while(Result::ok).for_each(|line| {
if let Some(line) = line.split_whitespace().nth(1) {
writeln!(writer, "{line}").expect("Failed to write to pipe");
}
});
}
removal_handler.join().expect("Failed to join");
debugging_handler.join().expect("Failed to join");
list_notes.wait()?;
get_commit_dates.wait()?;
remove_measurements.wait()?;
Ok(())
}
fn new_symbolic_write_ref() -> Result<String, GitError> {
let target = create_temp_ref_name(REFS_NOTES_WRITE_TARGET_PREFIX);
git_symbolic_ref_create_or_update(REFS_NOTES_WRITE_SYMBOLIC_REF, &target)?;
Ok(target)
}
pub fn create_new_write_ref() -> Result<String> {
new_symbolic_write_ref().map_err(|e| anyhow!("{:?}", e))
}
const EMPTY_OID: &str = "0000000000000000000000000000000000000000";
fn consolidate_write_branches_into(
current_upstream_oid: &str,
target: &str,
except_ref: Option<&str>,
) -> Result<Vec<Reference>, GitError> {
git_update_ref(unindent(
format!(
r#"
start
verify {REFS_NOTES_BRANCH} {current_upstream_oid}
update {target} {current_upstream_oid} {EMPTY_OID}
commit
"#
)
.as_str(),
))?;
let additional_args = vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")];
let refs = get_refs(additional_args)?
.into_iter()
.filter(|r| r.refname != except_ref.unwrap_or_default())
.collect_vec();
for reference in &refs {
reconcile_branch_with(target, &reference.oid)?;
}
Ok(refs)
}
fn remove_reference(ref_name: &str) -> Result<(), GitError> {
git_update_ref(unindent(
format!(
r#"
start
delete {ref_name}
commit
"#
)
.as_str(),
))
}
fn raw_push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<(), GitError> {
ensure_remote_exists()?;
let new_write_ref = new_symbolic_write_ref()?;
let merge_ref = create_temp_ref_name(REFS_NOTES_MERGE_BRANCH_PREFIX);
defer!(remove_reference(&merge_ref).expect("Deleting our own branch should never fail"));
let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
let refs =
consolidate_write_branches_into(¤t_upstream_oid, &merge_ref, Some(&new_write_ref))?;
if refs.is_empty() && current_upstream_oid == EMPTY_OID {
return Err(GitError::MissingMeasurements);
}
git_push_notes_ref(¤t_upstream_oid, &merge_ref, &work_dir, remote)?;
fetch(None)?;
let mut commands = Vec::new();
commands.push(String::from("start"));
for Reference { refname, oid } in &refs {
commands.push(format!("delete {refname} {oid}"));
}
commands.push(String::from("commit"));
commands.push(String::new());
let commands = commands.join("\n");
git_update_ref(commands)?;
Ok(())
}
fn git_push_notes_ref(
expected_upstream: &str,
push_ref: &str,
working_dir: &Option<&Path>,
remote: Option<&str>,
) -> Result<(), GitError> {
let remote_name = remote.unwrap_or(GIT_PERF_REMOTE);
let output = capture_git_output(
&[
"push",
"--porcelain",
format!("--force-with-lease={REFS_NOTES_BRANCH}:{expected_upstream}").as_str(),
remote_name,
format!("{push_ref}:{REFS_NOTES_BRANCH}").as_str(),
],
working_dir,
);
match output {
Ok(output) => {
print!("{}", &output.stdout);
Ok(())
}
Err(GitError::ExecError { output, .. }) => {
let successful_push = output.stdout.lines().any(|l| {
l.contains(format!("{REFS_NOTES_BRANCH}:").as_str()) && !l.starts_with('!')
});
if successful_push {
Ok(())
} else {
Err(GitError::RefFailedToPush { output })
}
}
Err(e) => Err(e),
}?;
Ok(())
}
pub fn prune() -> Result<()> {
let op = || -> Result<(), ::backoff::Error<GitError>> {
raw_prune().map_err(map_git_error_for_backoff)
};
let backoff = default_backoff();
::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
::backoff::Error::Permanent(err) => {
anyhow!(err).context("Permanent failure while pruning refs")
}
::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
})?;
Ok(())
}
fn raw_prune() -> Result<(), GitError> {
if is_shallow_repo()? {
return Err(GitError::ShallowRepository);
}
execute_notes_operation(|target| {
capture_git_output(&["notes", "--ref", target, "prune"], &None).map(|_| ())
})
}
pub fn list_commits_with_measurements() -> Result<Vec<String>> {
let temp_ref = update_read_branch()?;
let mut list_notes =
spawn_git_command(&["notes", "--ref", &temp_ref.ref_name, "list"], &None, None)?;
let stdout = list_notes
.stdout
.take()
.ok_or_else(|| anyhow!("Failed to capture stdout from git notes list"))?;
let commits: Vec<String> = BufReader::new(stdout)
.lines()
.filter_map(|line_result| {
line_result
.ok()
.and_then(|line| line.split_whitespace().nth(1).map(|s| s.to_string()))
})
.collect();
Ok(commits)
}
pub struct ReadBranchGuard {
temp_ref: TempRef,
}
impl ReadBranchGuard {
#[must_use]
pub fn ref_name(&self) -> &str {
&self.temp_ref.ref_name
}
}
pub fn create_consolidated_read_branch() -> Result<ReadBranchGuard> {
let temp_ref = update_read_branch()?;
Ok(ReadBranchGuard { temp_ref })
}
pub fn create_consolidated_pending_read_branch() -> Result<ReadBranchGuard> {
let temp_ref = update_pending_read_branch()?;
Ok(ReadBranchGuard { temp_ref })
}
fn get_refs(additional_args: Vec<String>) -> Result<Vec<Reference>, GitError> {
let mut args = vec!["for-each-ref", "--format=%(refname)%00%(objectname)"];
args.extend(additional_args.iter().map(|s| s.as_str()));
let output = capture_git_output(&args, &None)?;
let refs: Result<Vec<Reference>, _> = output
.stdout
.lines()
.filter(|s| !s.is_empty())
.map(|s| {
let items = s.split('\0').take(2).collect_vec();
if items.len() != 2 {
return Err(GitError::ExecError {
command: format!("git {}", args.join(" ")),
output: GitOutput {
stdout: format!("Unexpected git for-each-ref output format: {}", s),
stderr: String::new(),
},
});
}
Ok(Reference {
refname: items[0].to_string(),
oid: items[1].to_string(),
})
})
.collect();
refs
}
struct TempRef {
ref_name: String,
}
impl TempRef {
fn new(prefix: &str) -> Result<Self, GitError> {
Ok(TempRef {
ref_name: create_temp_ref(prefix, EMPTY_OID)?,
})
}
}
impl Drop for TempRef {
fn drop(&mut self) {
remove_reference(&self.ref_name)
.unwrap_or_else(|_| panic!("Failed to remove reference: {}", self.ref_name))
}
}
fn update_read_branch() -> Result<TempRef> {
let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
.map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
let current_upstream_oid = git_rev_parse(REFS_NOTES_BRANCH).unwrap_or(EMPTY_OID.to_string());
consolidate_write_branches_into(¤t_upstream_oid, &temp_ref.ref_name, None)
.map_err(|e| anyhow!("Failed to consolidate write branches: {:?}", e))?;
Ok(temp_ref)
}
fn update_pending_read_branch() -> Result<TempRef> {
let temp_ref = TempRef::new(REFS_NOTES_READ_PREFIX)
.map_err(|e| anyhow!("Failed to create temporary ref: {:?}", e))?;
let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
.map_err(|e| anyhow!("Failed to get write refs: {:?}", e))?;
for reference in &refs {
reconcile_branch_with(&temp_ref.ref_name, &reference.oid)
.map_err(|e| anyhow!("Failed to merge write ref: {:?}", e))?;
}
Ok(temp_ref)
}
pub fn walk_commits_from(start_commit: &str, num_commits: usize) -> Result<Vec<CommitWithNotes>> {
let temp_ref = update_read_branch()?;
let resolved_commit = resolve_committish(start_commit)
.context(format!("Failed to resolve commit '{}'", start_commit))?;
let output = capture_git_output(
&[
"--no-pager",
"log",
"--no-color",
"--ignore-missing",
"-n",
num_commits.to_string().as_str(),
"--first-parent",
"--pretty=--,%H,%s,%an,%D%n%N",
"--decorate=full",
format!("--notes={}", temp_ref.ref_name).as_str(),
&resolved_commit,
],
&None,
)
.context(format!("Failed to retrieve commits from {}", start_commit))?;
let mut commits: Vec<CommitWithNotes> = Vec::new();
let mut detected_shallow = false;
let mut current_commit_sha: Option<String> = None;
for l in output.stdout.lines() {
if l.starts_with("--") {
let parts: Vec<&str> = l.splitn(5, ',').collect();
if parts.len() < 5 {
bail!(
"Invalid git log format: expected 5 fields, got {}",
parts.len()
);
}
let sha = parts[1].to_string();
let title = if parts[2].is_empty() {
"[no subject]".to_string()
} else {
parts[2].to_string()
};
let author = if parts[3].is_empty() {
"[unknown]".to_string()
} else {
parts[3].to_string()
};
let decorations = parts[4];
detected_shallow |= decorations.contains("grafted");
current_commit_sha = Some(sha.clone());
commits.push(CommitWithNotes {
sha,
title,
author,
note_lines: Vec::new(),
});
} else if current_commit_sha.is_some() {
if let Some(last) = commits.last_mut() {
last.note_lines.push(l.to_string());
}
}
}
if detected_shallow && commits.len() < num_commits {
bail!("Refusing to continue as commit log depth was limited by shallow clone");
}
Ok(commits)
}
pub fn walk_commits(num_commits: usize) -> Result<Vec<CommitWithNotes>> {
walk_commits_from("HEAD", num_commits)
}
pub fn get_commits_with_notes(notes_ref: &str) -> Result<Vec<String>> {
let output = capture_git_output(&["notes", "--ref", notes_ref, "list"], &None)
.context(format!("Failed to list notes in {}", notes_ref))?;
let commits: Vec<String> = output
.stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
Some(parts[1].to_string())
} else {
None
}
})
.collect();
Ok(commits)
}
pub fn get_commit_details(commit_shas: &[String]) -> Result<Vec<CommitWithNotes>> {
if commit_shas.is_empty() {
return Ok(Vec::new());
}
let mut commits = Vec::new();
for sha in commit_shas {
let output =
capture_git_output(&["show", "--no-patch", "--format=%H%n%s%n%an", sha], &None)
.context(format!("Failed to get commit details for {}", sha))?;
let lines: Vec<&str> = output.stdout.lines().collect();
if lines.len() >= 3 {
commits.push(CommitWithNotes {
sha: lines[0].to_string(),
title: if lines[1].is_empty() {
"[no subject]".to_string()
} else {
lines[1].to_string()
},
author: if lines[2].is_empty() {
"[unknown]".to_string()
} else {
lines[2].to_string()
},
note_lines: Vec::new(), });
}
}
Ok(commits)
}
pub fn get_notes_for_commit(notes_ref: &str, commit_sha: &str) -> Result<Vec<String>> {
let output = capture_git_output(&["notes", "--ref", notes_ref, "show", commit_sha], &None);
match output {
Ok(output) => {
let note_lines: Vec<String> = output.stdout.lines().map(|s| s.to_string()).collect();
Ok(note_lines)
}
Err(_) => {
Ok(Vec::new())
}
}
}
pub fn pull(work_dir: Option<&Path>) -> Result<()> {
pull_internal(work_dir)?;
Ok(())
}
fn pull_internal(work_dir: Option<&Path>) -> Result<(), GitError> {
fetch(work_dir)?;
Ok(())
}
pub fn push(work_dir: Option<&Path>, remote: Option<&str>) -> Result<()> {
let op = || {
raw_push(work_dir, remote)
.map_err(map_git_error_for_backoff)
.map_err(|e: ::backoff::Error<GitError>| match e {
::backoff::Error::Transient { .. } => {
let pull_result = pull_internal(work_dir).map_err(map_git_error_for_backoff);
let pull_succeeded = pull_result.is_ok()
|| matches!(
pull_result,
Err(::backoff::Error::Permanent(
GitError::RefConcurrentModification { .. }
| GitError::RefFailedToLock { .. }
))
);
if pull_succeeded {
e
} else {
pull_result.unwrap_err()
}
}
::backoff::Error::Permanent { .. } => e,
})
};
let backoff = default_backoff();
::backoff::retry_notify(backoff, op, retry_notify).map_err(|e| match e {
::backoff::Error::Permanent(err) => {
anyhow!(err).context("Permanent failure while pushing refs")
}
::backoff::Error::Transient { err, .. } => anyhow!(err).context("Timed out pushing refs"),
})?;
Ok(())
}
pub fn get_write_refs() -> Result<Vec<(String, String)>> {
let refs = get_refs(vec![format!("{REFS_NOTES_WRITE_TARGET_PREFIX}*")])
.map_err(|e| anyhow!("{:?}", e))?;
Ok(refs.into_iter().map(|r| (r.refname, r.oid)).collect())
}
pub fn delete_reference(ref_name: &str) -> Result<()> {
remove_reference(ref_name).map_err(|e| anyhow!("{:?}", e))
}
#[cfg(test)]
mod test {
use super::*;
use crate::test_helpers::{run_git_command, with_isolated_cwd_git};
use std::process::Command;
use httptest::{
http::{header::AUTHORIZATION, Uri},
matchers::{self, request},
responders::status_code,
Expectation, Server,
};
fn add_server_remote(origin_url: Uri, extra_header: &str, dir: &Path) {
let url = origin_url.to_string();
run_git_command(&["remote", "add", "origin", &url], dir);
run_git_command(
&[
"config",
"--add",
format!("http.{}.extraHeader", url).as_str(),
extra_header,
],
dir,
);
}
#[test]
fn test_customheader_pull() {
with_isolated_cwd_git(|git_dir| {
let mut test_server = Server::run();
add_server_remote(test_server.url(""), "AUTHORIZATION: sometoken", git_dir);
test_server.expect(
Expectation::matching(request::headers(matchers::contains((
AUTHORIZATION.as_str(),
"sometoken",
))))
.times(1..)
.respond_with(status_code(200)),
);
let _ = pull(None);
test_server.verify_and_clear();
});
}
#[test]
fn test_customheader_push() {
with_isolated_cwd_git(|git_dir| {
let test_server = Server::run();
add_server_remote(
test_server.url(""),
"AUTHORIZATION: someothertoken",
git_dir,
);
test_server.expect(
Expectation::matching(request::headers(matchers::contains((
AUTHORIZATION.as_str(),
"someothertoken",
))))
.times(1..)
.respond_with(status_code(200)),
);
ensure_symbolic_write_ref_exists().expect("Failed to ensure symbolic write ref exists");
add_note_line_to_head("test note line").expect("Failed to add note line");
let error = push(None, None);
error
.as_ref()
.expect_err("We have no valid git http server setup -> should fail");
dbg!(&error);
});
}
#[test]
fn test_random_suffix() {
for _ in 1..1000 {
let first = random_suffix();
dbg!(&first);
let second = random_suffix();
dbg!(&second);
let all_hex = |s: &String| s.chars().all(|c| c.is_ascii_hexdigit());
assert_ne!(first, second);
assert_eq!(first.len(), 8);
assert_eq!(second.len(), 8);
assert!(all_hex(&first));
assert!(all_hex(&second));
}
}
#[test]
fn test_empty_or_never_pushed_remote_error_for_fetch() {
with_isolated_cwd_git(|git_dir| {
let git_dir_url = format!("file://{}", git_dir.display());
run_git_command(&["remote", "add", "origin", &git_dir_url], git_dir);
std::env::set_var("GIT_TRACE", "true");
let result = super::fetch(Some(git_dir));
match result {
Err(GitError::NoRemoteMeasurements { output }) => {
assert!(
output.stderr.contains(GIT_PERF_REMOTE),
"Expected output to contain {GIT_PERF_REMOTE}. Output: '{}'",
output.stderr
)
}
other => panic!("Expected NoRemoteMeasurements error, got: {:?}", other),
}
});
}
#[test]
fn test_empty_or_never_pushed_remote_error_for_push() {
with_isolated_cwd_git(|git_dir| {
run_git_command(&["remote", "add", "origin", "invalid invalid"], git_dir);
std::env::set_var("GIT_TRACE", "true");
add_note_line_to_head("test line, invalid measurement, does not matter").unwrap();
let result = super::raw_push(Some(git_dir), None);
match result {
Err(GitError::RefFailedToPush { output }) => {
assert!(
output.stderr.contains(GIT_PERF_REMOTE),
"Expected output to contain {GIT_PERF_REMOTE}, got: {}",
output.stderr
)
}
other => panic!("Expected RefFailedToPush error, got: {:?}", other),
}
});
}
#[test]
fn test_new_symbolic_write_ref_returns_valid_ref() {
with_isolated_cwd_git(|_git_dir| {
let result = new_symbolic_write_ref();
assert!(
result.is_ok(),
"Should create symbolic write ref: {:?}",
result
);
let ref_name = result.unwrap();
assert!(
!ref_name.is_empty(),
"Reference name should not be empty, got: '{}'",
ref_name
);
assert!(
ref_name.starts_with(REFS_NOTES_WRITE_TARGET_PREFIX),
"Reference should start with {}, got: {}",
REFS_NOTES_WRITE_TARGET_PREFIX,
ref_name
);
let suffix = ref_name
.strip_prefix(REFS_NOTES_WRITE_TARGET_PREFIX)
.expect("Should have prefix");
assert!(
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_hexdigit()),
"Suffix should be non-empty hex string, got: {}",
suffix
);
});
}
#[test]
fn test_add_and_retrieve_notes() {
with_isolated_cwd_git(|_git_dir| {
let result = add_note_line_to_head("test: 100");
assert!(
result.is_ok(),
"Should add note (requires valid ref from new_symbolic_write_ref): {:?}",
result
);
let result2 = add_note_line_to_head("test: 200");
assert!(result2.is_ok(), "Should add second note: {:?}", result2);
let commits = walk_commits(10);
assert!(commits.is_ok(), "Should walk commits: {:?}", commits);
let commits = commits.unwrap();
assert!(!commits.is_empty(), "Should have commits");
let commit_with_notes = &commits[0];
assert!(
!commit_with_notes.note_lines.is_empty(),
"HEAD should have notes"
);
assert!(
commit_with_notes
.note_lines
.iter()
.any(|n| n.contains("test:")),
"Notes should contain our test data"
);
});
}
#[test]
fn test_walk_commits_shallow_repo_detection() {
use std::env::set_current_dir;
with_isolated_cwd_git(|git_dir| {
for i in 2..=5 {
run_git_command(
&["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
git_dir,
);
}
let shallow_dir = git_dir.join("shallow");
let output = Command::new("git")
.args([
"clone",
"--depth",
"2",
git_dir.to_str().unwrap(),
shallow_dir.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"Shallow clone failed: {}",
String::from_utf8_lossy(&output.stderr)
);
set_current_dir(&shallow_dir).unwrap();
add_note_line_to_head("test: 100").expect("Should add note");
let result = walk_commits(10);
assert!(result.is_ok(), "walk_commits should succeed: {:?}", result);
let commits = result.unwrap();
assert!(
!commits.is_empty(),
"Should have found commits in shallow repo"
);
});
}
#[test]
fn test_walk_commits_normal_repo_not_shallow() {
with_isolated_cwd_git(|git_dir| {
for i in 2..=3 {
run_git_command(
&["commit", "--allow-empty", "-m", &format!("Commit {}", i)],
git_dir,
);
}
add_note_line_to_head("test: 100").expect("Should add note");
let result = walk_commits(10);
assert!(result.is_ok(), "walk_commits should succeed");
let commits = result.unwrap();
assert!(!commits.is_empty(), "Should have found commits");
});
}
}