Skip to content

Commit bc62b1e

Browse files
authored
Merge pull request #2229 from GitoxideLabs/copilot/fix-refspec-for-shallow-clone
Fix refspec for shallow clones to use single-branch instead of wildcard
2 parents 607a19a + c331afc commit bc62b1e

File tree

6 files changed

+157
-16
lines changed

6 files changed

+157
-16
lines changed

gitoxide-core/src/repository/fetch.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ pub(crate) mod function {
320320
err,
321321
"server sent {} tips, {} were filtered due to {} refspec(s).",
322322
map.remote_refs.len(),
323-
map.remote_refs.len() - map.mappings.len(),
323+
map.remote_refs.len().saturating_sub(map.mappings.len()),
324324
refspecs.len()
325325
)?;
326326
}

gix-ref/src/fullname.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1+
use crate::{bstr::ByteVec, name::is_pseudo_ref, Category, FullName, FullNameRef, Namespace, PartialNameRef};
12
use gix_object::bstr::{BStr, BString, ByteSlice};
23
use std::{borrow::Borrow, path::Path};
34

4-
use crate::{bstr::ByteVec, name::is_pseudo_ref, Category, FullName, FullNameRef, Namespace, PartialNameRef};
5-
65
impl TryFrom<&str> for FullName {
76
type Error = gix_validate::reference::name::Error;
87

@@ -165,6 +164,8 @@ impl FullNameRef {
165164
impl Category<'_> {
166165
/// As the inverse of [`FullNameRef::category_and_short_name()`], use the prefix of this category alongside
167166
/// the `short_name` to create a valid fully qualified [reference name](FullName).
167+
///
168+
/// If `short_name` already contains the prefix that it would receive (and is thus a full name), no duplication will occur.
168169
pub fn to_full_name<'a>(&self, short_name: impl Into<&'a BStr>) -> Result<FullName, crate::name::Error> {
169170
let mut out: BString = self.prefix().into();
170171
let short_name = short_name.into();
@@ -185,8 +186,12 @@ impl Category<'_> {
185186
| Category::PseudoRef
186187
| Category::MainPseudoRef => short_name,
187188
};
188-
out.extend_from_slice(partial_name);
189-
FullName::try_from(out)
189+
if out.is_empty() || !partial_name.starts_with(&out) {
190+
out.extend_from_slice(partial_name);
191+
FullName::try_from(out)
192+
} else {
193+
FullName::try_from(partial_name.as_bstr())
194+
}
190195
}
191196
}
192197

gix-ref/tests/refs/fullname.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,25 @@ fn shorten_and_category() {
117117
);
118118
}
119119

