pub(crate) fn fetch_pr_head(number: u64, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
fetch_pr_heads(&[number], cwd)?
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("fetch_pr_heads returned no SHA for PR #{number}"))
}
pub(crate) fn fetch_pr_heads(
numbers: &[u64],
cwd: Option<&std::path::Path>,
) -> anyhow::Result<Vec<String>> {
if numbers.is_empty() {
return Ok(Vec::new());
}
let refspecs: Vec<String> = numbers
.iter()
.map(|number| format!("+refs/pull/{number}/head:{}", local_pr_head_ref(*number)))
.collect();
let mut fetch_command = std::process::Command::new("git");
fetch_command.args(["fetch", "origin"]).args(&refspecs);
if let Some(cwd) = cwd {
fetch_command.current_dir(cwd);
}
let fetch_output = fetch_command.output()?;
if !fetch_output.status.success() {
anyhow::bail!(
"git fetch origin {} failed: {}",
refspecs.join(" "),
String::from_utf8_lossy(&fetch_output.stderr)
);
}
numbers
.iter()
.map(|number| rev_parse(&local_pr_head_ref(*number), cwd))
.collect()
}
fn local_pr_head_ref(number: u64) -> String {
format!("refs/rinkaku/pull/{number}/head")
}
fn rev_parse(reference: &str, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
let mut command = std::process::Command::new("git");
command.args(["rev-parse", reference]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
anyhow::bail!(
"git rev-parse {reference} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn fetch_branch_head(
name: &str,
cwd: Option<&std::path::Path>,
) -> anyhow::Result<String> {
run_git_fetch(name, cwd)
}
pub(crate) fn resolve_pr_base_sha(
base_ref_oid: &str,
mut object_exists: impl FnMut(&str) -> bool,
mut fetch_base_branch: impl FnMut() -> anyhow::Result<String>,
mut fetch_oid: impl FnMut(&str) -> anyhow::Result<()>,
) -> anyhow::Result<(String, bool)> {
if object_exists(base_ref_oid) {
return Ok((base_ref_oid.to_string(), false));
}
let branch_tip = match fetch_base_branch() {
Ok(tip) => {
if object_exists(base_ref_oid) {
return Ok((base_ref_oid.to_string(), false));
}
Some(tip)
}
Err(source) => {
log::warn!(
"fetching the base branch failed, continuing the base-commit resolution \
cascade: {source}"
);
None
}
};
if fetch_oid(base_ref_oid).is_ok() && object_exists(base_ref_oid) {
return Ok((base_ref_oid.to_string(), false));
}
let branch_tip = match branch_tip {
Some(tip) => tip,
None => fetch_base_branch()?,
};
Ok((branch_tip, true))
}
pub(crate) fn object_exists_locally(cwd: Option<&std::path::Path>, oid: &str) -> bool {
let mut command = std::process::Command::new("git");
command.args(["cat-file", "-e", &format!("{oid}^{{commit}}")]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command.output().is_ok_and(|output| output.status.success())
}
pub(crate) fn fetch_oid(cwd: Option<&std::path::Path>, oid: &str) -> anyhow::Result<()> {
let mut command = std::process::Command::new("git");
command.args(["fetch", "origin", oid]);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command.output()?;
if !output.status.success() {
anyhow::bail!(
"git fetch origin {oid} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
fn run_git_fetch(refspec: &str, cwd: Option<&std::path::Path>) -> anyhow::Result<String> {
let mut fetch_command = std::process::Command::new("git");
fetch_command.args(["fetch", "origin", refspec]);
if let Some(cwd) = cwd {
fetch_command.current_dir(cwd);
}
let fetch_output = fetch_command.output()?;
if !fetch_output.status.success() {
anyhow::bail!(
"git fetch origin {refspec} failed: {}",
String::from_utf8_lossy(&fetch_output.stderr)
);
}
let mut rev_parse_command = std::process::Command::new("git");
rev_parse_command.args(["rev-parse", "FETCH_HEAD"]);
if let Some(cwd) = cwd {
rev_parse_command.current_dir(cwd);
}
let rev_parse_output = rev_parse_command.output()?;
if !rev_parse_output.status.success() {
anyhow::bail!(
"git rev-parse FETCH_HEAD failed after fetching {refspec}: {}",
String::from_utf8_lossy(&rev_parse_output.stderr)
);
}
Ok(String::from_utf8(rev_parse_output.stdout)?
.trim()
.to_string())
}
#[cfg(test)]
mod tests {
mod fetch_pr_heads_tests {
use crate::github::base_sha::fetch_pr_heads;
use crate::test_util::{init_repo_with_committed_file, run_git};
use pretty_assertions::assert_eq;
fn head_sha(dir: &std::path::Path) -> String {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.expect("git rev-parse");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn should_return_each_pr_head_in_input_order_when_fetched_in_one_call() {
let remote = tempfile::TempDir::new().expect("remote dir");
init_repo_with_committed_file(remote.path(), "fn one() {}\n");
let first = head_sha(remote.path());
run_git(remote.path(), &["update-ref", "refs/pull/7/head", &first]);
std::fs::write(remote.path().join("src/lib.rs"), "fn two() {}\n").expect("write");
run_git(remote.path(), &["commit", "-am", "second"]);
let second = head_sha(remote.path());
run_git(remote.path(), &["update-ref", "refs/pull/9/head", &second]);
let clone = tempfile::TempDir::new().expect("clone dir");
let clone_dir = clone.path().join("repo");
run_git(
clone.path(),
&[
"clone",
"--quiet",
remote.path().to_str().expect("utf8 path"),
clone_dir.to_str().expect("utf8 path"),
],
);
let actual = fetch_pr_heads(&[9, 7], Some(&clone_dir)).expect("fetch");
assert_eq!(vec![second, first], actual);
}
#[test]
fn should_return_empty_without_fetching_when_no_numbers_are_given() {
let dir = tempfile::TempDir::new().expect("dir");
let actual = fetch_pr_heads(&[], Some(dir.path())).expect("fetch");
assert_eq!(Vec::<String>::new(), actual);
}
}
mod fetch_pr_head_tests {
use crate::github::base_sha::fetch_pr_head;
use crate::test_util::{init_repo_with_committed_file, run_git};
use pretty_assertions::assert_eq;
fn head_sha(dir: &std::path::Path) -> String {
let output = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.expect("git rev-parse");
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
#[test]
fn should_return_the_pr_head_when_fetched_into_a_named_ref() {
let remote = tempfile::TempDir::new().expect("remote dir");
init_repo_with_committed_file(remote.path(), "fn one() {}\n");
let expected = head_sha(remote.path());
run_git(
remote.path(),
&["update-ref", "refs/pull/7/head", &expected],
);
let clone = tempfile::TempDir::new().expect("clone dir");
let clone_dir = clone.path().join("repo");
run_git(
clone.path(),
&[
"clone",
"--quiet",
remote.path().to_str().expect("utf8 path"),
clone_dir.to_str().expect("utf8 path"),
],
);
let actual = fetch_pr_head(7, Some(&clone_dir)).expect("fetch");
assert_eq!(expected, actual);
let ref_output = std::process::Command::new("git")
.args(["rev-parse", "refs/rinkaku/pull/7/head"])
.current_dir(&clone_dir)
.output()
.expect("git rev-parse");
assert_eq!(expected, String::from_utf8_lossy(&ref_output.stdout).trim());
}
}
use super::*;
mod resolve_pr_base_sha_tests {
use super::*;
use pretty_assertions::assert_eq;
use std::cell::RefCell;
#[test]
fn should_return_base_ref_oid_when_it_already_exists_locally() {
let fetch_base_branch_calls = RefCell::new(0);
let fetch_oid_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| true,
|| {
*fetch_base_branch_calls.borrow_mut() += 1;
Ok("branch-tip-sha".to_string())
},
|_oid| {
*fetch_oid_calls.borrow_mut() += 1;
Ok(())
},
)
.expect("should resolve without error");
assert_eq!(("base789".to_string(), false), actual);
assert_eq!(0, *fetch_base_branch_calls.borrow());
assert_eq!(0, *fetch_oid_calls.borrow());
}
#[test]
fn should_return_base_ref_oid_when_fetching_the_base_branch_makes_it_available() {
let exists_calls = RefCell::new(0);
let object_exists = |_oid: &str| {
let mut calls = exists_calls.borrow_mut();
*calls += 1;
*calls > 1
};
let actual = resolve_pr_base_sha(
"base789",
object_exists,
|| Ok("branch-tip-sha".to_string()),
|_oid| panic!("fetch_oid must not be called when the base branch fetch sufficed"),
)
.expect("should resolve without error");
assert_eq!(("base789".to_string(), false), actual);
}
#[test]
fn should_return_base_ref_oid_when_fetching_the_oid_directly_makes_it_available() {
let exists_calls = RefCell::new(0);
let object_exists = |_oid: &str| {
let mut calls = exists_calls.borrow_mut();
*calls += 1;
*calls > 2
};
let actual = resolve_pr_base_sha(
"base789",
object_exists,
|| Ok("branch-tip-sha".to_string()),
|_oid| Ok(()),
)
.expect("should resolve without error");
assert_eq!(("base789".to_string(), false), actual);
}
#[test]
fn should_fall_back_to_branch_tip_when_the_oid_is_unreachable_by_any_means() {
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| Ok("branch-tip-sha".to_string()),
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
}
#[test]
fn should_fall_back_to_branch_tip_when_fetch_oid_succeeds_but_object_still_missing() {
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| Ok("branch-tip-sha".to_string()),
|_oid| Ok(()),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
}
#[test]
fn should_fall_through_to_fetch_oid_when_fetching_the_base_branch_fails() {
let exists_calls = RefCell::new(0);
let object_exists = |_oid: &str| {
let mut calls = exists_calls.borrow_mut();
*calls += 1;
*calls > 1
};
let actual = resolve_pr_base_sha(
"base789",
object_exists,
|| anyhow::bail!("simulated: base branch was deleted"),
|_oid| Ok(()),
)
.expect("a step-2 failure must not abort the cascade");
assert_eq!(("base789".to_string(), false), actual);
}
#[test]
fn should_fetch_branch_tip_for_fallback_when_step_two_failed_and_fetch_oid_also_fails() {
let fetch_base_branch_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| {
let mut calls = fetch_base_branch_calls.borrow_mut();
*calls += 1;
if *calls == 1 {
anyhow::bail!("simulated: base branch was deleted")
} else {
Ok("branch-tip-sha".to_string())
}
},
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
assert_eq!(2, *fetch_base_branch_calls.borrow());
}
#[test]
fn should_reuse_step_two_tip_for_fallback_without_refetching() {
let fetch_base_branch_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| {
*fetch_base_branch_calls.borrow_mut() += 1;
Ok("branch-tip-sha".to_string())
},
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
)
.expect("should fall back rather than error");
assert_eq!(("branch-tip-sha".to_string(), true), actual);
assert_eq!(
1,
*fetch_base_branch_calls.borrow(),
"fetch_base_branch must only be called once (by step 2); step 4 must reuse its \
result instead of fetching the base branch again"
);
}
#[test]
fn should_propagate_error_when_the_branch_tip_fallback_itself_fails() {
let fetch_base_branch_calls = RefCell::new(0);
let actual = resolve_pr_base_sha(
"base789",
|_oid| false,
|| {
let mut calls = fetch_base_branch_calls.borrow_mut();
*calls += 1;
anyhow::bail!("simulated: git fetch origin main failed")
},
|_oid| anyhow::bail!("simulated: base789 not found on the remote"),
);
assert!(actual.is_err());
}
}
}