Skip to content

Commit ddd9fd1

Browse files
committed
feat: filters and partial cloning: initial support
Introduce `gix::remote::fetch::ObjectFilter` (currently blob filters only) and plumb it through clone/fetch all the way into the fetch protocol, returning a clear error if the remote doesn’t advertise `filter` capability. Also persist the partial-clone configuration on clone (`remote.<name>.partialclonefilter`, `remote.<name>.promisor`, `extensions.partialclone`) so the repository is marked as a promisor/partial clone in the same way as Git. On the CLI/plumbing side, add `--filter <spec>` to `gix clone`, and allow fetch arguments to be either refspecs or raw object IDs (treated as additional wants). Includes tests for filter parsing and for persisting partial-clone settings during clone. Credit: - Original work by Cameron Esfahani <cesfahani@roku.com> - Rewritten for modern Gitoxide by Jake Staehle <jstaehle@roku.com> [Upstream-Status: Appropriate for OSS Release]
1 parent e6270e1 commit ddd9fd1

16 files changed

Lines changed: 389 additions & 25 deletions

File tree

gitoxide-core/src/pack/receive.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ where
136136
shallow_file: "no shallow file required as we reject it to keep it simple".into(),
137137
shallow: &Default::default(),
138138
tags: Default::default(),
139+
filter: None,
139140
reject_shallow_remote: true,
140141
},
141142
)

gitoxide-core/src/repository/clone.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub struct Options {
77
pub no_tags: bool,
88
pub shallow: gix::remote::fetch::Shallow,
99
pub ref_name: Option<gix::refs::PartialName>,
10+
pub filter: Option<gix::remote::fetch::ObjectFilter>,
1011
}
1112

1213
pub const PROGRESS_RANGE: std::ops::RangeInclusive<u8> = 1..=3;
@@ -34,6 +35,7 @@ pub(crate) mod function {
3435
no_tags,
3536
ref_name,
3637
shallow,
38+
filter,
3739
}: Options,
3840
) -> anyhow::Result<()>
3941
where
@@ -76,6 +78,7 @@ pub(crate) mod function {
7678
prepare = prepare.configure_remote(|r| Ok(r.with_fetch_tags(gix::remote::fetch::Tags::None)));
7779
}
7880
let (mut checkout, fetch_outcome) = prepare
81+
.with_filter(filter)
7982
.with_shallow(shallow)
8083
.with_ref_name(ref_name.as_ref())?
8184
.fetch_then_checkout(&mut progress, &gix::interrupt::IS_INTERRUPTED)?;

gitoxide-core/src/repository/fetch.rs

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use gix::bstr::BString;
1+
use gix::{bstr::BString, hash::ObjectId};
22

33
use crate::OutputFormat;
44

@@ -29,7 +29,7 @@ pub(crate) mod function {
2929
std_shapes::shapes::{Arrow, Element, ShapeKind},
3030
};
3131

32-
use super::Options;
32+
use super::{ObjectId, Options};
3333
use crate::OutputFormat;
3434