120+
#[test]
121+
fn to_full_name() -> gix_testtools::Result {
122+
assert_eq!(
123+
Category::LocalBranch.to_full_name("refs/heads/full")?.as_bstr(),
124+
"refs/heads/full",
125+
"prefixes aren't duplicated"
126+
);
127+
128+
assert_eq!(
129+
Category::LocalBranch
130+
.to_full_name("refs/remotes/origin/other")?
131+
.as_bstr(),
132+
"refs/heads/refs/remotes/origin/other",
133+
"full names with a different category will be prefixed, to support 'main-worktree' special cases"
134+
);
135+
136+
Ok(())
137+
}
138+
120139
#[test]
121140
fn prefix_with_namespace_and_stripping() {
122141
let ns = gix_ref::namespace::expand("foo").unwrap();

gix/src/clone/fetch/mod.rs

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::{
22
bstr::{BString, ByteSlice},
33
clone::PrepareFetch,
44
};
5+
use gix_ref::Category;
56

67
/// The error returned by [`PrepareFetch::fetch_only()`].
78
#[derive(Debug, thiserror::Error)]
@@ -47,6 +48,10 @@ pub enum Error {
4748
},
4849
#[error(transparent)]
4950
CommitterOrFallback(#[from] crate::config::time::Error),
51+
#[error(transparent)]
52+
RefMap(#[from] crate::remote::ref_map::Error),
53+
#[error(transparent)]
54+
ReferenceName(#[from] gix_validate::reference::name::Error),
5055
}
5156

5257
/// Modification
@@ -101,14 +106,81 @@ impl PrepareFetch {
101106
};
102107

103108
let mut remote = repo.remote_at(self.url.clone())?;
109+
110+
// For shallow clones without custom configuration, we'll use a single-branch refspec
111+
// to match git's behavior (matching git's single-branch behavior for shallow clones).
112+
let use_single_branch_for_shallow = self.shallow != remote::fetch::Shallow::NoChange
113+
&& remote.fetch_specs.is_empty()
114+
&& self.fetch_options.extra_refspecs.is_empty();
115+
116+
let target_ref = if use_single_branch_for_shallow {
117+
// Determine target branch from user-specified ref_name or default branch
118+
if let Some(ref_name) = &self.ref_name {
119+
Some(Category::LocalBranch.to_full_name(ref_name.as_ref().as_bstr())?)
120+
} else {
121+
// For shallow clones without a specified ref, we need to determine the default branch.
122+
// We'll connect to get HEAD information. For Protocol V2, we need to explicitly list refs.
123+
let mut connection = remote.connect(remote::Direction::Fetch).await?;
124+
125+
// Perform handshake and try to get HEAD from it (works for Protocol V1)
126+
let _ = connection.ref_map_by_ref(&mut progress, Default::default()).await?;
127+
128+
let target = if let Some(handshake) = &connection.handshake {
129+
// Protocol V1: refs are in handshake
130+
handshake.refs.as_ref().and_then(|refs| {
131+
refs.iter().find_map(|r| match r {
132+
gix_protocol::handshake::Ref::Symbolic {
133+
full_ref_name, target, ..
134+
} if full_ref_name == "HEAD" => gix_ref::FullName::try_from(target).ok(),
135+
_ => None,
136+
})
137+
})
138+
} else {
139+
None
140+
};
141+
142+
// For Protocol V2 or if we couldn't determine HEAD, use the configured default branch
143+
let fallback_branch = target
144+
.or_else(|| {
145+
repo.config
146+
.resolved
147+
.string(crate::config::tree::Init::DEFAULT_BRANCH)
148+
.and_then(|name| Category::LocalBranch.to_full_name(name.as_bstr()).ok())
149+
})
150+
.unwrap_or_else(|| gix_ref::FullName::try_from("refs/heads/main").expect("known to be valid"));
151+
152+
// Drop the connection explicitly to release the borrow on remote
153+
drop(connection);
154+
155+
Some(fallback_branch)
156+
}
157+
} else {
158+
None
159+
};
160+
161+
// Set up refspec based on whether we're doing a single-branch shallow clone,
162+
// which requires a single ref to match Git unless it's overridden.
104163
if remote.fetch_specs.is_empty() {
105-
remote = remote
106-
.with_refspecs(
107-
Some(format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_str()),
108-
remote::Direction::Fetch,
109-
)
110-
.expect("valid static spec");
164+
if let Some(target_ref) = &target_ref {
165+
// Single-branch refspec for shallow clones
166+
let short_name = target_ref.shorten();
167+
remote = remote
168+
.with_refspecs(
169+
Some(format!("+{target_ref}:refs/remotes/{remote_name}/{short_name}").as_str()),
170+
remote::Direction::Fetch,
171+
)
172+
.expect("valid refspec");
173+
} else {
174+
// Wildcard refspec for non-shallow clones or when target couldn't be determined
175+
remote = remote
176+
.with_refspecs(
177+
Some(format!("+refs/heads/*:refs/remotes/{remote_name}/*").as_str()),
178+
remote::Direction::Fetch,
179+
)
180+
.expect("valid static spec");
181+
}
111182
}
183+
112184
let mut clone_fetch_tags = None;
113185
if let Some(f) = self.configure_remote.as_mut() {
114186
remote = f(remote).map_err(Error::RemoteConfiguration)?;
@@ -133,6 +205,7 @@ impl PrepareFetch {
133205
.expect("valid")
134206
.to_owned();
135207
let pending_pack: remote::fetch::Prepare<'_, '_, _> = {
208+
// For shallow clones, we already connected once, so we need to connect again
136209
let mut connection = remote.connect(remote::Direction::Fetch).await?;
137210
if let Some(f) = self.configure_connection.as_mut() {
138211
f(&mut connection).map_err(Error::RemoteConnection)?;

gix/src/config/tree/sections/init.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use crate::{
55

66
impl Init {
77
/// The `init.defaultBranch` key.
8+
// TODO: review its usage for cases where this key is not set - sometimes it's 'master', sometimes it's 'main'.
89
pub const DEFAULT_BRANCH: keys::Any = keys::Any::new("defaultBranch", &config::Tree::INIT)
910
.with_deviation("If not set, we use `main` instead of `master`");
1011
}

gix/tests/gix/clone.rs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ use crate::{remote, util::restricted};
44
mod blocking_io {
55
use std::{borrow::Cow, path::Path, sync::atomic::AtomicBool};
66

7+
use crate::{
8+
remote,
9+
util::{hex_to_id, restricted},
10+
};
711
use gix::{
812
bstr::BString,
913
config::tree::{Clone, Core, Init, Key},
@@ -14,11 +18,7 @@ mod blocking_io {
1418
};
1519
use gix_object::bstr::ByteSlice;
1620
use gix_ref::TargetRef;
17-
18-
use crate::{
19-
remote,
20-
util::{hex_to_id, restricted},
21-
};
21+
use gix_refspec::parse::Operation;
2222

2323
#[test]
2424
fn fetch_shallow_no_checkout_then_unshallow() -> crate::Result {
@@ -83,6 +83,40 @@ mod blocking_io {
8383
Ok(())
8484
}
8585

86+
#[test]
87+
fn shallow_clone_uses_single_branch_refspec() -> crate::Result {
88+
let tmp = gix_testtools::tempfile::TempDir::new()?;
89+
let (repo, _out) = gix::prepare_clone_bare(remote::repo("base").path(), tmp.path())?
90+
.with_shallow(Shallow::DepthAtRemote(1.try_into()?))
91+
.fetch_only(gix::progress::Discard, &std::sync::atomic::AtomicBool::default())?;
92+
93+
assert!(repo.is_shallow(), "repository should be shallow");
94+
95+
// Verify that only a single-branch refspec was configured
96+
let remote = repo.find_remote("origin")?;
97+
let refspecs: Vec<_> = remote
98+
.refspecs(Direction::Fetch)
99+
.iter()
100+
.map(|spec| spec.to_ref().to_bstring())
101+
.collect();
102+
103+
assert_eq!(refspecs.len(), 1, "shallow clone should have only one fetch refspec");
104+
105+
// The refspec should be for a single branch (main), not a wildcard
106+
let refspec_str = refspecs[0].to_str().expect("valid utf8");
107+
assert_eq!(
108+
refspec_str,
109+
if cfg!(windows) {
110+
"+refs/heads/master:refs/remotes/origin/master"
111+
} else {
112+
"+refs/heads/main:refs/remotes/origin/main"
113+
},
114+
"shallow clone refspec should not use wildcard and should be the main branch: {refspec_str}"
115+
);
116+
117+
Ok(())
118+
}
119+
86120
#[test]
87121
fn from_shallow_prohibited_with_option() -> crate::Result {
88122
let tmp = gix_testtools::tempfile::TempDir::new()?;
@@ -203,7 +237,16 @@ mod blocking_io {
203237
fn from_non_shallow_by_deepen_exclude_then_deepen_to_unshallow() -> crate::Result {
204238
let tmp = gix_testtools::tempfile::TempDir::new()?;
205239
let excluded_leaf_refs = ["g", "h", "j"];
240+
206241
let (repo, _change) = gix::prepare_clone_bare(remote::repo("base").path(), tmp.path())?
242+
.with_fetch_options(gix::remote::ref_map::Options {
243+
extra_refspecs: vec![gix::refspec::parse(
244+
"refs/heads/*:refs/remotes/origin/*".into(),
245+
Operation::Fetch,
246+
)?
247+
.into()],
248+
..Default::default()
249+
})
207250
.with_shallow(Shallow::Exclude {
208251
remote_refs: excluded_leaf_refs
209252
.into_iter()

0 commit comments

Comments
 (0)