use crate::local::LocalDeepenPlan;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use sley_config::GitConfig;
use sley_config::remotes::{remote_config_values, remote_exists, rewrite_url_with_config};
use sley_core::{GitError, ObjectFormat, ObjectId, Result};
use sley_odb::{
FileObjectDatabase, collect_reachable_object_ids, collect_reachable_object_ids_excluding,
};
#[cfg(feature = "http")]
use sley_protocol::ProtocolVersion;
use sley_protocol::{
FetchHeadRecord, FetchRefUpdate, RefAdvertisement, RefSpec, encode_fetch_head,
fetch_ref_updates_to_fetch_head, parse_refspec, plan_fetch_ref_updates, refspec_map_source,
};
use sley_refs::{BundleRefUpdate, FileRefStore, Ref, RefTarget};
use sley_transport::RemoteUrl;
use crate::{CredentialProvider, ProgressSink};
pub enum FetchSource {
Http(RemoteUrl),
Ssh(RemoteUrl),
Git(RemoteUrl),
Local {
git_dir: PathBuf,
common_git_dir: PathBuf,
},
}
#[derive(Debug, Clone)]
pub struct FetchOptions {
pub quiet: bool,
pub auto_follow_tags: bool,
pub fetch_all_tags: bool,
pub prune: bool,
pub dry_run: bool,
pub append: bool,
pub write_fetch_head: bool,
pub tag_option_explicit: bool,
pub prune_option_explicit: bool,
pub depth: Option<u32>,
pub merge_src: Option<String>,
pub filter: Option<sley_odb::PackObjectFilter>,
pub cloning: bool,
pub update_shallow: bool,
pub deepen_relative: bool,
pub deepen_since: Option<i64>,
pub deepen_not: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrunedRef {
pub branch: String,
pub refname: String,
}
#[derive(Debug, Clone, Default)]
pub struct FetchOutcome {
pub ref_updates: Vec<FetchRefUpdate>,
pub pruned: Vec<PrunedRef>,
pub head_symref: Option<String>,
pub wrote_fetch_head: bool,
}
pub struct FetchRequest<'a> {
pub git_dir: &'a Path,
pub format: ObjectFormat,
pub config: &'a GitConfig,
pub remote_name: &'a str,
pub source: &'a FetchSource,
pub refspecs: &'a [String],
pub options: &'a FetchOptions,
}
pub struct FetchServices<'a> {
pub credentials: &'a mut dyn CredentialProvider,
pub progress: &'a mut dyn ProgressSink,
}
pub fn fetch(request: FetchRequest<'_>, services: FetchServices<'_>) -> Result<FetchOutcome> {
let mut options = request.options.clone();
apply_configured_remote_tag_option(request.config, request.remote_name, &mut options);
apply_configured_fetch_prune_option(request.config, request.remote_name, &mut options);
let promisor_remote = request
.config
.get_bool("remote", Some(request.remote_name), "promisor")
.unwrap_or(false);
let configured_refspecs = if request.refspecs.is_empty() {
remote_config_values(request.config, request.remote_name, "fetch")
} else {
Vec::new()
};
let default_head_fetch = request.refspecs.is_empty() && configured_refspecs.is_empty();
let configured_remote_fetch = request.refspecs.is_empty() && !configured_refspecs.is_empty();
let fetch_head_source = fetch_head_source_description(request.config, request.remote_name);
let effective_refspecs = fetch_refspecs_for_source(
configured_refspecs,
request.refspecs,
options.fetch_all_tags,
);
let parsed_refspecs = effective_refspecs
.iter()
.map(|refspec| parse_refspec(refspec))
.collect::<Result<Vec<_>>>()?;
let store = FileRefStore::new(request.git_dir, request.format);
let mut outcome = FetchOutcome::default();
let advertisements = match request.source {
#[cfg(not(feature = "http"))]
FetchSource::Http(_) => {
return Err(GitError::Unsupported(
"HTTP transport is not enabled in this build".into(),
));
}
#[cfg(feature = "http")]
FetchSource::Http(remote) => {
let client = crate::http::new_http_client();
let discovered = crate::http::http_service_advertisements(
&client,
remote,
request.format,
sley_protocol::GitService::UploadPack,
services.credentials,
)?;
let advertisements = discovered.set.refs;
let features = advertisements
.first()
.map(|advertisement| {
sley_protocol::parse_upload_pack_features(&advertisement.capabilities)
})
.transpose()?
.unwrap_or_default();
outcome.head_symref = head_symref_from_features(&features.symrefs);
let mut updates = plan_and_adjust_updates(FetchPlanInput {
advertisements: &advertisements,
refspecs: &parsed_refspecs,
options: &options,
store: &store,
reachable: None,
deepen_excluded: None,
format: request.format,
configured_remote_fetch,
})?;
let wants = updates.iter().map(|update| update.oid).collect();
let existing_shallow =
shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
let pack_request = crate::http::HttpFetchPackRequest {
client: &client,
git_dir: request.git_dir,
format: request.format,
remote,
wants,
shallow: existing_shallow,
deepen: options.depth,
promisor: promisor_remote,
};
let shallow_info = if discovered.set.protocol == ProtocolVersion::V2 {
let handshake = discovered.handshake.as_ref().ok_or_else(|| {
GitError::InvalidFormat(
"protocol v2 HTTP fetch requires a v2 handshake from service discovery"
.into(),
)
})?;
crate::http::install_fetch_pack_via_http_protocol_v2_fetch(
pack_request,
handshake,
services.credentials,
)?
} else {
crate::http::install_fetch_pack_via_http_upload_pack(
pack_request,
services.credentials,
)?
};
if !options.dry_run {
crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
}
finalize_fetch(
FetchFinalize {
git_dir: request.git_dir,
store: &store,
options: &options,
remote_name: request.remote_name,
fetch_head_source: &fetch_head_source,
default_head_fetch,
},
&mut updates,
&mut outcome,
)?;
advertisements
}
FetchSource::Ssh(remote) => {
let (advertisements, features) =
crate::ssh::ssh_upload_pack_advertisements(remote, request.format)?;
outcome.head_symref = head_symref_from_features(&features.symrefs);
let mut updates = plan_and_adjust_updates(FetchPlanInput {
advertisements: &advertisements,
refspecs: &parsed_refspecs,
options: &options,
store: &store,
reachable: None,
deepen_excluded: None,
format: request.format,
configured_remote_fetch,
})?;
let wants = updates.iter().map(|update| update.oid).collect();
let existing_shallow =
shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
let shallow_info = crate::ssh::install_fetch_pack_via_ssh_upload_pack(
crate::ssh::SshFetchPackRequest {
git_dir: request.git_dir,
format: request.format,
remote,
features: &features,
wants,
shallow: existing_shallow,
deepen: options.depth,
promisor: promisor_remote,
},
)?;
if !options.dry_run {
crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
}
finalize_fetch(
FetchFinalize {
git_dir: request.git_dir,
store: &store,
options: &options,
remote_name: request.remote_name,
fetch_head_source: &fetch_head_source,
default_head_fetch,
},
&mut updates,
&mut outcome,
)?;
advertisements
}
FetchSource::Git(remote) => {
let (advertisements, features) =
crate::git::git_upload_pack_advertisements(remote, request.format)?;
outcome.head_symref = head_symref_from_features(&features.symrefs);
let mut updates = plan_and_adjust_updates(FetchPlanInput {
advertisements: &advertisements,
refspecs: &parsed_refspecs,
options: &options,
store: &store,
reachable: None,
deepen_excluded: None,
format: request.format,
configured_remote_fetch,
})?;
let wants = updates.iter().map(|update| update.oid).collect();
let existing_shallow =
shallow_boundary_for_request(request.git_dir, request.format, options.depth)?;
let shallow_info = crate::git::install_fetch_pack_via_git_upload_pack(
crate::git::GitFetchPackRequest {
git_dir: request.git_dir,
format: request.format,
remote,
features: &features,
wants,
shallow: existing_shallow,
deepen: options.depth,
promisor: promisor_remote,
},
)?;
if !options.dry_run {
crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
}
finalize_fetch(
FetchFinalize {
git_dir: request.git_dir,
store: &store,
options: &options,
remote_name: request.remote_name,
fetch_head_source: &fetch_head_source,
default_head_fetch,
},
&mut updates,
&mut outcome,
)?;
advertisements
}
FetchSource::Local {
git_dir: remote_git_dir,
common_git_dir: remote_common_git_dir,
} => {
let remote_format = crate::object_format_for_git_dir(remote_common_git_dir)?;
if remote_format != request.format {
return Err(GitError::InvalidObjectId(format!(
"remote repository uses {}, local repository uses {}",
remote_format.name(),
request.format.name()
)));
}
let advertisements =
crate::local::local_fetch_advertisements(remote_git_dir, request.format)?;
let remote_db = FileObjectDatabase::from_git_dir(remote_common_git_dir, request.format);
let remote_shallow =
crate::shallow::read_shallow(remote_common_git_dir, request.format)?;
let explicit_deepen = options.depth.is_some()
|| options.deepen_since.is_some()
|| !options.deepen_not.is_empty();
let implicit_deepen = !explicit_deepen && !remote_shallow.is_empty();
let mut deepen_not_oids = Vec::new();
for name in &options.deepen_not {
let resolved = advertisements.iter().find(|advertisement| {
advertisement.name == *name
|| advertisement.name == format!("refs/tags/{name}")
|| advertisement.name == format!("refs/heads/{name}")
|| advertisement.name == format!("refs/{name}")
});
match resolved {
Some(advertisement) => deepen_not_oids.push(advertisement.oid),
None => {
return Err(GitError::Command(format!(
"git upload-pack: deepen-not is not a ref: {name}"
)));
}
}
}
let plan_deepen = |heads: &[ObjectId]| -> Result<Option<LocalDeepenPlan>> {
if !explicit_deepen && !implicit_deepen {
return Ok(None);
}
let client_shallow = crate::shallow::read_shallow(request.git_dir, request.format)?;
if options.deepen_since.is_some() || !deepen_not_oids.is_empty() {
return Ok(Some(crate::local::compute_local_deepen_by_rev_list(
&remote_db,
request.format,
heads,
client_shallow,
options.deepen_since,
&deepen_not_oids,
)?));
}
let depth = options.depth.unwrap_or(crate::local::INFINITE_DEPTH);
Ok(Some(crate::local::compute_local_deepen(
&remote_db,
request.format,
heads,
client_shallow,
depth,
options.deepen_relative,
)?))
};
let primary_heads = {
let primary = plan_fetch_ref_updates(
&advertisements,
&parsed_refspecs,
options.auto_follow_tags,
)?;
let mut seen = HashSet::new();
let mut heads = Vec::new();
for update in &primary {
if seen.insert(update.oid) {
heads.push(update.oid);
}
}
heads
};
let mut deepen_plan = plan_deepen(&primary_heads)?;
let mut updates = plan_and_adjust_updates(FetchPlanInput {
advertisements: &advertisements,
refspecs: &parsed_refspecs,
options: &options,
store: &store,
reachable: Some((&remote_db, &advertisements)),
deepen_excluded: deepen_plan.as_ref().map(|plan| &plan.excluded),
format: request.format,
configured_remote_fetch,
})?;
if implicit_deepen && !options.cloning && !options.update_shallow {
let client_shallow: HashSet<ObjectId> =
crate::shallow::read_shallow(request.git_dir, request.format)?
.into_iter()
.collect();
let new_points: HashSet<ObjectId> = deepen_plan
.as_ref()
.map(|plan| {
plan.shallow_info
.iter()
.filter_map(|entry| match entry {
sley_protocol::ProtocolV2FetchShallowInfo::Shallow(oid)
if !client_shallow.contains(oid) =>
{
Some(*oid)
}
_ => None,
})
.collect()
})
.unwrap_or_default();
if !new_points.is_empty() {
let mut dirty_cache: HashMap<ObjectId, bool> = HashMap::new();
let mut dirty = |tip: &ObjectId| -> Result<bool> {
if let Some(&cached) = dirty_cache.get(tip) {
return Ok(cached);
}
let result =
tip_reaches_boundary(&remote_db, request.format, tip, &new_points)?;
dirty_cache.insert(*tip, result);
Ok(result)
};
let mut kept = Vec::new();
for update in updates {
if dirty(&update.oid)? {
continue;
}
kept.push(update);
}
updates = kept;
let mut seen = HashSet::new();
let mut heads = Vec::new();
for update in &updates {
if seen.insert(update.oid) {
heads.push(update.oid);
}
}
deepen_plan = if heads.is_empty() {
None
} else {
plan_deepen(&heads)?
};
}
}
let starts: Vec<ObjectId> = updates.iter().map(|update| update.oid).collect();
let shallow_info = if starts.is_empty() && deepen_plan.is_none() {
Vec::new()
} else {
crate::local::install_fetch_pack_via_local_upload_pack(
request.git_dir,
remote_git_dir,
request.format,
starts,
deepen_plan.as_ref(),
promisor_remote,
options.filter,
None,
)?
};
if !options.dry_run {
crate::shallow::apply_shallow_info(request.git_dir, request.format, &shallow_info)?;
}
finalize_fetch(
FetchFinalize {
git_dir: request.git_dir,
store: &store,
options: &options,
remote_name: request.remote_name,
fetch_head_source: &fetch_head_source,
default_head_fetch,
},
&mut updates,
&mut outcome,
)?;
advertisements
}
};
if !options.dry_run && options.prune && remote_exists(request.config, request.remote_name) {
outcome.pruned = prune_remote_tracking_refs_from_advertisements(
request.config,
&store,
request.remote_name,
&advertisements,
options.quiet,
services.progress,
)?;
}
Ok(outcome)
}
fn tip_reaches_boundary<R: sley_odb::ObjectReader>(
remote_db: &R,
format: ObjectFormat,
tip: &ObjectId,
boundary: &HashSet<ObjectId>,
) -> Result<bool> {
let mut seen: HashSet<ObjectId> = HashSet::new();
let mut queue: Vec<ObjectId> = vec![*tip];
while let Some(oid) = queue.pop() {
if !seen.insert(oid) {
continue;
}
let object = remote_db.read_object(&oid)?;
let commit = match object.object_type {
sley_object::ObjectType::Commit => {
sley_object::Commit::parse_ref(format, &object.body)?
}
sley_object::ObjectType::Tag => {
let tag = sley_object::Tag::parse_ref(format, &object.body)?;
queue.push(tag.object);
continue;
}
_ => continue,
};
if boundary.contains(&oid) {
return Ok(true);
}
queue.extend(sley_odb::grafted_parents(remote_db, &oid, commit.parents));
}
Ok(false)
}
fn shallow_boundary_for_request(
git_dir: &Path,
format: ObjectFormat,
depth: Option<u32>,
) -> Result<Vec<ObjectId>> {
if depth.is_none() {
return Ok(Vec::new());
}
crate::shallow::read_shallow(git_dir, format)
}
struct FetchPlanInput<'a> {
advertisements: &'a [RefAdvertisement],
refspecs: &'a [RefSpec],
options: &'a FetchOptions,
store: &'a FileRefStore,
reachable: Option<(&'a FileObjectDatabase, &'a [RefAdvertisement])>,
deepen_excluded: Option<&'a HashSet<ObjectId>>,
format: ObjectFormat,
configured_remote_fetch: bool,
}
fn plan_and_adjust_updates(input: FetchPlanInput<'_>) -> Result<Vec<FetchRefUpdate>> {
let FetchPlanInput {
advertisements,
refspecs,
options,
store,
reachable,
deepen_excluded,
format,
configured_remote_fetch,
} = input;
let mut updates = plan_fetch_ref_updates(advertisements, refspecs, options.auto_follow_tags)?;
if options.fetch_all_tags {
mark_tag_refspec_updates_not_for_merge(&mut updates);
} else {
if options.auto_follow_tags
&& let Some((remote_db, advertisements)) = reachable
{
append_reachable_auto_follow_tags(
advertisements,
remote_db,
format,
refspecs,
&mut updates,
deepen_excluded,
)?;
}
retain_missing_auto_follow_tags(store, &mut updates)?;
}
if configured_remote_fetch {
for update in &mut updates {
update.not_for_merge = true;
}
if let Some(merge_src) = &options.merge_src {
for update in &mut updates {
if update.src == *merge_src {
update.not_for_merge = false;
}
}
}
}
Ok(updates)
}
struct FetchFinalize<'a> {
git_dir: &'a Path,
store: &'a FileRefStore,
options: &'a FetchOptions,
remote_name: &'a str,
fetch_head_source: &'a str,
default_head_fetch: bool,
}
fn finalize_fetch(
finalize: FetchFinalize<'_>,
updates: &mut Vec<FetchRefUpdate>,
outcome: &mut FetchOutcome,
) -> Result<()> {
let FetchFinalize {
git_dir,
store,
options,
remote_name,
fetch_head_source,
default_head_fetch,
} = finalize;
if options.dry_run {
outcome.ref_updates = std::mem::take(updates);
return Ok(());
}
if options.write_fetch_head {
if default_head_fetch
&& updates.len() == 1
&& updates[0].src == "HEAD"
&& updates[0].dst.is_none()
{
write_default_fetch_head(git_dir, remote_name, updates[0].oid, options.append)?;
} else {
write_fetch_head(git_dir, fetch_head_source, updates, options.append)?;
}
outcome.wrote_fetch_head = true;
}
let ref_updates = updates
.iter()
.filter_map(|update| {
update.dst.as_ref().map(|dst| BundleRefUpdate {
name: dst.clone(),
oid: update.oid,
})
})
.collect::<Vec<_>>();
store.apply_bundle_ref_updates(&ref_updates, None)?;
outcome.ref_updates = std::mem::take(updates);
Ok(())
}
fn head_symref_from_features(symrefs: &[String]) -> Option<String> {
symrefs
.iter()
.find_map(|entry| entry.strip_prefix("HEAD:").map(|target| target.to_string()))
}
pub fn apply_configured_remote_tag_option(
config: &GitConfig,
source: &str,
options: &mut FetchOptions,
) {
if options.tag_option_explicit || !remote_exists(config, source) {
return;
}
match remote_config_values(config, source, "tagopt")
.into_iter()
.last()
.as_deref()
{
Some("--tags") => {
options.auto_follow_tags = true;
options.fetch_all_tags = true;
}
Some("--no-tags") => {
options.auto_follow_tags = false;
options.fetch_all_tags = false;
}
_ => {}
}
}
pub fn apply_configured_fetch_prune_option(
config: &GitConfig,
source: &str,
options: &mut FetchOptions,
) {
if options.prune_option_explicit || !remote_exists(config, source) {
return;
}
if let Some(prune) = config.get_bool("remote", Some(source), "prune") {
options.prune = prune;
} else if let Some(prune) = config.get_bool("fetch", None, "prune") {
options.prune = prune;
}
}
pub fn fetch_refspecs_for_source(
configured: Vec<String>,
refspecs: &[String],
fetch_all_tags: bool,
) -> Vec<String> {
let mut effective = if !refspecs.is_empty() {
refspecs.to_vec()
} else if configured.is_empty() {
vec!["HEAD".to_string()]
} else {
configured
};
if fetch_all_tags {
effective.push("refs/tags/*:refs/tags/*".to_string());
}
effective
}
pub fn mark_tag_refspec_updates_not_for_merge(updates: &mut [FetchRefUpdate]) {
for update in updates {
if update.src.starts_with("refs/tags/") && update.dst.as_deref() == Some(&update.src) {
update.not_for_merge = true;
}
}
}
pub fn retain_missing_auto_follow_tags(
store: &FileRefStore,
updates: &mut Vec<FetchRefUpdate>,
) -> Result<()> {
let mut retained = Vec::with_capacity(updates.len());
for update in updates.drain(..) {
if update.not_for_merge
&& update.src.starts_with("refs/tags/")
&& update.dst.as_deref() == Some(&update.src)
&& store.read_ref(&update.src)?.is_some()
{
continue;
}
retained.push(update);
}
*updates = retained;
Ok(())
}
pub fn append_reachable_auto_follow_tags(
advertisements: &[RefAdvertisement],
remote_db: &FileObjectDatabase,
format: ObjectFormat,
refspecs: &[RefSpec],
updates: &mut Vec<FetchRefUpdate>,
deepen_excluded: Option<&HashSet<ObjectId>>,
) -> Result<()> {
if !updates.iter().any(|update| update.dst.is_some()) {
return Ok(());
}
let starts = updates
.iter()
.filter(|update| update.dst.is_some() && !update.src.starts_with("refs/tags/"))
.map(|update| update.oid);
let reachable = match deepen_excluded {
Some(excluded) => {
collect_reachable_object_ids_excluding(remote_db, format, starts, excluded)?
}
None => collect_reachable_object_ids(remote_db, format, starts)?,
};
let mut fetched_srcs = updates
.iter()
.map(|update| update.src.clone())
.collect::<HashSet<_>>();
for reference in advertisements {
if !reference.name.starts_with("refs/tags/")
|| fetched_srcs.contains(&reference.name)
|| !reachable.contains(&reference.oid)
|| fetch_refspec_excludes(refspecs, &reference.name)?
{
continue;
}
fetched_srcs.insert(reference.name.clone());
updates.push(FetchRefUpdate {
src: reference.name.clone(),
dst: Some(reference.name.clone()),
oid: reference.oid,
not_for_merge: true,
});
}
Ok(())
}
pub fn fetch_refspec_excludes(refspecs: &[RefSpec], name: &str) -> Result<bool> {
for refspec in refspecs.iter().filter(|refspec| refspec.negative) {
if refspec.pattern {
if refspec_map_source(refspec, name)?.is_some() {
return Ok(true);
}
} else if refspec.src.as_deref() == Some(name) {
return Ok(true);
}
}
Ok(false)
}
pub fn order_bundle_fetch_all_tags_updates(updates: &mut Vec<FetchRefUpdate>) {
let followed_oids = updates
.iter()
.filter(|update| !update.src.starts_with("refs/tags/") && update.dst.is_some())
.map(|update| update.oid)
.collect::<HashSet<_>>();
if followed_oids.is_empty() {
return;
}
let mut non_tags = Vec::new();
let mut followed_tags = Vec::new();
let mut other_tags = Vec::new();
for update in updates.drain(..) {
if update.src.starts_with("refs/tags/") {
if followed_oids.contains(&update.oid) {
followed_tags.push(update);
} else {
other_tags.push(update);
}
} else {
non_tags.push(update);
}
}
updates.extend(non_tags);
updates.extend(followed_tags);
updates.extend(other_tags);
}
pub fn write_default_fetch_head(
git_dir: &Path,
source: &str,
oid: ObjectId,
append: bool,
) -> Result<()> {
let records = [FetchHeadRecord {
oid,
not_for_merge: false,
description: source.to_string(),
}];
write_fetch_head_records(git_dir, &records, append)?;
Ok(())
}
pub fn write_fetch_head_records(
git_dir: &Path,
records: &[FetchHeadRecord],
append: bool,
) -> Result<()> {
let encoded = encode_fetch_head(records)?;
if append {
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(git_dir.join("FETCH_HEAD"))?;
file.write_all(&encoded)?;
} else {
fs::write(git_dir.join("FETCH_HEAD"), encoded)?;
}
Ok(())
}
pub fn write_fetch_head(
git_dir: &Path,
description: &str,
fetched: &[FetchRefUpdate],
append: bool,
) -> Result<()> {
let records = fetch_ref_updates_to_fetch_head(fetched, description)?;
write_fetch_head_records(git_dir, &records, append)?;
Ok(())
}
pub fn fetch_head_source_description(config: &GitConfig, source: &str) -> String {
remote_config_values(config, source, "url")
.into_iter()
.next()
.map(|url| rewrite_url_with_config(config, &url, false))
.unwrap_or_else(|| rewrite_url_with_config(config, source, false))
}
pub fn prune_remote_tracking_refs_from_advertisements(
config: &GitConfig,
store: &FileRefStore,
remote: &str,
advertisements: &[RefAdvertisement],
quiet: bool,
progress: &mut dyn ProgressSink,
) -> Result<Vec<PrunedRef>> {
let remote_branches = advertisements
.iter()
.filter_map(|advertisement| advertisement.name.strip_prefix("refs/heads/"))
.collect::<BTreeSet<_>>();
let local_refs = store.list_refs()?;
let stale_branches = remote_tracking_branch_names(&local_refs, remote)
.into_iter()
.filter(|branch| !remote_branches.contains(branch.as_str()))
.collect::<Vec<_>>();
if stale_branches.is_empty() {
return Ok(Vec::new());
}
let mut emit = |line: &str| {
if !quiet {
progress.message(line);
}
};
let display_url = remote_config_values(config, remote, "url")
.into_iter()
.next()
.unwrap_or_else(|| remote.into());
emit(&format!("Pruning {remote}"));
emit(&format!("URL: {display_url}"));
let remote_head = format!("refs/remotes/{remote}/HEAD");
let remote_prefix = format!("refs/remotes/{remote}/");
let head_target = match store.read_ref(&remote_head)? {
Some(RefTarget::Symbolic(target)) => Some(target),
Some(RefTarget::Direct(_)) | None => None,
};
let mut pruned = Vec::new();
for branch in stale_branches {
let refname = format!("{remote_prefix}{branch}");
match store.read_ref(&refname)? {
Some(RefTarget::Symbolic(_)) => {
let _ = store.delete_symbolic_ref(&refname)?;
}
Some(RefTarget::Direct(_)) => {
let _ = store.delete_ref(&refname)?;
}
None => {}
}
emit(&format!(" * [pruned] {remote}/{branch}"));
if head_target.as_deref() == Some(refname.as_str()) {
let _ = store.delete_symbolic_ref(&remote_head)?;
emit(&format!(
" refs/remotes/{remote}/HEAD has become dangling after {refname} was deleted"
));
}
pruned.push(PrunedRef { branch, refname });
}
Ok(pruned)
}
fn remote_tracking_branch_names(refs: &[Ref], name: &str) -> Vec<String> {
let prefix = format!("refs/remotes/{name}/");
refs.iter()
.filter_map(|reference| reference.name.strip_prefix(&prefix))
.filter(|branch| *branch != "HEAD")
.map(str::to_string)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
use sley_formats::RepositoryLayout;
use sley_object::{Commit, EncodedObject, ObjectType, Tree};
use sley_odb::{FileObjectDatabase, ObjectWriter};
use sley_refs::{RefTarget, RefUpdate};
use crate::{NoCredentials, SilentProgress};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_repo(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"sley-remote-fetch-{name}-{}-{}",
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&dir);
RepositoryLayout::init_at(&dir, ObjectFormat::Sha1, false)
.expect("test repository should initialize");
dir.join(".git")
}
fn commit_on(git_dir: &Path, branch: &str, message: &str) -> ObjectId {
let format = ObjectFormat::Sha1;
let db = FileObjectDatabase::from_git_dir(git_dir, format);
let tree = db
.write_object(EncodedObject::new(
ObjectType::Tree,
Tree { entries: vec![] }.write(),
))
.expect("tree should write");
let identity = b"Test User <test@example.invalid> 1 +0000".to_vec();
let oid = db
.write_object(EncodedObject::new(
ObjectType::Commit,
Commit {
tree,
parents: Vec::new(),
author: identity.clone(),
committer: identity,
encoding: None,
message: format!("{message}\n").into_bytes(),
}
.write(),
))
.expect("commit should write");
let store = FileRefStore::new(git_dir, format);
let mut tx = store.transaction();
tx.update(RefUpdate {
name: format!("refs/heads/{branch}"),
expected: None,
new: RefTarget::Direct(oid),
reflog: None,
});
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Symbolic(format!("refs/heads/{branch}")),
reflog: None,
});
tx.commit().expect("refs should update");
oid
}
fn default_options() -> FetchOptions {
FetchOptions {
quiet: true,
auto_follow_tags: false,
fetch_all_tags: false,
prune: false,
dry_run: false,
append: false,
write_fetch_head: true,
tag_option_explicit: true,
prune_option_explicit: true,
depth: None,
merge_src: None,
filter: None,
cloning: false,
update_shallow: false,
deepen_relative: false,
deepen_since: None,
deepen_not: Vec::new(),
}
}
#[test]
fn local_fetch_installs_pack_updates_ref_and_fetch_head() {
let remote = temp_repo("remote");
let local = temp_repo("local");
let tip = commit_on(&remote, "main", "remote tip");
let source = FetchSource::Local {
git_dir: remote.clone(),
common_git_dir: remote.clone(),
};
let refspecs = vec!["refs/heads/main:refs/remotes/origin/main".to_string()];
let options = default_options();
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
let outcome = fetch(
FetchRequest {
git_dir: &local,
format: ObjectFormat::Sha1,
config: &GitConfig::default(),
remote_name: "origin",
source: &source,
refspecs: &refspecs,
options: &options,
},
FetchServices {
credentials: &mut credentials,
progress: &mut progress,
},
)
.expect("fetch should succeed");
assert_eq!(outcome.ref_updates.len(), 1);
assert!(outcome.wrote_fetch_head);
let local_db = FileObjectDatabase::from_git_dir(&local, ObjectFormat::Sha1);
assert!(local_db.contains(&tip).expect("contains should read"));
let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
assert_eq!(
local_refs
.read_ref("refs/remotes/origin/main")
.expect("ref should read"),
Some(RefTarget::Direct(tip))
);
let fetch_head = fs::read_to_string(local.join("FETCH_HEAD")).expect("FETCH_HEAD exists");
assert!(fetch_head.contains("origin"));
}
#[test]
fn shallow_local_fetch_writes_depth_boundary_metadata() {
let remote = temp_repo("remote-shallow");
let local = temp_repo("local-shallow");
let tip = commit_on(&remote, "main", "tip");
let source = FetchSource::Local {
git_dir: remote.clone(),
common_git_dir: remote.clone(),
};
let mut options = default_options();
options.depth = Some(1);
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
fetch(
FetchRequest {
git_dir: &local,
format: ObjectFormat::Sha1,
config: &GitConfig::default(),
remote_name: "origin",
source: &source,
refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
options: &options,
},
FetchServices {
credentials: &mut credentials,
progress: &mut progress,
},
)
.expect("shallow fetch should succeed");
assert_eq!(
crate::shallow::read_shallow(&local, ObjectFormat::Sha1)
.expect("shallow file should read"),
vec![tip]
);
}
#[test]
fn failed_local_fetch_does_not_partially_mutate_refs_or_fetch_head() {
let remote = temp_repo("remote-missing");
let local = temp_repo("local-missing");
let old = commit_on(&local, "main", "old local");
let bogus =
ObjectId::from_hex(ObjectFormat::Sha1, &"11".repeat(20)).expect("valid bogus oid");
let remote_refs = FileRefStore::new(&remote, ObjectFormat::Sha1);
let mut tx = remote_refs.transaction();
tx.update(RefUpdate {
name: "refs/heads/main".into(),
expected: None,
new: RefTarget::Direct(bogus),
reflog: None,
});
tx.update(RefUpdate {
name: "HEAD".into(),
expected: None,
new: RefTarget::Symbolic("refs/heads/main".into()),
reflog: None,
});
tx.commit().expect("remote bogus ref should write");
let local_refs = FileRefStore::new(&local, ObjectFormat::Sha1);
let mut tx = local_refs.transaction();
tx.update(RefUpdate {
name: "refs/remotes/origin/main".into(),
expected: None,
new: RefTarget::Direct(old),
reflog: None,
});
tx.commit().expect("local tracking ref should write");
let source = FetchSource::Local {
git_dir: remote.clone(),
common_git_dir: remote.clone(),
};
let options = default_options();
let mut credentials = NoCredentials;
let mut progress = SilentProgress;
let err = fetch(
FetchRequest {
git_dir: &local,
format: ObjectFormat::Sha1,
config: &GitConfig::default(),
remote_name: "origin",
source: &source,
refspecs: &["refs/heads/main:refs/remotes/origin/main".to_string()],
options: &options,
},
FetchServices {
credentials: &mut credentials,
progress: &mut progress,
},
)
.expect_err("fetch should fail before finalizing refs");
assert!(err.to_string().contains("missing object"));
assert_eq!(
local_refs
.read_ref("refs/remotes/origin/main")
.expect("ref should read"),
Some(RefTarget::Direct(old))
);
assert!(!local.join("FETCH_HEAD").exists());
}
}