3535
pub fn fetch<P>(
@@ -57,15 +57,29 @@ pub(crate) mod function {
5757
}
5858

5959
let mut remote = crate::repository::remote::by_name_or_url(&repo, remote.as_deref())?;
60-
if !ref_specs.is_empty() {
61-
remote.replace_refspecs(ref_specs.iter(), gix::remote::Direction::Fetch)?;
60+
let mut wants = Vec::new();
61+
let mut fetch_refspecs = Vec::new();
62+
let expected_hex_len = repo.object_hash().len_in_hex();
63+
for spec in ref_specs {
64+
if spec.len() == expected_hex_len {
65+
if let Ok(oid) = ObjectId::from_hex(spec.as_ref()) {
66+
wants.push(oid);
67+
continue;
68+
}
69+
}
70+
fetch_refspecs.push(spec);
71+
}
72+
73+
if !fetch_refspecs.is_empty() {
74+
remote.replace_refspecs(fetch_refspecs.iter(), gix::remote::Direction::Fetch)?;
6275
remote = remote.with_fetch_tags(gix::remote::fetch::Tags::None);
6376
}
6477
let res: gix::remote::fetch::Outcome = remote
6578
.connect(gix::remote::Direction::Fetch)?
6679
.prepare_fetch(&mut progress, Default::default())?
6780
.with_dry_run(dry_run)
6881
.with_shallow(shallow)
82+
.with_additional_wants(wants)
6983
.receive(&mut progress, &gix::interrupt::IS_INTERRUPTED)?;
7084

7185
if handshake_info {

gix-protocol/src/fetch/function.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ pub async fn fetch<P, T, E>(
5454
shallow_file,
5555
shallow,
5656
tags,
57+
filter,
5758
reject_shallow_remote,
5859
}: Options<'_>,
5960
) -> Result<Option<Outcome>, Error>
@@ -77,6 +78,15 @@ where
7778
crate::fetch::Response::check_required_features(protocol_version, &fetch_features)?;
7879
let sideband_all = fetch_features.iter().any(|(n, _)| *n == "sideband-all");
7980
let mut arguments = Arguments::new(protocol_version, fetch_features, trace_packetlines);
81+
if let Some(filter) = filter {
82+
if !arguments.can_use_filter() {
83+
return Err(Error::MissingServerFeature {
84+
feature: "filter",
85+
description: "Partial clone filters require server support configured on the remote server",
86+
});
87+
}
88+
arguments.filter(filter);
89+
}
8090
if matches!(tags, Tags::Included) {
8191
if !arguments.can_use_include_tag() {
8292
return Err(Error::MissingServerFeature {

gix-protocol/src/fetch/types.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ pub struct Options<'a> {
1313
pub shallow: &'a Shallow,
1414
/// Describe how to handle tags when fetching.
1515
pub tags: Tags,
16+
/// If set, request that the remote filters the object set according to `filter`.
17+
pub filter: Option<&'a str>,
1618
/// If `true`, if we fetch from a remote that only offers shallow clones, the operation will fail with an error
1719
/// instead of writing the shallow boundary to the shallow file.
1820
pub reject_shallow_remote: bool,

gix/src/clone/access.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ impl PrepareFetch {
3535
self
3636
}
3737

38+
/// Ask the remote to omit objects based on `filter`.
39+
pub fn with_filter(mut self, filter: impl Into<Option<crate::remote::fetch::ObjectFilter>>) -> Self {
40+
self.filter = filter.into();
41+
self
42+
}
43+
3844
/// Apply the given configuration `values` right before readying the actual fetch from the remote.
3945
/// The configuration is marked with [source API](gix_config::Source::Api), and will not be written back, it's
4046
/// retained only in memory.

gix/src/clone/fetch/mod.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ pub enum Error {
2020
RemoteConfiguration(#[source] Box<dyn std::error::Error + Send + Sync>),
2121
#[error("Custom configuration of connection to use when cloning failed")]
2222
RemoteConnection(#[source] Box<dyn std::error::Error + Send + Sync>),
23+
#[error("Remote name {remote_name:?} is not valid UTF-8")]
24+
RemoteNameNotUtf8 {
25+
source: crate::bstr::Utf8Error,
26+
remote_name: crate::bstr::BString,
27+
},
28+
#[error("Configuration value name {name:?} is invalid")]
29+
ConfigValueName {
30+
name: &'static str,
31+
source: gix_config::parse::section::value_name::Error,
32+
},
33+
#[error(transparent)]
34+
ConfigSectionHeader(#[from] gix_config::parse::section::header::Error),
2335
#[error(transparent)]
2436
RemoteName(#[from] crate::config::remote::symbolic_name::Error),
2537
#[error(transparent)]
@@ -203,7 +215,12 @@ impl PrepareFetch {
203215
clone_fetch_tags = remote::fetch::Tags::All.into();
204216
}
205217

206-
let config = util::write_remote_to_local_config_file(&mut remote, remote_name.clone())?;
218+
let filter_spec_to_save = self
219+
.filter
220+
.as_ref()
221+
.map(crate::remote::fetch::ObjectFilter::to_argument_string);
222+
let config =
223+
util::write_remote_to_local_config_file(&mut remote, remote_name.clone(), filter_spec_to_save.as_deref())?;
207224

208225
// Now we are free to apply remote configuration we don't want to be written to disk.
209226
if let Some(fetch_tags) = clone_fetch_tags {
@@ -283,6 +300,7 @@ impl PrepareFetch {
283300
b
284301
};
285302
let outcome = pending_pack
303+
.with_filter(self.filter.clone())
286304
.with_write_packed_refs_only(true)
287305
.with_reflog_message(RefLogMessage::Override {
288306
message: reflog_message.clone(),
@@ -326,3 +344,5 @@ impl PrepareFetch {
326344
}
327345

328346
mod util;
347+
348+
pub use util::write_remote_to_local_config_file;

gix/src/clone/fetch/util.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,49 @@ enum WriteMode {
1616
Append,
1717
}
1818

19+
/// Persist the provided remote into the repository's local config, optionally adding partial clone settings.
1920
#[allow(clippy::result_large_err)]
2021
pub fn write_remote_to_local_config_file(
2122
remote: &mut crate::Remote<'_>,
2223
remote_name: BString,
24+
filter: Option<&str>,
2325
) -> Result<gix_config::File<'static>, Error> {
26+
use gix_config::parse::section::ValueName;
27+
2428
let mut config = gix_config::File::new(local_config_meta(remote.repo));
25-
remote.save_as_to(remote_name, &mut config)?;
29+
remote.save_as_to(remote_name.clone(), &mut config)?;
30+
31+
if let Some(filter_spec) = filter {
32+
let subsection = remote_name.to_str().map_err(|err| Error::RemoteNameNotUtf8 {
33+
remote_name: remote_name.clone(),
34+
source: err,
35+
})?;
36+
let mut remote_section = config.section_mut_or_create_new("remote", Some(subsection.into()))?;
37+
38+
while remote_section.remove("partialclonefilter").is_some() {}
39+
while remote_section.remove("promisor").is_some() {}
40+
41+
let partial_clone_filter = ValueName::try_from("partialclonefilter").map_err(|err| Error::ConfigValueName {
42+
name: "partialclonefilter",
43+
source: err,
44+
})?;
45+
remote_section.push(partial_clone_filter, Some(BStr::new(filter_spec)));
46+
47+
let promisor = ValueName::try_from("promisor").map_err(|err| Error::ConfigValueName {
48+
name: "promisor",
49+
source: err,
50+
})?;
51+
remote_section.push(promisor, Some(BStr::new("true")));
52+
53+
let mut extensions_section = config.section_mut_or_create_new("extensions", None)?;
54+
while extensions_section.remove("partialclone").is_some() {}
55+
56+
let partial_clone = ValueName::try_from("partialclone").map_err(|err| Error::ConfigValueName {
57+
name: "partialclone",
58+
source: err,
59+
})?;
60+
extensions_section.push(partial_clone, Some(remote_name.as_ref()));
61+
}
2662

2763
write_to_local_config(&config, WriteMode::Append)?;
2864
Ok(config)

gix/src/clone/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ pub struct PrepareFetch {
3939
/// How to handle shallow clones
4040
#[cfg_attr(not(feature = "blocking-network-client"), allow(dead_code))]
4141
shallow: remote::fetch::Shallow,
42+
/// Optional object filter to request from the remote.
43+
#[cfg_attr(not(feature = "blocking-network-client"), allow(dead_code))]
44+
filter: Option<remote::fetch::ObjectFilter>,
4245
/// The name of the reference to fetch. If `None`, the reference pointed to by `HEAD` will be checked out.
4346
#[cfg_attr(not(feature = "blocking-network-client"), allow(dead_code))]
4447
ref_name: Option<gix_ref::PartialName>,
@@ -121,6 +124,7 @@ impl PrepareFetch {
121124
#[cfg(any(feature = "async-network-client", feature = "blocking-network-client"))]
122125
configure_connection: None,
123126
shallow: remote::fetch::Shallow::NoChange,
127+
filter: None,
124128
ref_name: None,
125129
})
126130
}

gix/src/remote/connection/fetch/mod.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use crate::{
1212
},
1313
Progress,
1414
};
15+
use gix_hash::ObjectId;
1516

1617
mod error;
1718
pub use error::Error;
@@ -153,6 +154,8 @@ where
153154
reflog_message: None,
154155
write_packed_refs: WritePackedRefs::Never,
155156
shallow: Default::default(),
157+
filter: None,
158+
additional_wants: Vec::new(),
156159
})
157160
}
158161
}
@@ -184,6 +187,8 @@ where
184187
reflog_message: Option<RefLogMessage>,
185188
write_packed_refs: WritePackedRefs,
186189
shallow: remote::fetch::Shallow,
190+
filter: Option<remote::fetch::ObjectFilter>,
191+
additional_wants: Vec<ObjectId>,
187192
}
188193

189194
/// Builder
@@ -225,4 +230,22 @@ where
225230
self.shallow = shallow;
226231
self
227232
}
233+
234+
/// Ask the server to apply `filter` when sending objects.
235+
pub fn with_filter(mut self, filter: Option<remote::fetch::ObjectFilter>) -> Self {
236+
self.filter = filter;
237+
self
238+
}
239+
240+
/// Request that the server also sends the objects identified by the given object ids.
241+
///
242+
/// Objects already present locally will be ignored during negotiation.
243+
pub fn with_additional_wants(mut self, wants: impl IntoIterator<Item = ObjectId>) -> Self {
244+
for want in wants {
245+
if !self.additional_wants.contains(&want) {
246+
self.additional_wants.push(want);
247+
}
248+
}
249+
self
250+
}
228251
}

0 commit comments

Comments
 (0)