diff --git a/gix-protocol/src/fetch/refmap/init.rs b/gix-protocol/src/fetch/refmap/init.rs index f8168d50968..5efaac44c25 100644 --- a/gix-protocol/src/fetch/refmap/init.rs +++ b/gix-protocol/src/fetch/refmap/init.rs @@ -110,7 +110,7 @@ impl RefMap { let num_explicit_specs = fetch_refspecs.len(); let group = gix_refspec::MatchGroup::from_fetch_specs(all_refspecs.iter().map(gix_refspec::RefSpec::to_ref)); let (res, fixes) = group - .match_remotes(remote_refs.iter().map(|r| { + .match_lhs(remote_refs.iter().map(|r| { let (full_ref_name, target, object) = r.unpack(); gix_refspec::match_group::Item { full_ref_name, diff --git a/gix-refspec/src/match_group/mod.rs b/gix-refspec/src/match_group/mod.rs index c02276b90eb..b13dcf255f1 100644 --- a/gix-refspec/src/match_group/mod.rs +++ b/gix-refspec/src/match_group/mod.rs @@ -3,7 +3,7 @@ use std::collections::BTreeSet; use crate::{parse::Operation, types::Mode, MatchGroup, RefSpecRef}; pub(crate) mod types; -pub use types::{Item, Mapping, Outcome, Source, SourceRef}; +pub use types::{match_lhs, match_rhs, Item, Mapping, Source, SourceRef}; /// pub mod validate; @@ -26,13 +26,20 @@ impl<'a> MatchGroup<'a> { } /// Matching -impl<'a> MatchGroup<'a> { +impl<'spec> MatchGroup<'spec> { /// Match all `items` against all *fetch* specs present in this group, returning deduplicated mappings from source to destination. - /// *Note that this method is correct only for specs*, even though it also *works for push-specs*. + /// `items` are expected to be references on the remote, which will be matched and mapped to obtain their local counterparts, + /// i.e. *left side of refspecs is mapped to their right side*. + /// *Note that this method is correct only for fetch-specs*, even though it also *works for push-specs*. + /// + /// Object names are never mapped and always returned as match. /// /// Note that negative matches are not part of the return value, so they are not observable but will be used to remove mappings. // TODO: figure out how to deal with push-specs, probably when push is being implemented. - pub fn match_remotes<'item>(self, mut items: impl Iterator> + Clone) -> Outcome<'a, 'item> { + pub fn match_lhs<'item>( + self, + mut items: impl Iterator> + Clone, + ) -> match_lhs::Outcome<'spec, 'item> { let mut out = Vec::new(); let mut seen = BTreeSet::default(); let mut push_unique = |mapping| { @@ -67,16 +74,15 @@ impl<'a> MatchGroup<'a> { continue; } for (item_index, item) in items.clone().enumerate() { - if let Some(matcher) = matcher { - let (matched, rhs) = matcher.matches_lhs(item); - if matched { - push_unique(Mapping { - item_index: Some(item_index), - lhs: SourceRef::FullName(item.full_ref_name), - rhs, - spec_index, - }); - } + let Some(matcher) = matcher else { continue }; + let (matched, rhs) = matcher.matches_lhs(item); + if matched { + push_unique(Mapping { + item_index: Some(item_index), + lhs: SourceRef::FullName(item.full_ref_name.into()), + rhs, + spec_index, + }); } } } @@ -88,12 +94,78 @@ impl<'a> MatchGroup<'a> { .zip(self.specs.iter()) .filter_map(|(m, spec)| m.and_then(|m| (spec.mode == Mode::Negative).then_some(m))) { - out.retain(|m| match m.lhs { + out.retain(|m| match &m.lhs { SourceRef::ObjectId(_) => true, SourceRef::FullName(name) => { !matcher .matches_lhs(Item { - full_ref_name: name, + full_ref_name: name.as_ref(), + target: &null_id, + object: None, + }) + .0 + } + }); + } + } + match_lhs::Outcome { + group: self, + mappings: out, + } + } + + /// Match all `items` against all *fetch* specs present in this group, returning deduplicated mappings from destination to source. + /// `items` are expected to be tracking references in the local clone, which will be matched and reverse-mapped to obtain their remote counterparts, + /// i.e. *right side of refspecs is mapped to their left side*. + /// *Note that this method is correct only for fetch-specs*, even though it also *works for push-specs*. + /// + /// Note that negative matches are not part of the return value, so they are not observable but will be used to remove mappings. + // Reverse-mapping is implemented here: https://github.com/git/git/blob/76cf4f61c87855ebf0784b88aaf737d6b09f504b/branch.c#L252 + pub fn match_rhs<'item>( + self, + mut items: impl Iterator> + Clone, + ) -> match_rhs::Outcome<'spec, 'item> { + let mut out = Vec::>::new(); + let mut seen = BTreeSet::default(); + let mut push_unique = |mapping| { + if seen.insert(calculate_hash(&mapping)) { + out.push(mapping); + } + }; + let mut matchers: Vec> = self.specs.iter().copied().map(Matcher::from).collect(); + + let mut has_negation = false; + for (spec_index, (spec, matcher)) in self.specs.iter().zip(matchers.iter_mut()).enumerate() { + if spec.mode == Mode::Negative { + has_negation = true; + continue; + } + for (item_index, item) in items.clone().enumerate() { + let (matched, lhs) = matcher.matches_rhs(item); + if let Some(lhs) = lhs.filter(|_| matched) { + push_unique(Mapping { + item_index: Some(item_index), + lhs: SourceRef::FullName(lhs), + rhs: Some(item.full_ref_name.into()), + spec_index, + }); + } + } + } + + if let Some(hash_kind) = has_negation.then(|| items.next().map(|i| i.target.kind())).flatten() { + let null_id = hash_kind.null(); + for matcher in matchers + .into_iter() + .zip(self.specs.iter()) + .filter_map(|(m, spec)| (spec.mode == Mode::Negative).then_some(m)) + { + out.retain(|m| match &m.lhs { + SourceRef::ObjectId(_) => true, + SourceRef::FullName(name) => { + !matcher + .matches_rhs(Item { + full_ref_name: name.as_ref(), target: &null_id, object: None, }) @@ -102,7 +174,7 @@ impl<'a> MatchGroup<'a> { }); } } - Outcome { + match_rhs::Outcome { group: self, mappings: out, } diff --git a/gix-refspec/src/match_group/types.rs b/gix-refspec/src/match_group/types.rs index 6be601dd594..fc6792405cc 100644 --- a/gix-refspec/src/match_group/types.rs +++ b/gix-refspec/src/match_group/types.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use bstr::{BStr, BString}; +use bstr::BStr; use gix_hash::oid; use crate::RefSpecRef; @@ -12,15 +12,38 @@ pub struct MatchGroup<'a> { pub specs: Vec>, } -/// The outcome of any matching operation of a [`MatchGroup`]. /// -/// It's used to validate and process the contained [mappings][Mapping]. -#[derive(Debug, Clone)] -pub struct Outcome<'spec, 'item> { - /// The match group that produced this outcome. - pub group: MatchGroup<'spec>, - /// The mappings derived from matching [items][Item]. - pub mappings: Vec>, +pub mod match_lhs { + use crate::match_group::Mapping; + use crate::MatchGroup; + + /// The outcome of any matching operation of a [`MatchGroup`]. + /// + /// It's used to validate and process the contained [mappings](Mapping). + #[derive(Debug, Clone)] + pub struct Outcome<'spec, 'item> { + /// The match group that produced this outcome. + pub group: MatchGroup<'spec>, + /// The mappings derived from matching [items](crate::match_group::Item). + pub mappings: Vec>, + } +} + +/// +pub mod match_rhs { + use crate::match_group::Mapping; + use crate::MatchGroup; + + /// The outcome of any matching operation of a [`MatchGroup`]. + /// + /// It's used to validate and process the contained [mappings](Mapping). + #[derive(Debug, Clone)] + pub struct Outcome<'spec, 'item> { + /// The match group that produced this outcome. + pub group: MatchGroup<'spec>, + /// The mappings derived from matching [items](crate::match_group::Item). + pub mappings: Vec>, + } } /// An item to match, input to various matching operations. @@ -34,13 +57,13 @@ pub struct Item<'a> { pub object: Option<&'a oid>, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -/// The source (or left-hand) side of a mapping, which references its name. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// The source (or left-hand) side of a mapping. pub enum SourceRef<'a> { /// A full reference name, which is expected to be valid. /// /// Validity, however, is not enforced here. - FullName(&'a BStr), + FullName(Cow<'a, BStr>), /// The name of an object that is expected to exist on the remote side. /// Note that it might not be advertised by the remote but part of the object graph, /// and thus gets sent in the pack. The server is expected to fail unless the desired @@ -49,38 +72,27 @@ pub enum SourceRef<'a> { } impl SourceRef<'_> { - /// Create a fully owned instance from this one. - pub fn to_owned(&self) -> Source { + /// Create a fully owned instance by consuming this one. + pub fn into_owned(self) -> Source { match self { - SourceRef::ObjectId(id) => Source::ObjectId(*id), - SourceRef::FullName(name) => Source::FullName((*name).to_owned()), + SourceRef::ObjectId(id) => Source::ObjectId(id), + SourceRef::FullName(name) => Source::FullName(name.into_owned().into()), } } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -/// The source (or left-hand) side of a mapping, which owns its name. -pub enum Source { - /// A full reference name, which is expected to be valid. - /// - /// Validity, however, is not enforced here. - FullName(BString), - /// The name of an object that is expected to exist on the remote side. - /// Note that it might not be advertised by the remote but part of the object graph, - /// and thus gets sent in the pack. The server is expected to fail unless the desired - /// object is present but at some time it is merely a request by the user. - ObjectId(gix_hash::ObjectId), -} - -impl std::fmt::Display for Source { +impl std::fmt::Display for SourceRef<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Source::FullName(name) => name.fmt(f), - Source::ObjectId(id) => id.fmt(f), + SourceRef::FullName(name) => name.fmt(f), + SourceRef::ObjectId(id) => id.fmt(f), } } } +/// The source (or left-hand) side of a mapping, which owns its name. +pub type Source = SourceRef<'static>; + /// A mapping from a remote to a local refs for fetches or local to remote refs for pushes. /// /// Mappings are like edges in a graph, initially without any constraints. diff --git a/gix-refspec/src/match_group/util.rs b/gix-refspec/src/match_group/util.rs index 59bedca22a1..a5cae7fd59d 100644 --- a/gix-refspec/src/match_group/util.rs +++ b/gix-refspec/src/match_group/util.rs @@ -12,8 +12,10 @@ pub struct Matcher<'a> { } impl<'a> Matcher<'a> { - /// Match `item` against this spec and return `(true, Some)` to gain the other side of the match as configured, or `(true, None)` - /// if there was no `rhs` but the `item` matched. Lastly, return `(false, None)` if `item` didn't match at all. + /// Match the lefthand-side `item` against this spec and return `(true, Some)` to gain the other, + /// transformed righthand-side of the match as configured by the refspec. + /// Or return `(true, None)` if there was no `rhs` but the `item` matched. + /// Lastly, return `(false, None)` if `item` didn't match at all. /// /// This may involve resolving a glob with an allocation, as the destination is built using the matching portion of a glob. pub fn matches_lhs(&self, item: Item<'_>) -> (bool, Option>) { @@ -23,6 +25,20 @@ impl<'a> Matcher<'a> { (None, _) => (false, None), } } + + /// Match the righthand-side `item` against this spec and return `(true, Some)` to gain the other, + /// transformed lefthand-side of the match as configured by the refspec. + /// Or return `(true, None)` if there was no `lhs` but the `item` matched. + /// Lastly, return `(false, None)` if `item` didn't match at all. + /// + /// This may involve resolving a glob with an allocation, as the destination is built using the matching portion of a glob. + pub fn matches_rhs(&self, item: Item<'_>) -> (bool, Option>) { + match (self.lhs, self.rhs) { + (None, Some(rhs)) => (rhs.matches(item).is_match(), None), + (Some(lhs), Some(rhs)) => rhs.matches(item).into_match_outcome(lhs, item), + (_, None) => (false, None), + } + } } #[derive(Debug, Copy, Clone)] diff --git a/gix-refspec/src/match_group/validate.rs b/gix-refspec/src/match_group/validate.rs index 6a72a72fdfa..3ee2c28c319 100644 --- a/gix-refspec/src/match_group/validate.rs +++ b/gix-refspec/src/match_group/validate.rs @@ -3,10 +3,39 @@ use std::collections::BTreeMap; use bstr::BString; use crate::{ - match_group::{Outcome, Source}, + match_group::{match_lhs, Source}, RefSpec, }; +/// The error returned [outcome validation](match_lhs::Outcome::validated()). +#[derive(Debug)] +pub struct Error { + /// All issues discovered during validation. + pub issues: Vec, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Found {} {} the refspec mapping to be used: \n\t{}", + self.issues.len(), + if self.issues.len() == 1 { + "issue that prevents" + } else { + "issues that prevent" + }, + self.issues + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n\t") + ) + } +} + +impl std::error::Error for Error {} + /// All possible issues found while validating matched mappings. #[derive(Debug, PartialEq, Eq)] pub enum Issue { @@ -59,36 +88,7 @@ pub enum Fix { }, } -/// The error returned [outcome validation][Outcome::validated()]. -#[derive(Debug)] -pub struct Error { - /// All issues discovered during validation. - pub issues: Vec, -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Found {} {} the refspec mapping to be used: \n\t{}", - self.issues.len(), - if self.issues.len() == 1 { - "issue that prevents" - } else { - "issues that prevent" - }, - self.issues - .iter() - .map(ToString::to_string) - .collect::>() - .join("\n\t") - ) - } -} - -impl std::error::Error for Error {} - -impl Outcome<'_, '_> { +impl match_lhs::Outcome<'_, '_> { /// Validate all mappings or dissolve them into an error stating the discovered issues. /// Return `(modified self, issues)` providing a fixed-up set of mappings in `self` with the fixed `issues` /// provided as part of it. @@ -113,7 +113,10 @@ impl Outcome<'_, '_> { .iter() .map(|(spec_idx, _)| self.group.specs[*spec_idx].to_bstring()) .collect(), - sources: conflicting_sources.into_iter().map(|(_, src)| src.to_owned()).collect(), + sources: conflicting_sources + .into_iter() + .map(|(_, src)| src.clone().into_owned()) + .collect(), }); } if !issues.is_empty() { diff --git a/gix-refspec/tests/impls/mod.rs b/gix-refspec/tests/refspec/impls.rs similarity index 100% rename from gix-refspec/tests/impls/mod.rs rename to gix-refspec/tests/refspec/impls.rs diff --git a/gix-refspec/tests/refspec.rs b/gix-refspec/tests/refspec/main.rs similarity index 100% rename from gix-refspec/tests/refspec.rs rename to gix-refspec/tests/refspec/main.rs diff --git a/gix-refspec/tests/match_group/mod.rs b/gix-refspec/tests/refspec/match_group.rs similarity index 100% rename from gix-refspec/tests/match_group/mod.rs rename to gix-refspec/tests/refspec/match_group.rs diff --git a/gix-refspec/tests/matching/mod.rs b/gix-refspec/tests/refspec/matching.rs similarity index 96% rename from gix-refspec/tests/matching/mod.rs rename to gix-refspec/tests/refspec/matching.rs index 36cb7c33665..42161942bbb 100644 --- a/gix-refspec/tests/matching/mod.rs +++ b/gix-refspec/tests/refspec/matching.rs @@ -146,7 +146,7 @@ pub mod baseline { .unwrap_or_else(|| panic!("BUG: Need {key:?} added to the baseline")) .as_ref(); - let actual = match_group.match_remotes(input()).validated(); + let actual = match_group.match_lhs(input()).validated(); let (actual, expected) = match &mode { Mode::Normal { validate_err } => match validate_err { Some(err_message) => { @@ -179,7 +179,11 @@ pub mod baseline { ); for (idx, (actual, expected)) in actual.iter().zip(expected).enumerate() { - assert_eq!(source_to_bstring(actual.lhs), expected.remote, "{idx}: remote mismatch"); + assert_eq!( + source_to_bstring(&actual.lhs), + expected.remote, + "{idx}: remote mismatch" + ); if let Some(expected) = expected.local.as_ref() { match actual.rhs.as_ref() { None => panic!("{idx}: Expected local ref to be {expected}, got none"), @@ -189,9 +193,9 @@ pub mod baseline { } } - fn source_to_bstring(source: SourceRef) -> BString { + fn source_to_bstring(source: &SourceRef) -> BString { match source { - SourceRef::FullName(name) => name.into(), + SourceRef::FullName(name) => name.as_ref().into(), SourceRef::ObjectId(id) => id.to_string().into(), } } diff --git a/gix-refspec/tests/parse/fetch.rs b/gix-refspec/tests/refspec/parse/fetch.rs similarity index 100% rename from gix-refspec/tests/parse/fetch.rs rename to gix-refspec/tests/refspec/parse/fetch.rs diff --git a/gix-refspec/tests/parse/invalid.rs b/gix-refspec/tests/refspec/parse/invalid.rs similarity index 93% rename from gix-refspec/tests/parse/invalid.rs rename to gix-refspec/tests/refspec/parse/invalid.rs index 7286e186a43..db591d2e0b3 100644 --- a/gix-refspec/tests/parse/invalid.rs +++ b/gix-refspec/tests/refspec/parse/invalid.rs @@ -64,7 +64,8 @@ fn push_to_empty() { #[test] fn fuzzed() { - let input = include_bytes!("../fixtures/fuzzed/clusterfuzz-testcase-minimized-gix-refspec-parse-4658733962887168"); + let input = + include_bytes!("../../fixtures/fuzzed/clusterfuzz-testcase-minimized-gix-refspec-parse-4658733962887168"); drop(gix_refspec::parse(input.into(), gix_refspec::parse::Operation::Fetch).unwrap_err()); drop(gix_refspec::parse(input.into(), gix_refspec::parse::Operation::Push).unwrap_err()); } diff --git a/gix-refspec/tests/parse/mod.rs b/gix-refspec/tests/refspec/parse/mod.rs similarity index 100% rename from gix-refspec/tests/parse/mod.rs rename to gix-refspec/tests/refspec/parse/mod.rs diff --git a/gix-refspec/tests/parse/push.rs b/gix-refspec/tests/refspec/parse/push.rs similarity index 100% rename from gix-refspec/tests/parse/push.rs rename to gix-refspec/tests/refspec/parse/push.rs diff --git a/gix-refspec/tests/spec/mod.rs b/gix-refspec/tests/refspec/spec.rs similarity index 100% rename from gix-refspec/tests/spec/mod.rs rename to gix-refspec/tests/refspec/spec.rs diff --git a/gix-refspec/tests/write/mod.rs b/gix-refspec/tests/refspec/write.rs similarity index 100% rename from gix-refspec/tests/write/mod.rs rename to gix-refspec/tests/refspec/write.rs diff --git a/gix/src/clone/fetch/util.rs b/gix/src/clone/fetch/util.rs index 54458c2c9b5..780af0f5218 100644 --- a/gix/src/clone/fetch/util.rs +++ b/gix/src/clone/fetch/util.rs @@ -206,7 +206,7 @@ pub(super) fn find_custom_refname<'a>( object: None, }) .collect(); - let res = group.match_remotes(filtered_items.iter().copied()); + let res = group.match_lhs(filtered_items.iter().copied()); match res.mappings.len() { 0 => Err(Error::RefNameMissing { wanted: ref_name.clone(), @@ -221,9 +221,9 @@ pub(super) fn find_custom_refname<'a>( wanted: ref_name.clone(), candidates: res .mappings - .iter() + .into_iter() .filter_map(|m| match m.lhs { - gix_refspec::match_group::SourceRef::FullName(name) => Some(name.to_owned()), + gix_refspec::match_group::SourceRef::FullName(name) => Some(name.into_owned()), gix_refspec::match_group::SourceRef::ObjectId(_) => None, }) .collect(), @@ -252,7 +252,7 @@ fn setup_branch_config( .expect("remote was just created and must be visible in config"); let group = gix_refspec::MatchGroup::from_fetch_specs(remote.fetch_specs.iter().map(gix_refspec::RefSpec::to_ref)); let null = gix_hash::ObjectId::null(repo.object_hash()); - let res = group.match_remotes( + let res = group.match_lhs( Some(gix_refspec::match_group::Item { full_ref_name: branch.as_bstr(), target: branch_id.unwrap_or(&null), diff --git a/gix/src/remote/connection/fetch/update_refs/tests.rs b/gix/src/remote/connection/fetch/update_refs/tests.rs index 980634956ea..f5e2e71dad4 100644 --- a/gix/src/remote/connection/fetch/update_refs/tests.rs +++ b/gix/src/remote/connection/fetch/update_refs/tests.rs @@ -920,7 +920,7 @@ mod update { let mut references: Vec<_> = references.all().unwrap().map(|r| into_remote_ref(r.unwrap())).collect(); references.push(into_remote_ref(remote_repo.find_reference("HEAD").unwrap())); let mappings = group - .match_remotes(references.iter().map(remote_ref_to_item)) + .match_lhs(references.iter().map(remote_ref_to_item)) .mappings .into_iter() .map(|m| fetch::refmap::Mapping { diff --git a/gix/src/repository/config/branch.rs b/gix/src/repository/config/branch.rs index 55ad7fda50a..d9d231460ad 100644 --- a/gix/src/repository/config/branch.rs +++ b/gix/src/repository/config/branch.rs @@ -5,7 +5,9 @@ use gix_ref::{FullName, FullNameRef}; use crate::bstr::BStr; use crate::config::cache::util::ApplyLeniencyDefault; use crate::config::tree::{Branch, Push}; -use crate::repository::{branch_remote_ref_name, branch_remote_tracking_ref_name}; +use crate::repository::{ + branch_remote_ref_name, branch_remote_tracking_ref_name, upstream_branch_and_remote_name_for_tracking_branch, +}; use crate::{push, remote}; /// Query configuration related to branches. @@ -20,19 +22,18 @@ impl crate::Repository { self.subsection_str_names_of("branch") } - /// Returns the validated reference on the remote associated with the given `name`, + /// Returns the validated reference name of the upstream branch on the remote associated with the given `name`, /// which will be used when *merging*. - /// The returned value corresponds to the `branch..merge` configuration key. + /// The returned value corresponds to the `branch..merge` configuration key for [`remote::Direction::Fetch`]. + /// For the [push direction](`remote::Direction::Push`) the Git configuration is used for a variety of different outcomes, + /// similar to what would happen when running `git push `. /// - /// Returns `None` if there is no value at the given key, or if no remote or remote ref is configured. - /// May return an error if the reference name to be returned is invalid. + /// Returns `None` if there is nothing configured, or if no remote or remote ref is configured. /// /// ### Note /// - /// This name refers to what Git calls upstream branch (as opposed to upstream *tracking* branch). + /// The returned name refers to what Git calls upstream branch (as opposed to upstream *tracking* branch). /// The value is also fast to retrieve compared to its tracking branch. - /// Also note that a [remote::Direction] isn't used here as Git only supports (and requires) configuring - /// the remote to fetch from, not the one to push to. /// /// See also [`Reference::remote_ref_name()`](crate::Reference::remote_ref_name()). #[doc(alias = "branch_upstream_name", alias = "git2")] @@ -125,6 +126,73 @@ impl crate::Repository { .map(|res| res.map_err(Into::into)) } + /// Given a local `tracking_branch` name, find the remote that maps to it along with the name of the branch on + /// the side of the remote, also called upstream branch. + /// + /// Return `Ok(None)` if there is no remote with fetch-refspecs that would match `tracking_branch` on the right-hand side, + /// or `Err` if the matches were ambiguous. + /// + /// ### Limitations + /// + /// A single valid mapping is required as fine-grained matching isn't implemented yet. This means that + pub fn upstream_branch_and_remote_for_tracking_branch( + &self, + tracking_branch: &FullNameRef, + ) -> Result)>, upstream_branch_and_remote_name_for_tracking_branch::Error> { + use upstream_branch_and_remote_name_for_tracking_branch::Error; + if tracking_branch.category() != Some(gix_ref::Category::RemoteBranch) { + return Err(Error::BranchCategory { + full_name: tracking_branch.to_owned(), + }); + } + + let null = self.object_hash().null(); + let item_to_search = gix_refspec::match_group::Item { + full_ref_name: tracking_branch.as_bstr(), + target: &null, + object: None, + }; + let mut candidates = Vec::new(); + let mut ambiguous_remotes = Vec::new(); + for remote_name in self.remote_names() { + let remote = self.find_remote(remote_name.as_ref())?; + let match_group = gix_refspec::MatchGroup::from_fetch_specs( + remote + .refspecs(remote::Direction::Fetch) + .iter() + .map(|spec| spec.to_ref()), + ); + let out = match_group.match_rhs(Some(item_to_search).into_iter()); + match &out.mappings[..] { + [] => {} + [one] => candidates.push((remote.clone(), one.lhs.clone().into_owned())), + [..] => ambiguous_remotes.push(remote), + } + } + + if candidates.len() == 1 { + let (remote, candidate) = candidates.pop().expect("just checked for one entry"); + let upstream_branch = match candidate { + gix_refspec::match_group::SourceRef::FullName(name) => gix_ref::FullName::try_from(name.into_owned())?, + gix_refspec::match_group::SourceRef::ObjectId(_) => { + unreachable!("Such a reverse mapping isn't ever produced") + } + }; + return Ok(Some((upstream_branch, remote))); + } + if ambiguous_remotes.len() + candidates.len() > 1 { + return Err(Error::AmbiguousRemotes { + remotes: ambiguous_remotes + .into_iter() + .map(|r| r.name) + .chain(candidates.into_iter().map(|(r, _)| r.name)) + .flatten() + .collect(), + }); + } + Ok(None) + } + /// Returns the unvalidated name of the remote associated with the given `short_branch_name`, /// typically `main` instead of `refs/heads/main`. /// In some cases, the returned name will be an URL. @@ -196,7 +264,7 @@ fn matching_remote<'a>( .collect(), }; let null_id = object_hash.null(); - let out = search.match_remotes( + let out = search.match_lhs( Some(gix_refspec::match_group::Item { full_ref_name: lhs.as_bstr(), target: &null_id, diff --git a/gix/src/repository/mod.rs b/gix/src/repository/mod.rs index 4d5ca4093fa..20e2fa4d8c7 100644 --- a/gix/src/repository/mod.rs +++ b/gix/src/repository/mod.rs @@ -330,7 +330,6 @@ pub mod index_from_tree { /// pub mod branch_remote_ref_name { - /// The error returned by [Repository::branch_remote_ref_name()](crate::Repository::branch_remote_ref_name()). #[derive(Debug, thiserror::Error)] #[allow(missing_docs)] @@ -346,7 +345,6 @@ pub mod branch_remote_ref_name { /// pub mod branch_remote_tracking_ref_name { - /// The error returned by [Repository::branch_remote_tracking_ref_name()](crate::Repository::branch_remote_tracking_ref_name()). #[derive(Debug, thiserror::Error)] #[allow(missing_docs)] @@ -360,6 +358,25 @@ pub mod branch_remote_tracking_ref_name { } } +/// +pub mod upstream_branch_and_remote_name_for_tracking_branch { + /// The error returned by [Repository::upstream_branch_and_remote_name_for_tracking_branch()](crate::Repository::upstream_branch_and_remote_for_tracking_branch()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("The input branch '{}' needs to be a remote tracking branch", full_name.as_bstr())] + BranchCategory { full_name: gix_ref::FullName }, + #[error(transparent)] + FindRemote(#[from] crate::remote::find::existing::Error), + #[error("Found ambiguous remotes without 1:1 mapping or more than one match: {}", remotes.iter() + .map(|r| r.as_bstr().to_string()) + .collect::>().join(", "))] + AmbiguousRemotes { remotes: Vec> }, + #[error(transparent)] + ValidateUpstreamBranch(#[from] gix_ref::name::Error), + } +} + /// #[cfg(feature = "attributes")] pub mod pathspec_defaults_ignore_case { diff --git a/gix/src/repository/worktree.rs b/gix/src/repository/worktree.rs index 09ff3910886..16c6686d317 100644 --- a/gix/src/repository/worktree.rs +++ b/gix/src/repository/worktree.rs @@ -12,7 +12,7 @@ impl crate::Repository { /// Note that these need additional processing to become usable, but provide a first glimpse a typical worktree information. pub fn worktrees(&self) -> std::io::Result>> { let mut res = Vec::new(); - let iter = match std::fs::read_dir(dbg!(self.common_dir()).join("worktrees")) { + let iter = match std::fs::read_dir(self.common_dir().join("worktrees")) { Ok(iter) => iter, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(res), Err(err) => return Err(err), diff --git a/gix/tests/fixtures/generated-archives/make_remote_config_repos.tar b/gix/tests/fixtures/generated-archives/make_remote_config_repos.tar index 2f2838bbf7c..15b3589e6ca 100644 Binary files a/gix/tests/fixtures/generated-archives/make_remote_config_repos.tar and b/gix/tests/fixtures/generated-archives/make_remote_config_repos.tar differ diff --git a/gix/tests/fixtures/make_remote_config_repos.sh b/gix/tests/fixtures/make_remote_config_repos.sh index efd444d1c2d..89040f5f723 100755 --- a/gix/tests/fixtures/make_remote_config_repos.sh +++ b/gix/tests/fixtures/make_remote_config_repos.sh @@ -138,6 +138,12 @@ git clone fetch multiple-remotes git remote add with/two/slashes ../fetch && git fetch with/two/slashes git remote add with/two ../fetch && git fetch with/two + # add a specialised refspec mapping + git config --add remote.with/two.fetch +refs/heads/special:refs/remotes/with/two/special + # make sure the ref exists + cp .git/refs/remotes/with/two/main .git/refs/remotes/with/two/special + # show Git can checkout such an ambiguous refspec + git checkout -b track-special with/two/special git checkout -b main --track origin/main git checkout -b other-main --track other/main ) \ No newline at end of file diff --git a/gix/tests/gix/repository/config/remote.rs b/gix/tests/gix/repository/config/remote.rs index ba1e5bdb373..fe4f3f1a2a6 100644 --- a/gix/tests/gix/repository/config/remote.rs +++ b/gix/tests/gix/repository/config/remote.rs @@ -107,6 +107,21 @@ mod branch_remote { .as_bstr(), "refs/remotes/remote_repo/main" ); + let (upstream, remote_name) = repo + .upstream_branch_and_remote_for_tracking_branch("refs/remotes/remote_repo/main".try_into()?)? + .expect("mapping exists"); + assert_eq!(upstream.as_bstr(), "refs/heads/main"); + assert_eq!( + remote_name.name().expect("non-anonymous remote").as_bstr(), + "remote_repo" + ); + + assert_eq!( + repo.upstream_branch_and_remote_for_tracking_branch("refs/remotes/missing-remote/main".try_into()?)?, + None, + "It's OK to find nothing" + ); + for direction in [remote::Direction::Fetch, remote::Direction::Push] { assert_eq!( repo.branch_remote_name("main", direction) @@ -145,6 +160,41 @@ mod branch_remote { Ok(()) } + #[test] + fn upstream_branch_and_remote_name_for_tracking_branch() -> crate::Result { + let repo = repo("multiple-remotes")?; + for expected_remote_name in ["other", "with/two"] { + let (upstream, remote) = repo + .upstream_branch_and_remote_for_tracking_branch( + format!("refs/remotes/{expected_remote_name}/main") + .as_str() + .try_into()?, + )? + .expect("mapping exists"); + assert_eq!(remote.name().expect("named remote").as_bstr(), expected_remote_name); + assert_eq!(upstream.as_bstr(), "refs/heads/main"); + } + let err = repo + .upstream_branch_and_remote_for_tracking_branch("refs/remotes/with/two/slashes/main".try_into()?) + .unwrap_err(); + assert_eq!( + err.to_string(), + "Found ambiguous remotes without 1:1 mapping or more than one match: with/two, with/two/slashes", + "we aren't very specific report an error just like Git does in case of multi-remote ambiguity" + ); + + let (upstream, remote) = repo + .upstream_branch_and_remote_for_tracking_branch("refs/remotes/with/two/special".try_into()?)? + .expect("mapping exists"); + assert_eq!(remote.name().expect("non-anonymous remote").as_bstr(), "with/two"); + assert_eq!( + upstream.as_bstr(), + "refs/heads/special", + "it finds a single mapping even though there are two refspecs" + ); + Ok(()) + } + #[test] fn push_default() -> crate::Result { let repo = repo("fetch")?;