use std::collections::HashSet;
use std::path::Path;
use std::sync::atomic::AtomicBool;
use gix::bstr::ByteSlice;
#[derive(Debug, Clone)]
pub(crate) enum FetchError {
Open(String),
Connect(String),
Receive(String),
Prune(String),
}
impl std::fmt::Display for FetchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FetchError::Open(message) => write!(f, "failed to open git repository: {message}"),
FetchError::Connect(message) => write!(f, "failed to connect to remote: {message}"),
FetchError::Receive(message) => write!(f, "failed to fetch: {message}"),
FetchError::Prune(message) => {
write!(f, "failed to prune a stale remote-tracking ref: {message}")
}
}
}
}
#[allow(clippy::result_large_err)]
fn refuse_credentials(
_action: gix::credentials::helper::Action,
) -> gix::credentials::protocol::Result {
Ok(None)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AdvertisedDefaultBranch {
Branch(String),
Unborn,
}
#[derive(Debug)]
pub(crate) struct FetchOutcome {
#[cfg_attr(
not(test),
expect(dead_code, reason = "the prune count is a test observation only")
)]
pub(crate) pruned: usize,
pub(crate) advertised_default_branch: Option<AdvertisedDefaultBranch>,
}
fn head_refspec() -> gix::refspec::RefSpec {
gix::refspec::parse("HEAD".into(), gix::refspec::parse::Operation::Fetch)
.expect("\"HEAD\" is a valid refspec")
.to_owned()
}
fn head_ref_map_options() -> gix::remote::ref_map::Options {
gix::remote::ref_map::Options {
extra_refspecs: vec![head_refspec()],
..Default::default()
}
}
fn advertised_default_branch(
remote_name: &str,
ref_map: &gix::remote::fetch::RefMap,
) -> Option<AdvertisedDefaultBranch> {
ref_map
.remote_refs
.iter()
.find_map(|reference| match reference {
gix::protocol::handshake::Ref::Symbolic {
full_ref_name,
target,
..
} if full_ref_name == "HEAD" => {
let branch = target
.strip_prefix(b"refs/heads/")
.unwrap_or(target.as_slice());
Some(AdvertisedDefaultBranch::Branch(format!(
"{remote_name}/{}",
branch.to_str_lossy()
)))
}
gix::protocol::handshake::Ref::Unborn { full_ref_name, .. }
if full_ref_name == "HEAD" =>
{
Some(AdvertisedDefaultBranch::Unborn)
}
_ => None,
})
}
pub(crate) fn probe_remote_head(
path: &Path,
) -> Result<Option<AdvertisedDefaultBranch>, FetchError> {
let repo = gix::open(path).map_err(|error| FetchError::Open(error.to_string()))?;
let remote_name = match repo.remote_default_name(gix::remote::Direction::Fetch) {
Some(name) => name,
None => return Ok(None),
};
let remote = repo
.find_remote(&*remote_name)
.map_err(|error| FetchError::Connect(error.to_string()))?;
let connection = remote
.connect(gix::remote::Direction::Fetch)
.map_err(|error| FetchError::Connect(error.to_string()))?
.with_credentials(refuse_credentials);
let (ref_map, _handshake) = connection
.ref_map(gix::progress::Discard, head_ref_map_options())
.map_err(|error| FetchError::Connect(error.to_string()))?;
Ok(advertised_default_branch(
&remote_name.to_string(),
&ref_map,
))
}
pub(crate) fn fetch_and_prune(
path: &Path,
cancel: &AtomicBool,
) -> Result<FetchOutcome, FetchError> {
let repo = gix::open(path).map_err(|error| FetchError::Open(error.to_string()))?;
let remote_name = match repo.remote_default_name(gix::remote::Direction::Fetch) {
Some(name) => name,
None => {
return Ok(FetchOutcome {
pruned: 0,
advertised_default_branch: None,
});
}
};
let remote = repo
.find_remote(&*remote_name)
.map_err(|error| FetchError::Connect(error.to_string()))?;
let connection = remote
.connect(gix::remote::Direction::Fetch)
.map_err(|error| FetchError::Connect(error.to_string()))?
.with_credentials(refuse_credentials);
let prepare = connection
.prepare_fetch(gix::progress::Discard, head_ref_map_options())
.map_err(|error| FetchError::Connect(error.to_string()))?;
let outcome = prepare
.receive(gix::progress::Discard, cancel)
.map_err(|error| FetchError::Receive(error.to_string()))?;
let advertised_default_branch =
advertised_default_branch(&remote_name.to_string(), &outcome.ref_map);
let pruned =
prune_stale_remote_tracking_refs(&repo, &remote_name.to_string(), &outcome.ref_map)?;
Ok(FetchOutcome {
pruned,
advertised_default_branch,
})
}
fn prune_stale_remote_tracking_refs(
repo: &gix::Repository,
remote_name: &str,
ref_map: &gix::remote::fetch::RefMap,
) -> Result<usize, FetchError> {
let still_mapped: HashSet<Vec<u8>> = ref_map
.mappings
.iter()
.filter_map(|mapping| mapping.local.as_ref())
.map(|name| name.to_vec())
.collect();
let prefix = format!("refs/remotes/{remote_name}/");
let platform = repo
.references()
.map_err(|error| FetchError::Prune(error.to_string()))?;
let candidates = platform
.prefixed(prefix.as_bytes())
.map_err(|error| FetchError::Prune(error.to_string()))?;
let mut pruned = 0;
for candidate in candidates {
let reference = candidate.map_err(|error| FetchError::Prune(error.to_string()))?;
let name = reference.name().as_bstr();
if name.ends_with(b"/HEAD") {
continue;
}
if still_mapped.contains(name.as_ref() as &[u8]) {
continue;
}
reference
.delete()
.map_err(|error| FetchError::Prune(error.to_string()))?;
pruned += 1;
}
Ok(pruned)
}
pub(crate) fn run_bounded<T, F>(items: Vec<T>, concurrency: usize, job: F)
where
T: Send,
F: Fn(T) + Sync + Send,
{
use rayon::iter::{IntoParallelIterator, ParallelIterator};
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(concurrency.max(1))
.build()
.expect("build the periodic fetch's own bounded pool");
pool.install(|| {
items.into_par_iter().for_each(job);
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{commit_file, git, push_new_commit, remote_and_clone};
use crossbeam_channel::unbounded;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use std::time::Duration;
fn never_cancelled() -> AtomicBool {
AtomicBool::new(false)
}
#[test]
fn a_fetch_deletes_a_remote_tracking_ref_whose_upstream_branch_is_gone() {
let (remote, clone) = remote_and_clone();
git(remote.path(), &["branch", "topic"]);
let seed_fetch = fetch_and_prune(clone.path(), &never_cancelled());
assert!(seed_fetch.is_ok(), "seed fetch failed: {seed_fetch:?}");
assert!(
std::process::Command::new("git")
.arg("-C")
.arg(clone.path())
.args(["rev-parse", "--verify", "refs/remotes/origin/topic"])
.status()
.expect("run git rev-parse")
.success(),
"the seed fetch must have created the remote-tracking ref this test then deletes upstream"
);
git(remote.path(), &["branch", "-D", "topic"]);
let outcome = fetch_and_prune(clone.path(), &never_cancelled()).expect("fetch and prune");
assert_eq!(
outcome.pruned, 1,
"exactly the one stale ref must be reported pruned"
);
assert!(
!std::process::Command::new("git")
.arg("-C")
.arg(clone.path())
.args(["rev-parse", "--verify", "refs/remotes/origin/topic"])
.status()
.expect("run git rev-parse")
.success(),
"a pruned remote-tracking ref must no longer resolve"
);
}
#[test]
fn a_fetch_never_deletes_the_remote_head_symbolic_ref() {
let (_remote, clone) = remote_and_clone();
assert!(
std::process::Command::new("git")
.arg("-C")
.arg(clone.path())
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.status()
.expect("run git symbolic-ref")
.success(),
"a real `git clone` must have written origin/HEAD for this test's premise to hold"
);
fetch_and_prune(clone.path(), &never_cancelled()).expect("fetch and prune");
assert!(
std::process::Command::new("git")
.arg("-C")
.arg(clone.path())
.args(["symbolic-ref", "refs/remotes/origin/HEAD"])
.status()
.expect("run git symbolic-ref")
.success(),
"origin/HEAD must survive a fetch's own prune"
);
}
#[test]
fn a_fetch_transfers_new_commits_so_a_behind_count_can_move() {
let (remote, clone) = remote_and_clone();
let before = crate::git::open_thread_safe(clone.path())
.expect("open the clone")
.to_thread_local();
let before_head = before.head_id().expect("clone has a HEAD");
push_new_commit(remote.path(), "second.txt", "more\n");
fetch_and_prune(clone.path(), &never_cancelled()).expect("fetch and prune");
let remote_tracking = std::process::Command::new("git")
.arg("-C")
.arg(clone.path())
.args(["rev-parse", "refs/remotes/origin/main"])
.output()
.expect("run git rev-parse");
assert!(remote_tracking.status.success());
let remote_tracking_sha = String::from_utf8(remote_tracking.stdout)
.expect("utf8 sha")
.trim()
.to_string();
assert_ne!(
remote_tracking_sha,
before_head.to_string(),
"the fetch must have moved the remote-tracking ref past the clone's own HEAD, \
which is what lets a behind count change"
);
}
fn working_tree_files(
path: &std::path::Path,
) -> std::collections::BTreeMap<std::path::PathBuf, Vec<u8>> {
fn walk(
dir: &std::path::Path,
out: &mut std::collections::BTreeMap<std::path::PathBuf, Vec<u8>>,
) {
for entry in std::fs::read_dir(dir).expect("read a working-tree dir") {
let entry = entry.expect("read a dir entry");
let path = entry.path();
if path.file_name().is_some_and(|name| name == ".git") {
continue;
}
if path.is_dir() {
walk(&path, out);
} else {
out.insert(
path.clone(),
std::fs::read(&path).expect("read a working-tree file"),
);
}
}
}
let mut out = std::collections::BTreeMap::new();
walk(path, &mut out);
out
}
#[test]
fn a_fetch_leaves_the_working_tree_byte_identical() {
let (remote, clone) = remote_and_clone();
push_new_commit(remote.path(), "second.txt", "more\n");
let before = working_tree_files(clone.path());
fetch_and_prune(clone.path(), &never_cancelled()).expect("fetch and prune");
let after = working_tree_files(clone.path());
assert_eq!(
before, after,
"a fetch must never write, remove or change a working-tree file"
);
}
#[test]
fn a_fetch_against_a_remote_needing_a_credential_helper_fails_rather_than_prompts() {
let clone = tempfile::tempdir().expect("temp dir");
git(clone.path(), &["init", "--initial-branch=main"]);
commit_file(clone.path(), "README.md", "seed\n");
git(
clone.path(),
&[
"remote",
"add",
"origin",
"https://askpass-required.invalid/example.git",
],
);
let (tx, rx) = mpsc::channel();
let path = clone.path().to_path_buf();
std::thread::spawn(move || {
let result = fetch_and_prune(&path, &never_cancelled());
let _ = tx.send(result);
});
let result = rx
.recv_timeout(Duration::from_secs(20))
.expect("a fetch that fails closed must return, never hang, on a credential prompt");
assert!(
result.is_err(),
"a remote this sandbox cannot reach must fail rather than succeed"
);
}
#[test]
fn run_bounded_never_runs_more_than_concurrency_jobs_at_once() {
let concurrency = 2;
let items = 6;
let current = AtomicUsize::new(0);
let peak = AtomicUsize::new(0);
let (started_tx, started_rx) = unbounded::<()>();
let (release_tx, release_rx) = unbounded::<()>();
std::thread::scope(|scope| {
scope.spawn(|| {
run_bounded(Vec::from_iter(0..items), concurrency, |_| {
let now = current.fetch_add(1, Ordering::SeqCst) + 1;
peak.fetch_max(now, Ordering::SeqCst);
started_tx.send(()).expect("report this job started");
release_rx
.recv_timeout(Duration::from_secs(10))
.expect("released by the test");
current.fetch_sub(1, Ordering::SeqCst);
});
});
for _ in 0..concurrency {
started_rx
.recv_timeout(Duration::from_secs(10))
.expect("a job to start concurrently with the others");
}
assert!(
started_rx.recv_timeout(Duration::from_millis(300)).is_err(),
"a job beyond the configured concurrency must not have started yet"
);
for _ in 0..items {
release_tx.send(()).expect("release a job");
}
});
assert!(
peak.load(Ordering::SeqCst) <= concurrency,
"observed concurrency {} must never exceed the configured bound {concurrency}",
peak.load(Ordering::SeqCst)
);
assert_eq!(
peak.load(Ordering::SeqCst),
concurrency,
"the bound must actually be reached, not merely never exceeded"
);
}
fn set_remote_head(path: &std::path::Path, branch: &str) {
git(
path,
&["symbolic-ref", "HEAD", &format!("refs/heads/{branch}")],
);
}
#[test]
fn probe_remote_head_reports_the_remotes_own_current_symbolic_answer_not_the_clones_cache() {
let (remote, clone) = remote_and_clone();
git(remote.path(), &["branch", "trunk"]);
set_remote_head(remote.path(), "trunk");
let answer = probe_remote_head(clone.path()).expect("probe the remote's head");
assert_eq!(
answer,
Some(AdvertisedDefaultBranch::Branch("origin/trunk".to_string())),
"the clone's own cached origin/HEAD still names main; this answer can only have \
come from the handshake actually asking the remote for its current HEAD"
);
}
#[test]
fn probe_remote_head_reports_unborn_for_a_remote_with_no_commits_yet() {
let remote = tempfile::tempdir().expect("temp dir");
crate::test_support::init_bare(remote.path());
let clone = tempfile::tempdir().expect("temp dir");
git(clone.path(), &["init", "--initial-branch=main"]);
crate::test_support::set_identity(clone.path());
git(
clone.path(),
&[
"remote",
"add",
"origin",
&remote.path().display().to_string(),
],
);
let answer = probe_remote_head(clone.path()).expect("probe the remote's head");
assert_eq!(
answer,
Some(AdvertisedDefaultBranch::Unborn),
"a remote with no commits at all must report Unborn, not an error"
);
}
#[test]
fn probe_remote_head_against_a_remote_needing_a_credential_helper_fails_rather_than_prompts() {
let clone = tempfile::tempdir().expect("temp dir");
git(clone.path(), &["init", "--initial-branch=main"]);
commit_file(clone.path(), "README.md", "seed\n");
git(
clone.path(),
&[
"remote",
"add",
"origin",
"https://askpass-required.invalid/example.git",
],
);
let (tx, rx) = mpsc::channel();
let path = clone.path().to_path_buf();
std::thread::spawn(move || {
let result = probe_remote_head(&path);
let _ = tx.send(result);
});
let result = rx
.recv_timeout(Duration::from_secs(20))
.expect("a lookup that fails closed must return, never hang, on a credential prompt");
assert!(
result.is_err(),
"a remote this sandbox cannot reach must fail rather than succeed"
);
}
#[test]
fn probe_remote_head_never_writes_the_answer_back_to_the_local_origin_head_file() {
let (remote, clone) = remote_and_clone();
git(remote.path(), &["branch", "trunk"]);
set_remote_head(remote.path(), "trunk");
let head_path = clone
.path()
.join(".git")
.join("refs")
.join("remotes")
.join("origin")
.join("HEAD");
let before = std::fs::read(&head_path).expect("read origin/HEAD before the lookup");
let answer = probe_remote_head(clone.path()).expect("probe the remote's head");
assert_eq!(
answer,
Some(AdvertisedDefaultBranch::Branch("origin/trunk".to_string())),
"the lookup must still have reached the remote's own differing answer"
);
let after = std::fs::read(&head_path).expect("read origin/HEAD after the lookup");
assert_eq!(
before, after,
"a lookup that landed a differing network answer must never write it back to the \
local origin/HEAD file"
);
}
}