From 77d7ce9b1297ef4b9bcb7ee7087e630424ab2b41 Mon Sep 17 00:00:00 2001 From: Jake Staehle Date: Thu, 17 Jul 2025 12:18:52 -0500 Subject: [PATCH 1/3] feat!: filters and partial cloning: initial support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..partialclonefilter`, `remote..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 ` 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. Creates a blobless bare clone using `gix`, creates (using real `git`) a worktree from that, and then using `gix` verify that `--filter=blob:none` is working! Credit: - Original work by Cameron Esfahani - Rewritten for modern Gitoxide by Jake Staehle [Upstream-Status: Appropriate for OSS Release] --- gitoxide-core/src/pack/receive.rs | 1 + gitoxide-core/src/repository/clone.rs | 3 + gitoxide-core/src/repository/fetch.rs | 22 +- gix-protocol/src/fetch/function.rs | 10 + gix-protocol/src/fetch/types.rs | 2 + gix/src/clone/access.rs | 6 + gix/src/clone/fetch/mod.rs | 22 +- gix/src/clone/fetch/util.rs | 38 +++- gix/src/clone/mod.rs | 4 + gix/src/remote/connection/fetch/mod.rs | 23 ++ .../remote/connection/fetch/receive_pack.rs | 112 ++++++++-- gix/src/remote/fetch.rs | 3 + gix/src/remote/fetch/object_filter.rs | 111 ++++++++++ gix/tests/gix/clone.rs | 49 ++++ src/plumbing/main.rs | 2 + src/plumbing/options/mod.rs | 6 + tests/journey/gix.sh | 209 ++++++++++++++++++ .../snapshots/plumbing/blobless-clone/cat-v1 | 1 + .../blobless-clone/cat-v1-blobless-failure | 1 + .../snapshots/plumbing/blobless-clone/cat-v2 | 1 + .../blobless-clone/cat-v2-blobless-failure | 1 + .../snapshots/plumbing/blobless-clone/cat-v4 | 1 + .../plumbing/blobless-clone/filter-config | 1 + .../blobless-clone/git-reported-missing-blobs | 13 ++ .../git-reported-missing-blobs-v4 | 6 + .../git-reported-missing-blobs-v5 | 7 + .../blobless-clone/v2-tree-entries-missing | 4 + .../blobless-clone/v2-tree-entries-partial | 4 + .../blobless-clone/v2-tree-entries-partial2 | 4 + .../blobless-clone/v4-tree-entries-missing | 6 + .../blobless-clone/v4-tree-entries-populated | 6 + .../blobless-clone/v5-tree-entries-missing | 5 + .../blobless-clone/v5-tree-entries-partial | 5 + .../bloblimit-clone/blob-limit-config | 1 + 34 files changed, 665 insertions(+), 25 deletions(-) create mode 100644 gix/src/remote/fetch/object_filter.rs create mode 100644 tests/snapshots/plumbing/blobless-clone/cat-v1 create mode 100644 tests/snapshots/plumbing/blobless-clone/cat-v1-blobless-failure create mode 100644 tests/snapshots/plumbing/blobless-clone/cat-v2 create mode 100644 tests/snapshots/plumbing/blobless-clone/cat-v2-blobless-failure create mode 100644 tests/snapshots/plumbing/blobless-clone/cat-v4 create mode 100644 tests/snapshots/plumbing/blobless-clone/filter-config create mode 100644 tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs create mode 100644 tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v4 create mode 100644 tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v5 create mode 100644 tests/snapshots/plumbing/blobless-clone/v2-tree-entries-missing create mode 100644 tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial create mode 100644 tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial2 create mode 100644 tests/snapshots/plumbing/blobless-clone/v4-tree-entries-missing create mode 100644 tests/snapshots/plumbing/blobless-clone/v4-tree-entries-populated create mode 100644 tests/snapshots/plumbing/blobless-clone/v5-tree-entries-missing create mode 100644 tests/snapshots/plumbing/blobless-clone/v5-tree-entries-partial create mode 100644 tests/snapshots/plumbing/bloblimit-clone/blob-limit-config diff --git a/gitoxide-core/src/pack/receive.rs b/gitoxide-core/src/pack/receive.rs index 10acf24f9d6..62a42289e1a 100644 --- a/gitoxide-core/src/pack/receive.rs +++ b/gitoxide-core/src/pack/receive.rs @@ -136,6 +136,7 @@ where shallow_file: "no shallow file required as we reject it to keep it simple".into(), shallow: &Default::default(), tags: Default::default(), + filter: None, reject_shallow_remote: true, }, ) diff --git a/gitoxide-core/src/repository/clone.rs b/gitoxide-core/src/repository/clone.rs index c5cfa530784..c672dda175e 100644 --- a/gitoxide-core/src/repository/clone.rs +++ b/gitoxide-core/src/repository/clone.rs @@ -7,6 +7,7 @@ pub struct Options { pub no_tags: bool, pub shallow: gix::remote::fetch::Shallow, pub ref_name: Option, + pub filter: Option, } pub const PROGRESS_RANGE: std::ops::RangeInclusive = 1..=3; @@ -34,6 +35,7 @@ pub(crate) mod function { no_tags, ref_name, shallow, + filter, }: Options, ) -> anyhow::Result<()> where @@ -76,6 +78,7 @@ pub(crate) mod function { prepare = prepare.configure_remote(|r| Ok(r.with_fetch_tags(gix::remote::fetch::Tags::None))); } let (mut checkout, fetch_outcome) = prepare + .with_filter(filter) .with_shallow(shallow) .with_ref_name(ref_name.as_ref())? .fetch_then_checkout(&mut progress, &gix::interrupt::IS_INTERRUPTED)?; diff --git a/gitoxide-core/src/repository/fetch.rs b/gitoxide-core/src/repository/fetch.rs index 2f143a9ec8e..ba36cc8a1d8 100644 --- a/gitoxide-core/src/repository/fetch.rs +++ b/gitoxide-core/src/repository/fetch.rs @@ -1,4 +1,4 @@ -use gix::bstr::BString; +use gix::{bstr::BString, hash::ObjectId}; use crate::OutputFormat; @@ -29,7 +29,7 @@ pub(crate) mod function { std_shapes::shapes::{Arrow, Element, ShapeKind}, }; - use super::Options; + use super::{ObjectId, Options}; use crate::OutputFormat; pub fn fetch

( @@ -57,8 +57,21 @@ pub(crate) mod function { } let mut remote = crate::repository::remote::by_name_or_url(&repo, remote.as_deref())?; - if !ref_specs.is_empty() { - remote.replace_refspecs(ref_specs.iter(), gix::remote::Direction::Fetch)?; + let mut wants = Vec::new(); + let mut fetch_refspecs = Vec::new(); + let expected_hex_len = repo.object_hash().len_in_hex(); + for spec in ref_specs { + if spec.len() == expected_hex_len { + if let Ok(oid) = ObjectId::from_hex(spec.as_ref()) { + wants.push(oid); + continue; + } + } + fetch_refspecs.push(spec); + } + + if !fetch_refspecs.is_empty() { + remote.replace_refspecs(fetch_refspecs.iter(), gix::remote::Direction::Fetch)?; remote = remote.with_fetch_tags(gix::remote::fetch::Tags::None); } let res: gix::remote::fetch::Outcome = remote @@ -66,6 +79,7 @@ pub(crate) mod function { .prepare_fetch(&mut progress, Default::default())? .with_dry_run(dry_run) .with_shallow(shallow) + .with_additional_wants(wants) .receive(&mut progress, &gix::interrupt::IS_INTERRUPTED)?; if handshake_info { diff --git a/gix-protocol/src/fetch/function.rs b/gix-protocol/src/fetch/function.rs index b24b2555a66..67167ece784 100644 --- a/gix-protocol/src/fetch/function.rs +++ b/gix-protocol/src/fetch/function.rs @@ -51,6 +51,7 @@ pub async fn fetch( shallow_file, shallow, tags, + filter, reject_shallow_remote, }: Options<'_>, ) -> Result, Error> @@ -74,6 +75,15 @@ where crate::fetch::Response::check_required_features(protocol_version, &fetch_features)?; let sideband_all = fetch_features.iter().any(|(n, _)| *n == "sideband-all"); let mut arguments = Arguments::new(protocol_version, fetch_features, trace_packetlines); + if let Some(filter) = filter { + if !arguments.can_use_filter() { + return Err(Error::MissingServerFeature { + feature: "filter", + description: "Partial clone filters require server support configured on the remote server", + }); + } + arguments.filter(filter); + } if matches!(tags, Tags::Included) { if !arguments.can_use_include_tag() { return Err(Error::MissingServerFeature { diff --git a/gix-protocol/src/fetch/types.rs b/gix-protocol/src/fetch/types.rs index adf822fbd1a..f425a024658 100644 --- a/gix-protocol/src/fetch/types.rs +++ b/gix-protocol/src/fetch/types.rs @@ -13,6 +13,8 @@ pub struct Options<'a> { pub shallow: &'a Shallow, /// Describe how to handle tags when fetching. pub tags: Tags, + /// If set, request that the remote filters the object set according to `filter`. + pub filter: Option<&'a str>, /// If `true`, if we fetch from a remote that only offers shallow clones, the operation will fail with an error /// instead of writing the shallow boundary to the shallow file. pub reject_shallow_remote: bool, diff --git a/gix/src/clone/access.rs b/gix/src/clone/access.rs index 8bec1aa282d..3c60b334d22 100644 --- a/gix/src/clone/access.rs +++ b/gix/src/clone/access.rs @@ -35,6 +35,12 @@ impl PrepareFetch { self } + /// Ask the remote to omit objects based on `filter`. + pub fn with_filter(mut self, filter: impl Into>) -> Self { + self.filter = filter.into(); + self + } + /// Apply the given configuration `values` right before readying the actual fetch from the remote. /// The configuration is marked with [source API](gix_config::Source::Api), and will not be written back, it's /// retained only in memory. diff --git a/gix/src/clone/fetch/mod.rs b/gix/src/clone/fetch/mod.rs index a07f40b95d2..0315e34ac9e 100644 --- a/gix/src/clone/fetch/mod.rs +++ b/gix/src/clone/fetch/mod.rs @@ -20,6 +20,18 @@ pub enum Error { RemoteConfiguration(#[source] Box), #[error("Custom configuration of connection to use when cloning failed")] RemoteConnection(#[source] Box), + #[error("Remote name {remote_name:?} is not valid UTF-8")] + RemoteNameNotUtf8 { + source: crate::bstr::Utf8Error, + remote_name: crate::bstr::BString, + }, + #[error("Configuration value name {name:?} is invalid")] + ConfigValueName { + name: &'static str, + source: gix_config::parse::section::value_name::Error, + }, + #[error(transparent)] + ConfigSectionHeader(#[from] gix_config::parse::section::header::Error), #[error(transparent)] RemoteName(#[from] crate::config::remote::symbolic_name::Error), #[error(transparent)] @@ -203,7 +215,12 @@ impl PrepareFetch { clone_fetch_tags = remote::fetch::Tags::All.into(); } - let config = util::write_remote_to_local_config_file(&mut remote, remote_name.clone())?; + let filter_spec_to_save = self + .filter + .as_ref() + .map(crate::remote::fetch::ObjectFilter::to_argument_string); + let config = + util::write_remote_to_local_config_file(&mut remote, remote_name.clone(), filter_spec_to_save.as_deref())?; // Now we are free to apply remote configuration we don't want to be written to disk. if let Some(fetch_tags) = clone_fetch_tags { @@ -283,6 +300,7 @@ impl PrepareFetch { b }; let outcome = pending_pack + .with_filter(self.filter.clone()) .with_write_packed_refs_only(true) .with_reflog_message(RefLogMessage::Override { message: reflog_message.clone(), @@ -326,3 +344,5 @@ impl PrepareFetch { } mod util; + +pub use util::write_remote_to_local_config_file; diff --git a/gix/src/clone/fetch/util.rs b/gix/src/clone/fetch/util.rs index 5ca6f6c6385..6a351f932f9 100644 --- a/gix/src/clone/fetch/util.rs +++ b/gix/src/clone/fetch/util.rs @@ -16,13 +16,49 @@ enum WriteMode { Append, } +/// Persist the provided remote into the repository's local config, optionally adding partial clone settings. #[allow(clippy::result_large_err)] pub fn write_remote_to_local_config_file( remote: &mut crate::Remote<'_>, remote_name: BString, + filter: Option<&str>, ) -> Result, Error> { + use gix_config::parse::section::ValueName; + let mut config = gix_config::File::new(local_config_meta(remote.repo)); - remote.save_as_to(remote_name, &mut config)?; + remote.save_as_to(remote_name.clone(), &mut config)?; + + if let Some(filter_spec) = filter { + let subsection = remote_name.to_str().map_err(|err| Error::RemoteNameNotUtf8 { + remote_name: remote_name.clone(), + source: err, + })?; + let mut remote_section = config.section_mut_or_create_new("remote", Some(subsection.into()))?; + + while remote_section.remove("partialclonefilter").is_some() {} + while remote_section.remove("promisor").is_some() {} + + let partial_clone_filter = ValueName::try_from("partialclonefilter").map_err(|err| Error::ConfigValueName { + name: "partialclonefilter", + source: err, + })?; + remote_section.push(partial_clone_filter, Some(BStr::new(filter_spec))); + + let promisor = ValueName::try_from("promisor").map_err(|err| Error::ConfigValueName { + name: "promisor", + source: err, + })?; + remote_section.push(promisor, Some(BStr::new("true"))); + + let mut extensions_section = config.section_mut_or_create_new("extensions", None)?; + while extensions_section.remove("partialclone").is_some() {} + + let partial_clone = ValueName::try_from("partialclone").map_err(|err| Error::ConfigValueName { + name: "partialclone", + source: err, + })?; + extensions_section.push(partial_clone, Some(remote_name.as_ref())); + } write_to_local_config(&config, WriteMode::Append)?; Ok(config) diff --git a/gix/src/clone/mod.rs b/gix/src/clone/mod.rs index c3d50ad3d9b..350ec19565b 100644 --- a/gix/src/clone/mod.rs +++ b/gix/src/clone/mod.rs @@ -39,6 +39,9 @@ pub struct PrepareFetch { /// How to handle shallow clones #[cfg_attr(not(feature = "blocking-network-client"), allow(dead_code))] shallow: remote::fetch::Shallow, + /// Optional object filter to request from the remote. + #[cfg_attr(not(feature = "blocking-network-client"), allow(dead_code))] + filter: Option, /// The name of the reference to fetch. If `None`, the reference pointed to by `HEAD` will be checked out. #[cfg_attr(not(feature = "blocking-network-client"), allow(dead_code))] ref_name: Option, @@ -121,6 +124,7 @@ impl PrepareFetch { #[cfg(any(feature = "async-network-client", feature = "blocking-network-client"))] configure_connection: None, shallow: remote::fetch::Shallow::NoChange, + filter: None, ref_name: None, }) } diff --git a/gix/src/remote/connection/fetch/mod.rs b/gix/src/remote/connection/fetch/mod.rs index 6efb6be4f73..6253bea89e1 100644 --- a/gix/src/remote/connection/fetch/mod.rs +++ b/gix/src/remote/connection/fetch/mod.rs @@ -12,6 +12,7 @@ use crate::{ }, Progress, }; +use gix_hash::ObjectId; mod error; pub use error::Error; @@ -153,6 +154,8 @@ where reflog_message: None, write_packed_refs: WritePackedRefs::Never, shallow: Default::default(), + filter: None, + additional_wants: Vec::new(), }) } } @@ -184,6 +187,8 @@ where reflog_message: Option, write_packed_refs: WritePackedRefs, shallow: remote::fetch::Shallow, + filter: Option, + additional_wants: Vec, } /// Builder @@ -225,4 +230,22 @@ where self.shallow = shallow; self } + + /// Ask the server to apply `filter` when sending objects. + pub fn with_filter(mut self, filter: Option) -> Self { + self.filter = filter; + self + } + + /// Request that the server also sends the objects identified by the given object ids. + /// + /// Objects already present locally will be ignored during negotiation. + pub fn with_additional_wants(mut self, wants: impl IntoIterator) -> Self { + for want in wants { + if !self.additional_wants.contains(&want) { + self.additional_wants.push(want); + } + } + self + } } diff --git a/gix/src/remote/connection/fetch/receive_pack.rs b/gix/src/remote/connection/fetch/receive_pack.rs index 2645874b354..2967896d51a 100644 --- a/gix/src/remote/connection/fetch/receive_pack.rs +++ b/gix/src/remote/connection/fetch/receive_pack.rs @@ -1,7 +1,7 @@ use std::{ops::DerefMut, path::PathBuf, sync::atomic::AtomicBool}; use gix_odb::store::RefreshMode; -use gix_protocol::fetch::{negotiate, Arguments}; +use gix_protocol::fetch::{negotiate, refmap, Arguments, Shallow}; #[cfg(feature = "async-network-client")] use gix_transport::client::async_io::Transport; #[cfg(feature = "blocking-network-client")] @@ -77,7 +77,7 @@ where P::SubProgress: 'static, { let ref_map = &self.ref_map; - if ref_map.is_missing_required_mapping() { + if self.additional_wants.is_empty() && ref_map.is_missing_required_mapping() { let mut specs = ref_map.refspecs.clone(); specs.extend(ref_map.extra_refspecs.clone()); return Err(Error::NoMapping { @@ -98,6 +98,12 @@ where }); } + let filter_argument = self + .filter + .as_ref() + .map(crate::remote::fetch::ObjectFilter::to_argument_string) + .filter(|_| self.additional_wants.is_empty()); + let fetch_options = gix_protocol::fetch::Options { shallow_file: repo.shallow_file(), shallow: &self.shallow, @@ -109,6 +115,7 @@ where .map(|val| Clone::REJECT_SHALLOW.enrich_error(val)) .transpose()? .unwrap_or(false), + filter: filter_argument.as_deref(), }; let context = gix_protocol::fetch::Context { handshake: &mut handshake, @@ -147,6 +154,7 @@ where tags: con.remote.fetch_tags, negotiator, open_options: repo.options.clone(), + additional_wants: &self.additional_wants, }; let write_pack_options = gix_pack::bundle::write::Options { @@ -248,11 +256,12 @@ struct Negotiate<'a, 'b, 'c> { tags: gix_protocol::fetch::Tags, negotiator: Box, open_options: crate::open::Options, + additional_wants: &'a [gix_hash::ObjectId], } impl gix_protocol::fetch::Negotiate for Negotiate<'_, '_, '_> { fn mark_complete_and_common_ref(&mut self) -> Result { - negotiate::mark_complete_and_common_ref( + let action = negotiate::mark_complete_and_common_ref( &self.objects, self.refs, { @@ -274,18 +283,71 @@ impl gix_protocol::fetch::Negotiate for Negotiate<'_, '_, '_> { self.ref_map, self.shallow, negotiate::make_refmapping_ignore_predicate(self.tags, self.ref_map), - ) + )?; + + if self.additional_wants.is_empty() { + return Ok(action); + } + + Ok(match action { + negotiate::Action::MustNegotiate { + remote_ref_target_known, + } => negotiate::Action::MustNegotiate { + remote_ref_target_known, + }, + negotiate::Action::NoChange | negotiate::Action::SkipToRefUpdate => negotiate::Action::MustNegotiate { + remote_ref_target_known: std::iter::repeat_n(true, self.ref_map.mappings.len()).collect(), + }, + }) } fn add_wants(&mut self, arguments: &mut Arguments, remote_ref_target_known: &[bool]) -> bool { - negotiate::add_wants( - self.objects, - arguments, - self.ref_map, - remote_ref_target_known, - self.shallow, - negotiate::make_refmapping_ignore_predicate(self.tags, self.ref_map), - ) + if self.additional_wants.is_empty() { + let mut has_wants = negotiate::add_wants( + self.objects, + arguments, + self.ref_map, + remote_ref_target_known, + self.shallow, + negotiate::make_refmapping_ignore_predicate(self.tags, self.ref_map), + ); + + for want in self.additional_wants { + arguments.want(want); + has_wants = true; + } + + return has_wants; + } + + let mut has_wants = false; + let mapping_is_ignored = negotiate::make_refmapping_ignore_predicate(self.tags, self.ref_map); + let is_shallow = !matches!(self.shallow, Shallow::NoChange); + for (mapping, known) in self.ref_map.mappings.iter().zip(remote_ref_target_known) { + if !is_shallow && *known { + continue; + } + if mapping_is_ignored(mapping) { + continue; + } + + if !arguments.can_use_ref_in_want() || matches!(mapping.remote, refmap::Source::ObjectId(_)) { + if let Some(id) = mapping.remote.as_id() { + arguments.want(id); + has_wants = true; + } + } else if let Some(name) = mapping.remote.as_name() { + arguments.want_ref(name); + has_wants = true; + } + } + + for want in self.additional_wants { + arguments.want(want); + has_wants = true; + } + + has_wants } fn one_round( @@ -294,12 +356,24 @@ impl gix_protocol::fetch::Negotiate for Negotiate<'_, '_, '_> { arguments: &mut Arguments, previous_response: Option<&gix_protocol::fetch::Response>, ) -> Result<(negotiate::Round, bool), negotiate::Error> { - negotiate::one_round( - self.negotiator.deref_mut(), - &mut *self.graph, - state, - arguments, - previous_response, - ) + if self.additional_wants.is_empty() { + return negotiate::one_round( + self.negotiator.deref_mut(), + &mut *self.graph, + state, + arguments, + previous_response, + ); + } + + Ok(( + negotiate::Round { + haves_sent: 0, + in_vain: 0, + haves_to_send: 0, + previous_response_had_at_least_one_in_common: false, + }, + true, + )) } } diff --git a/gix/src/remote/fetch.rs b/gix/src/remote/fetch.rs index d695c219c5c..a6ea09bb7b8 100644 --- a/gix/src/remote/fetch.rs +++ b/gix/src/remote/fetch.rs @@ -34,3 +34,6 @@ pub(crate) enum WritePackedRefs { #[cfg(any(feature = "blocking-network-client", feature = "async-network-client"))] pub use gix_protocol::fetch::{refmap, RefMap}; pub use gix_protocol::fetch::{Shallow, Tags}; + +mod object_filter; +pub use object_filter::{ObjectFilter, ParseError as ObjectFilterParseError}; diff --git a/gix/src/remote/fetch/object_filter.rs b/gix/src/remote/fetch/object_filter.rs new file mode 100644 index 00000000000..9c5c03331f4 --- /dev/null +++ b/gix/src/remote/fetch/object_filter.rs @@ -0,0 +1,111 @@ +use std::{fmt, num::ParseIntError, str::FromStr}; + +/// Describe object filters to apply when requesting data from a remote. +/// +/// This type mirrors [`git clone --filter`](https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---filterltfilter-specgt) +/// and is currently limited to blob filters. Additional variants may be added in the future. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObjectFilter { + /// Exclude all blobs from the initial clone, downloading them lazily as needed. + BlobNone, + /// Limit blobs included in the clone to those smaller or equal to `limit` bytes. + BlobLimit(u64), +} + +impl ObjectFilter { + /// Render this filter into the argument string expected by remote servers. + pub fn to_argument_string(&self) -> String { + match self { + ObjectFilter::BlobNone => "blob:none".into(), + ObjectFilter::BlobLimit(limit) => format!("blob:limit={limit}"), + } + } +} + +impl fmt::Display for ObjectFilter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_argument_string()) + } +} + +/// Errors emitted when parsing [`ObjectFilter`] values from command-line input. +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + /// The provided filter specification was empty. + #[error("filter specification must not be empty")] + Empty, + /// The provided filter specification is not supported yet. + #[error("unsupported filter specification '{0}'")] + Unsupported(String), + /// The provided blob size limit could not be parsed as an integer. + #[error("invalid blob size limit '{value}'")] + InvalidBlobLimit { + /// The string that failed to parse. + value: String, + /// The underlying parse error. + source: ParseIntError, + }, +} + +impl FromStr for ObjectFilter { + type Err = ParseError; + + fn from_str(input: &str) -> Result { + let spec = input.trim(); + if spec.is_empty() { + return Err(ParseError::Empty); + } + if spec.eq_ignore_ascii_case("blob:none") { + return Ok(ObjectFilter::BlobNone); + } + if let Some(limit_str) = spec.strip_prefix("blob:limit=") { + let limit = limit_str + .parse::() + .map_err(|source| ParseError::InvalidBlobLimit { + value: limit_str.to_owned(), + source, + })?; + return Ok(ObjectFilter::BlobLimit(limit)); + } + Err(ParseError::Unsupported(spec.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::ObjectFilter; + + #[test] + fn parse_blob_none() { + assert_eq!("blob:none".parse::().ok(), Some(ObjectFilter::BlobNone)); + } + + #[test] + fn parse_blob_limit() { + assert_eq!( + "blob:limit=42".parse::().ok(), + Some(ObjectFilter::BlobLimit(42)) + ); + } + + #[test] + fn parse_invalid_limit() { + let err = "blob:limit=foo".parse::().unwrap_err(); + assert!(matches!(err, super::ParseError::InvalidBlobLimit { .. })); + } + + #[test] + fn parse_unsupported() { + let err = "tree:0".parse::().unwrap_err(); + assert!(matches!(err, super::ParseError::Unsupported(_))); + } + + #[test] + fn displays_git_equivalent_filter_strings() { + assert_eq!(ObjectFilter::BlobNone.to_argument_string(), "blob:none"); + assert_eq!(ObjectFilter::BlobNone.to_string(), "blob:none"); + assert_eq!(ObjectFilter::BlobLimit(7).to_argument_string(), "blob:limit=7"); + assert_eq!(ObjectFilter::BlobLimit(7).to_string(), "blob:limit=7"); + } +} diff --git a/gix/tests/gix/clone.rs b/gix/tests/gix/clone.rs index c24ae33f3d9..1d29048b031 100644 --- a/gix/tests/gix/clone.rs +++ b/gix/tests/gix/clone.rs @@ -1,4 +1,6 @@ use crate::{remote, util::restricted}; +#[cfg(any(feature = "async-network-client-async-std", feature = "blocking-network-client"))] +use gix_testtools::tempfile; #[cfg(all(feature = "worktree-mutation", feature = "blocking-network-client"))] mod blocking_io { @@ -765,6 +767,53 @@ mod blocking_io { } } +#[cfg(any(feature = "async-network-client-async-std", feature = "blocking-network-client"))] +#[test] +fn write_remote_to_local_config_file_persists_partial_clone_settings() -> crate::Result { + let tmp = tempfile::tempdir()?; + let repo: gix::Repository = gix::ThreadSafeRepository::init_opts( + tmp.path(), + gix::create::Kind::Bare, + gix::create::Options::default(), + restricted(), + )? + .to_thread_local(); + + let mut remote = repo.remote_at("https://example.com/upstream")?; + let config = gix::clone::fetch::write_remote_to_local_config_file(&mut remote, "origin".into(), Some("blob:none"))?; + + assert_eq!( + config.raw_value("remote.origin.partialclonefilter")?.as_ref(), + "blob:none" + ); + assert_eq!(config.raw_value("remote.origin.promisor")?.as_ref(), "true"); + assert_eq!(config.raw_value("extensions.partialclone")?.as_ref(), "origin"); + + Ok(()) +} + +#[cfg(any(feature = "async-network-client-async-std", feature = "blocking-network-client"))] +#[test] +fn write_remote_to_local_config_file_skips_partial_clone_settings_without_filter() -> crate::Result { + let tmp = tempfile::tempdir()?; + let repo: gix::Repository = gix::ThreadSafeRepository::init_opts( + tmp.path(), + gix::create::Kind::Bare, + gix::create::Options::default(), + restricted(), + )? + .to_thread_local(); + + let mut remote = repo.remote_at("https://example.com/upstream")?; + let config = gix::clone::fetch::write_remote_to_local_config_file(&mut remote, "origin".into(), None)?; + + assert!(config.raw_value("remote.origin.partialclonefilter").is_err()); + assert!(config.raw_value("remote.origin.promisor").is_err()); + assert!(config.raw_value("extensions.partialclone").is_err()); + + Ok(()) +} + #[test] fn clone_and_early_persist_without_receive() -> crate::Result { let tmp = gix_testtools::tempfile::TempDir::new()?; diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index 1ff9821d904..f3b591a3020 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -604,6 +604,7 @@ pub fn main() -> Result<()> { ref_name, remote, shallow, + filter, directory, }) => { let opts = core::repository::clone::Options { @@ -613,6 +614,7 @@ pub fn main() -> Result<()> { no_tags, ref_name, shallow: shallow.into(), + filter, }; prepare_and_run( "clone", diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index 8908244c8f7..38341782f39 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -691,6 +691,12 @@ pub mod clone { #[clap(flatten)] pub shallow: ShallowOptions, + /// Request the remote to omit certain objects when cloning, similar to `git clone --filter`. + /// + /// Currently supports `blob:none` and `blob:limit=`. + #[clap(long, value_name = "FILTER-SPEC")] + pub filter: Option, + /// The url of the remote to connect to, like `https://github.com/byron/gitoxide`. pub remote: OsString, diff --git a/tests/journey/gix.sh b/tests/journey/gix.sh index 2bf3f910d8f..958ae513b31 100644 --- a/tests/journey/gix.sh +++ b/tests/journey/gix.sh @@ -703,3 +703,212 @@ title "gix commit-graph" ) ) ) + +if [[ "$kind" != "small" && "$kind" != "async" ]]; then +# Testing repository -- TODO: Move to `GitoxideLabs` group eventually +testrepo_name="gitoxide-testing" +testrepo_url="https://github.com/staehle/gitoxide-testing.git" +# This repo has various tags with noted differences in README.md, all in form `vN` +# `v5` is latest on main and should be the default cloned +testrepo_v5_tag="v5" +testrepo_v5_commit="6764049e6687f76f02ea3ca7c944b8cbd998edf8" +testrepo_v4_tag="v4" +testrepo_v4_commit="38395858a056b739ec09242115cd1c668986a594" +testrepo_v3_tag="v3" +testrepo_v3_commit="dea0993ab43e3b0e6d1931fe0c10a85537292930" +testrepo_v2_tag="v2" +testrepo_v2_commit="0ccb2debcbc3d1cac5756221cf823df14fef3094" +testrepo_v1_tag="v1" +testrepo_v1_commit="e60ef56eb63c866f290675e2f920792ca202054e" +# This file exists in all versions, but with different content: +testrepo_common_file_name="version.txt" +# gix options: +gix_clone_blobless="--filter=blob:none" +gix_clone_limit="--filter=blob:limit=1024" + +title "gix clone (functional tests)" +(when "running functional clone tests" + title "gix clone with partial clone filters" + (with "blobless clone of $testrepo_name repository" + snapshot="$snapshot/blobless-clone" + testrepo_path="${testrepo_name}-bare-blobless" + testworktree_path="${testrepo_name}-worktree" + + (sandbox + # Test blobless bare clone with --filter=blob:none + it "creates a blobless (${gix_clone_blobless}) bare clone successfully" && { + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose clone --bare ${gix_clone_blobless} ${testrepo_url} ${testrepo_path} + } + (with "the cloned blobless bare repository" + (cd ${testrepo_path} + it "should be a bare repository" && { + expect_run $SUCCESSFULLY test -f HEAD + expect_run $SUCCESSFULLY test -d objects + expect_run $SUCCESSFULLY test -d refs + expect_run $WITH_FAILURE test -d .git + } + it "gix can see remote.origin.partialclonefilter configuration" && { + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose config remote.origin.promisor + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose config remote.origin.partialclonefilter + } + it "real git client also sees partialclonefilter" && { + WITH_SNAPSHOT="$snapshot/filter-config" \ + expect_run $SUCCESSFULLY git --no-pager config remote.origin.partialclonefilter + } + it "tag ${testrepo_v5_tag} has appropriate tree entries (all missing)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v5_tag}-tree-entries-missing" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v5_tag} + } + it "tag ${testrepo_v4_tag} has appropriate tree entries (all missing)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v4_tag}-tree-entries-missing" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v4_tag} + } + it "tag ${testrepo_v2_tag} has appropriate tree entries (all missing)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v2_tag}-tree-entries-missing" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v2_tag} + } + it "real git client can detect all the missing blobs" && { + WITH_SNAPSHOT="$snapshot/git-reported-missing-blobs" \ + expect_run $SUCCESSFULLY git --no-pager rev-list --objects --quiet --missing=print HEAD + } + ) + ) + + # Test creating a worktree from the bare blobless clone, using non-default tag + it "can create a worktree from the bare blobless clone for tag ${testrepo_v4_tag}" && { + (cd ${testrepo_path} + # Once gix supports 'worktree add', we can use that directly: + # expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose worktree add ../${testworktree_path} ${testrepo_v4_tag} + # Until then, use the real git client: + expect_run $SUCCESSFULLY git --no-pager worktree add ../${testworktree_path} ${testrepo_v4_tag} + ) + } + + (with "the created worktree (on tag ${testrepo_v4_tag})" + (cd ${testworktree_path} + it "should be a valid worktree" && { + expect_run $SUCCESSFULLY test -f .git + expect_run $SUCCESSFULLY test -f ${testrepo_common_file_name} + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose status + } + + WORKTREE_HEAD=$("$exe_plumbing" --no-verbose rev resolve HEAD) + it "HEAD should NOT match the bare repo" && { + BARE_HEAD=$(cd ../${testrepo_path} && "$exe_plumbing" --no-verbose rev resolve HEAD) + expect_run $SUCCESSFULLY test "$BARE_HEAD" != "$WORKTREE_HEAD" + } + it "HEAD should match the tag ${testrepo_v4_tag}'s commit ${testrepo_v4_commit}" && { + expect_run $SUCCESSFULLY test "$WORKTREE_HEAD" = "${testrepo_v4_commit}" + } + + it "should pass fsck validation" && { + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose fsck + } + + it "real git client can detect all the missing blobs for ${testrepo_v4_tag}" && { + WITH_SNAPSHOT="$snapshot/git-reported-missing-blobs-${testrepo_v4_tag}" \ + expect_run $SUCCESSFULLY git --no-pager rev-list --objects --quiet --missing=print ${testrepo_v4_tag} + } + it "real git client can detect all the missing blobs for ${testrepo_v5_tag}" && { + WITH_SNAPSHOT="$snapshot/git-reported-missing-blobs-${testrepo_v5_tag}" \ + expect_run $SUCCESSFULLY git --no-pager rev-list --objects --quiet --missing=print ${testrepo_v5_tag} + } + it "tag ${testrepo_v4_tag} has appropriate tree entries (all populated)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v4_tag}-tree-entries-populated" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v4_tag} + } + it "tag ${testrepo_v5_tag} has appropriate tree entries (partially populated)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v5_tag}-tree-entries-partial" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v5_tag} + } + it "tag ${testrepo_v2_tag} has appropriate tree entries (partially populated)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v2_tag}-tree-entries-partial" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v2_tag} + } + + it "accessing blobs for '${testrepo_common_file_name}' from different commits..." && { + (with "the gix client" + # try to access the current version.txt blob + it "succeeds in finding the ${testrepo_v4_tag} tag's blob for this file" && { + WITH_SNAPSHOT="$snapshot/cat-${testrepo_v4_tag}" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose cat HEAD:${testrepo_common_file_name} + } + # try to access an old revision of ${testrepo_common_file_name} + # the gix plumbing version should not have the blob, so it should fail + it "fails to find a blob for an older tag '${testrepo_v2_tag}' (${testrepo_v2_commit}) as we are blobless" && { + WITH_SNAPSHOT="$snapshot/cat-${testrepo_v2_tag}-blobless-failure" \ + expect_run $WITH_FAILURE "$exe_plumbing" --no-verbose cat ${testrepo_v2_commit}:${testrepo_common_file_name} + } + ) + (with "the git client" + # however, the real git client should be able to fetch the old revision: + it "real git succeeds in finding the ${testrepo_v2_tag} blob as it fetches by default" && { + WITH_SNAPSHOT="$snapshot/cat-${testrepo_v2_tag}" \ + expect_run $SUCCESSFULLY git --no-pager cat-file blob ${testrepo_v2_commit}:${testrepo_common_file_name} + } + ) + (with "the gix client (round 2)" + # try to access the newly fetched by the real git client ${testrepo_v2_commit} blob + it "succeeds in finding the ${testrepo_v2_tag} blob just populated by real git" && { + WITH_SNAPSHOT="$snapshot/cat-${testrepo_v2_tag}" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose cat ${testrepo_v2_commit}:${testrepo_common_file_name} + } + ## Now, we're going to do the same thing again, but for an even older revision, except use + ## the new gix explicit fetch functionality: + it "fails to find a blob for an older tag ${testrepo_v1_tag} (${testrepo_v1_commit})" && { + WITH_SNAPSHOT="$snapshot/cat-${testrepo_v1_tag}-blobless-failure" \ + expect_run $WITH_FAILURE "$exe_plumbing" --no-verbose cat ${testrepo_v1_commit}:${testrepo_common_file_name} + } + it "gix explicit fetch succeeds (and hopefully gets all blobs for this commit)" && { + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose fetch ${testrepo_v1_commit} + } + it "succeeds in finding the ${testrepo_v1_tag} blob" && { + WITH_SNAPSHOT="$snapshot/cat-${testrepo_v1_tag}" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose cat ${testrepo_v1_commit}:${testrepo_common_file_name} + } + ) + } + + it "tag ${testrepo_v2_tag} has appropriate tree entries (more partially populated)" && { + WITH_SNAPSHOT="$snapshot/${testrepo_v2_tag}-tree-entries-partial2" \ + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose tree entries -r -e ${testrepo_v2_tag} + } + ) + ) + ) + ) + + (with "blob-limit clone of gitoxide repository" + snapshot="$snapshot/bloblimit-clone" + testrepo_path="${testrepo_name}-bare-bloblimit" + + (sandbox + # Test blob-limit (--filter=blob:limit=1024) bare clone + it "creates a blob-limit (${gix_clone_limit}) bare clone successfully" && { + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose clone --bare ${gix_clone_limit} ${testrepo_url} ${testrepo_path} + } + + (with "the blob-limit cloned repository" + (cd ${testrepo_path} + it "should be a bare repository" && { + expect_run $SUCCESSFULLY test -f HEAD + expect_run $SUCCESSFULLY test -d objects + expect_run $SUCCESSFULLY test -d refs + expect_run $WITH_FAILURE test -d .git + } + + it "should have partial clone configuration with blob limit" && { + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose config remote.origin.promisor + expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose config remote.origin.partialclonefilter + } + + it "should have the expected blob limit filter configuration" && { + WITH_SNAPSHOT="$snapshot/blob-limit-config" \ + expect_run $SUCCESSFULLY git --no-pager config remote.origin.partialclonefilter + } + ) + ) + ) + ) +) +fi diff --git a/tests/snapshots/plumbing/blobless-clone/cat-v1 b/tests/snapshots/plumbing/blobless-clone/cat-v1 new file mode 100644 index 00000000000..6bf12102a94 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/cat-v1 @@ -0,0 +1 @@ +This is version 1.0 \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/cat-v1-blobless-failure b/tests/snapshots/plumbing/blobless-clone/cat-v1-blobless-failure new file mode 100644 index 00000000000..41b36680259 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/cat-v1-blobless-failure @@ -0,0 +1 @@ +Error: An object with id e36f61cfc53baa75c671b3b886b16b0356b8d89b could not be found \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/cat-v2 b/tests/snapshots/plumbing/blobless-clone/cat-v2 new file mode 100644 index 00000000000..dba71cfa306 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/cat-v2 @@ -0,0 +1 @@ +This is version 2.0 \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/cat-v2-blobless-failure b/tests/snapshots/plumbing/blobless-clone/cat-v2-blobless-failure new file mode 100644 index 00000000000..5fbc0aaf489 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/cat-v2-blobless-failure @@ -0,0 +1 @@ +Error: An object with id fbc193181ebd664e11b2e0c0055a9d7d6b089941 could not be found \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/cat-v4 b/tests/snapshots/plumbing/blobless-clone/cat-v4 new file mode 100644 index 00000000000..8f8fc51214c --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/cat-v4 @@ -0,0 +1 @@ +This is version 4.0 \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/filter-config b/tests/snapshots/plumbing/blobless-clone/filter-config new file mode 100644 index 00000000000..d324da277ac --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/filter-config @@ -0,0 +1 @@ +blob:none \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs b/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs new file mode 100644 index 00000000000..2891c1006a6 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs @@ -0,0 +1,13 @@ +?384ee07bc677f2030c2788767da69909546186e2 +?0b53ddd032ad64319afb05e071c1595e645caaf3 +?e36f61cfc53baa75c671b3b886b16b0356b8d89b +?b3739d65d0431dfda7cab9816b10e7ae3f2b1ce4 +?69860e4808da44346d6f45e314765e31eb4ec7f6 +?e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +?fbc193181ebd664e11b2e0c0055a9d7d6b089941 +?c7cca01111de42524e8eb43ccf1be9ce99c676da +?d3d28e6267b8eb1a4ad2b40bb5da414070668029 +?d9dc521728d0bad44569ad735d5f156d8b264bea +?dee3fb380a2db5531442de869bceaab135e06e4d +?bff05d9c71959dfd13fa437a2701b1e7bade84f7 +?38f663602b7633883135b851d32f191e341ed744 \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v4 b/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v4 new file mode 100644 index 00000000000..1020da77792 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v4 @@ -0,0 +1,6 @@ +?384ee07bc677f2030c2788767da69909546186e2 +?e36f61cfc53baa75c671b3b886b16b0356b8d89b +?b3739d65d0431dfda7cab9816b10e7ae3f2b1ce4 +?e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +?fbc193181ebd664e11b2e0c0055a9d7d6b089941 +?38f663602b7633883135b851d32f191e341ed744 \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v5 b/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v5 new file mode 100644 index 00000000000..3e36a1206f2 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/git-reported-missing-blobs-v5 @@ -0,0 +1,7 @@ +?384ee07bc677f2030c2788767da69909546186e2 +?e36f61cfc53baa75c671b3b886b16b0356b8d89b +?b3739d65d0431dfda7cab9816b10e7ae3f2b1ce4 +?e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +?fbc193181ebd664e11b2e0c0055a9d7d6b089941 +?d9dc521728d0bad44569ad735d5f156d8b264bea +?38f663602b7633883135b851d32f191e341ed744 \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-missing b/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-missing new file mode 100644 index 00000000000..b2a46b38b37 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-missing @@ -0,0 +1,4 @@ +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da LICENSE +BLOB d3d28e6267b8eb1a4ad2b40bb5da414070668029 README.md +BLOB e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 new-file-in-v2 +BLOB fbc193181ebd664e11b2e0c0055a9d7d6b089941 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial b/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial new file mode 100644 index 00000000000..3eeeb0c7f2c --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial @@ -0,0 +1,4 @@ +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da 1069 LICENSE +BLOB d3d28e6267b8eb1a4ad2b40bb5da414070668029 142 README.md +BLOB e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 new-file-in-v2 +BLOB fbc193181ebd664e11b2e0c0055a9d7d6b089941 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial2 b/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial2 new file mode 100644 index 00000000000..4db804cd7a7 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v2-tree-entries-partial2 @@ -0,0 +1,4 @@ +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da 1069 LICENSE +BLOB d3d28e6267b8eb1a4ad2b40bb5da414070668029 142 README.md +BLOB e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 new-file-in-v2 +BLOB fbc193181ebd664e11b2e0c0055a9d7d6b089941 20 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v4-tree-entries-missing b/tests/snapshots/plumbing/blobless-clone/v4-tree-entries-missing new file mode 100644 index 00000000000..dba269521f0 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v4-tree-entries-missing @@ -0,0 +1,6 @@ +BLOB dee3fb380a2db5531442de869bceaab135e06e4d CHANGELOG.md +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da LICENSE +BLOB d3d28e6267b8eb1a4ad2b40bb5da414070668029 README.md + EXE 0b53ddd032ad64319afb05e071c1595e645caaf3 a_script.sh +BLOB 69860e4808da44346d6f45e314765e31eb4ec7f6 another-new-file +BLOB bff05d9c71959dfd13fa437a2701b1e7bade84f7 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v4-tree-entries-populated b/tests/snapshots/plumbing/blobless-clone/v4-tree-entries-populated new file mode 100644 index 00000000000..2f03372539a --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v4-tree-entries-populated @@ -0,0 +1,6 @@ +BLOB dee3fb380a2db5531442de869bceaab135e06e4d 386 CHANGELOG.md +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da 1069 LICENSE +BLOB d3d28e6267b8eb1a4ad2b40bb5da414070668029 142 README.md + EXE 0b53ddd032ad64319afb05e071c1595e645caaf3 67 a_script.sh +BLOB 69860e4808da44346d6f45e314765e31eb4ec7f6 40 another-new-file +BLOB bff05d9c71959dfd13fa437a2701b1e7bade84f7 20 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v5-tree-entries-missing b/tests/snapshots/plumbing/blobless-clone/v5-tree-entries-missing new file mode 100644 index 00000000000..eebc8d4b7e1 --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v5-tree-entries-missing @@ -0,0 +1,5 @@ +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da LICENSE +BLOB d9dc521728d0bad44569ad735d5f156d8b264bea README.md + EXE 0b53ddd032ad64319afb05e071c1595e645caaf3 a_script.sh +BLOB 69860e4808da44346d6f45e314765e31eb4ec7f6 another-new-file +BLOB bff05d9c71959dfd13fa437a2701b1e7bade84f7 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/blobless-clone/v5-tree-entries-partial b/tests/snapshots/plumbing/blobless-clone/v5-tree-entries-partial new file mode 100644 index 00000000000..7ad21f07d4b --- /dev/null +++ b/tests/snapshots/plumbing/blobless-clone/v5-tree-entries-partial @@ -0,0 +1,5 @@ +BLOB c7cca01111de42524e8eb43ccf1be9ce99c676da 1069 LICENSE +BLOB d9dc521728d0bad44569ad735d5f156d8b264bea README.md + EXE 0b53ddd032ad64319afb05e071c1595e645caaf3 67 a_script.sh +BLOB 69860e4808da44346d6f45e314765e31eb4ec7f6 40 another-new-file +BLOB bff05d9c71959dfd13fa437a2701b1e7bade84f7 20 version.txt \ No newline at end of file diff --git a/tests/snapshots/plumbing/bloblimit-clone/blob-limit-config b/tests/snapshots/plumbing/bloblimit-clone/blob-limit-config new file mode 100644 index 00000000000..b962a2a20ad --- /dev/null +++ b/tests/snapshots/plumbing/bloblimit-clone/blob-limit-config @@ -0,0 +1 @@ +blob:limit=1024 \ No newline at end of file From bb48bae2fbc1d24e769f3d767d854605178757d0 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 1 Feb 2026 16:54:35 +0100 Subject: [PATCH 2/3] refactor - --- gitoxide-core/src/repository/fetch.rs | 3 +-- gix/src/clone/fetch/mod.rs | 7 +------ gix/src/clone/fetch/util.rs | 21 ++++++--------------- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/gitoxide-core/src/repository/fetch.rs b/gitoxide-core/src/repository/fetch.rs index ba36cc8a1d8..3289d99b6bf 100644 --- a/gitoxide-core/src/repository/fetch.rs +++ b/gitoxide-core/src/repository/fetch.rs @@ -59,9 +59,8 @@ pub(crate) mod function { let mut remote = crate::repository::remote::by_name_or_url(&repo, remote.as_deref())?; let mut wants = Vec::new(); let mut fetch_refspecs = Vec::new(); - let expected_hex_len = repo.object_hash().len_in_hex(); for spec in ref_specs { - if spec.len() == expected_hex_len { + if spec.len() == repo.object_hash().len_in_hex() { if let Ok(oid) = ObjectId::from_hex(spec.as_ref()) { wants.push(oid); continue; diff --git a/gix/src/clone/fetch/mod.rs b/gix/src/clone/fetch/mod.rs index 0315e34ac9e..08e640c003a 100644 --- a/gix/src/clone/fetch/mod.rs +++ b/gix/src/clone/fetch/mod.rs @@ -25,11 +25,6 @@ pub enum Error { source: crate::bstr::Utf8Error, remote_name: crate::bstr::BString, }, - #[error("Configuration value name {name:?} is invalid")] - ConfigValueName { - name: &'static str, - source: gix_config::parse::section::value_name::Error, - }, #[error(transparent)] ConfigSectionHeader(#[from] gix_config::parse::section::header::Error), #[error(transparent)] @@ -218,7 +213,7 @@ impl PrepareFetch { let filter_spec_to_save = self .filter .as_ref() - .map(crate::remote::fetch::ObjectFilter::to_argument_string); + .map(remote::fetch::ObjectFilter::to_argument_string); let config = util::write_remote_to_local_config_file(&mut remote, remote_name.clone(), filter_spec_to_save.as_deref())?; diff --git a/gix/src/clone/fetch/util.rs b/gix/src/clone/fetch/util.rs index 6a351f932f9..04720eaf086 100644 --- a/gix/src/clone/fetch/util.rs +++ b/gix/src/clone/fetch/util.rs @@ -38,25 +38,16 @@ pub fn write_remote_to_local_config_file( while remote_section.remove("partialclonefilter").is_some() {} while remote_section.remove("promisor").is_some() {} - let partial_clone_filter = ValueName::try_from("partialclonefilter").map_err(|err| Error::ConfigValueName { - name: "partialclonefilter", - source: err, - })?; - remote_section.push(partial_clone_filter, Some(BStr::new(filter_spec))); + let partial_clone_filter = ValueName::try_from("partialclonefilter").expect("known to be valid"); + remote_section.push(partial_clone_filter, Some(filter_spec.into())); - let promisor = ValueName::try_from("promisor").map_err(|err| Error::ConfigValueName { - name: "promisor", - source: err, - })?; - remote_section.push(promisor, Some(BStr::new("true"))); + let promisor = ValueName::try_from("promisor").expect("known to be valid"); + remote_section.push(promisor, Some("true".into())); let mut extensions_section = config.section_mut_or_create_new("extensions", None)?; - while extensions_section.remove("partialclone").is_some() {} + while extensions_section.remove("partialClone").is_some() {} - let partial_clone = ValueName::try_from("partialclone").map_err(|err| Error::ConfigValueName { - name: "partialclone", - source: err, - })?; + let partial_clone = ValueName::try_from("partialClone").expect("known to be valid"); extensions_section.push(partial_clone, Some(remote_name.as_ref())); } From 28464b51897a54ca7a0dafca7487069380cefc92 Mon Sep 17 00:00:00 2001 From: Sebastian Thiel Date: Sun, 1 Feb 2026 17:46:28 +0100 Subject: [PATCH 3/3] Turn `https://github.com/staehle/gitoxide-testing` into a local script to avoid the network. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../make_gitoxide_testing_repo.tar | Bin 0 -> 116736 bytes .../scripts/make_gitoxide_testing_repo.sh | 137 ++++++++++++++++++ tests/journey/gix.sh | 27 +++- tests/tools/src/main.rs | 19 ++- 4 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/generated-archives/make_gitoxide_testing_repo.tar create mode 100755 tests/fixtures/scripts/make_gitoxide_testing_repo.sh diff --git a/tests/fixtures/generated-archives/make_gitoxide_testing_repo.tar b/tests/fixtures/generated-archives/make_gitoxide_testing_repo.tar new file mode 100644 index 0000000000000000000000000000000000000000..623ff9e42541ca88fcceff795fd7d16f5512788f GIT binary patch literal 116736 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/scripts/make_gitoxide_testing_repo.sh b/tests/fixtures/scripts/make_gitoxide_testing_repo.sh new file mode 100755 index 00000000000..a7ef58f3031 --- /dev/null +++ b/tests/fixtures/scripts/make_gitoxide_testing_repo.sh @@ -0,0 +1,137 @@ +#!/bin/bash +# Reproduces the history and content of https://github.com/staehle/gitoxide-testing +# for use in journey tests without requiring network access. +# +# Note: This script is designed to be run via `jtt run-script` which sets up +# a controlled Git environment with fixed author/committer dates. The resulting +# commit hashes will differ from the original repository but the content and +# structure will be semantically equivalent. +set -eu -o pipefail + +git init -q + +# Configure the repository to allow partial clone filters when served via file:// +git config uploadpack.allowFilter true +git config uploadpack.allowAnySHA1InWant true + +# Initial commit (simulating GitHub's initial commit) +cat > LICENSE << 'EOF' +MIT License + +Copyright (c) 2026 Jake Staehle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +EOF + +cat > README.md << 'EOF' +# gitoxide-testing +example repo to use in gitoxide functional tests +EOF + +git add LICENSE README.md +git commit -q -m "Initial commit" + +# Version 1 commit +cat > README.md << 'EOF' +# gitoxide-testing +Example repo to use in gitoxide functional tests + +For use with journey tests in: https://github.com/GitoxideLabs/gitoxide + +EOF + +echo "This is version 1.0" > version.txt +git add README.md version.txt +git commit -q -m "version 1 commit" +git tag v1 + +# Version 2 commit +echo "This is version 2.0" > version.txt +touch new-file-in-v2 +git add version.txt new-file-in-v2 +git commit -q -m "version 2" +git tag v2 + +# Commit between versions - removes new-file-in-v2, adds another-new-file, updates version +echo "This is version 2.1" > version.txt +echo "This should exist starting in version 2" > another-new-file +git rm -q new-file-in-v2 +git add version.txt another-new-file +git commit -q -m "a commit between versions" + +# Adds a script (non-executable) +cat > a_script.sh << 'EOF' +#!/bin/sh + +echo "This is a script!" +echo "It does things!" + +exit 0 +EOF +git add a_script.sh +git commit -q -m "adds a script (non-executable)" + +# Version 3 commit +echo "This is version 3.0" > version.txt +git add version.txt +git commit -q -m "version 3" +git tag v3 + +# Make script executable +chmod +x a_script.sh +git add a_script.sh +git commit -q -m "just changes the file mode for a_script.sh from 644 to 755" + +# Version 4 commit +echo "This is version 4.0" > version.txt + +cat > CHANGELOG.md << 'EOF' +# Summary of commits in this repo and what to test for: + +1. `v1`: Just adds `version.txt` and updates the README +2. `v2`: Immediately after v1, adds `new-file-in-v2`, and updates `version.txt` (all tags should have bumped versions of this) +3. `v3`: Has three commits after v2, adds `another-new-file` and a non-executable script `a_script.sh` +4. `v4`: Fixes the script to be executable +EOF + +git add version.txt CHANGELOG.md +git commit -q -m "version 4" +git tag v4 + +# Version 5 commit +cat > README.md << 'EOF' +# gitoxide-testing +Example repo to use in gitoxide functional tests + +For use with journey tests in: https://github.com/GitoxideLabs/gitoxide + +# Summary of commits in this repo and what to test for: + +1. `v1`: Just adds `version.txt` and updates the README +2. `v2`: Immediately after v1, adds `new-file-in-v2`, and updates `version.txt` (all tags should have bumped versions of this) +3. `v3`: Has three commits after v2, adds `another-new-file` and a non-executable script `a_script.sh` +4. `v4`: Fixes the script to be executable +5. `v5`: Removes `another-new-file` and moves the changelog to readme +EOF + +# Note: Despite the README saying v5 removes another-new-file, the original repo still has it at v5 +git rm -q CHANGELOG.md +git add README.md +git commit -q -m "version 5" +git tag v5 diff --git a/tests/journey/gix.sh b/tests/journey/gix.sh index 958ae513b31..4d1e34fe538 100644 --- a/tests/journey/gix.sh +++ b/tests/journey/gix.sh @@ -705,21 +705,17 @@ title "gix commit-graph" ) if [[ "$kind" != "small" && "$kind" != "async" ]]; then -# Testing repository -- TODO: Move to `GitoxideLabs` group eventually +# Testing repository - local reproduction of https://github.com/staehle/gitoxide-testing testrepo_name="gitoxide-testing" -testrepo_url="https://github.com/staehle/gitoxide-testing.git" +# Path relative to tests/fixtures/ for use with jtt run-script +testrepo_fixture_script="scripts/make_gitoxide_testing_repo.sh" # This repo has various tags with noted differences in README.md, all in form `vN` # `v5` is latest on main and should be the default cloned testrepo_v5_tag="v5" -testrepo_v5_commit="6764049e6687f76f02ea3ca7c944b8cbd998edf8" testrepo_v4_tag="v4" -testrepo_v4_commit="38395858a056b739ec09242115cd1c668986a594" testrepo_v3_tag="v3" -testrepo_v3_commit="dea0993ab43e3b0e6d1931fe0c10a85537292930" testrepo_v2_tag="v2" -testrepo_v2_commit="0ccb2debcbc3d1cac5756221cf823df14fef3094" testrepo_v1_tag="v1" -testrepo_v1_commit="e60ef56eb63c866f290675e2f920792ca202054e" # This file exists in all versions, but with different content: testrepo_common_file_name="version.txt" # gix options: @@ -735,6 +731,17 @@ title "gix clone (functional tests)" testworktree_path="${testrepo_name}-worktree" (sandbox + # Create/reuse the source repository using the fixture script (cached read-only) + testrepo_source=$("$jtt" run-script "$root/.." "$testrepo_fixture_script") + testrepo_url="file://${testrepo_source}" + + # Resolve commit hashes from tags dynamically + testrepo_v5_commit=$(cd "${testrepo_source}" && git rev-parse ${testrepo_v5_tag}) + testrepo_v4_commit=$(cd "${testrepo_source}" && git rev-parse ${testrepo_v4_tag}) + testrepo_v3_commit=$(cd "${testrepo_source}" && git rev-parse ${testrepo_v3_tag}) + testrepo_v2_commit=$(cd "${testrepo_source}" && git rev-parse ${testrepo_v2_tag}) + testrepo_v1_commit=$(cd "${testrepo_source}" && git rev-parse ${testrepo_v1_tag}) + # Test blobless bare clone with --filter=blob:none it "creates a blobless (${gix_clone_blobless}) bare clone successfully" && { expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose clone --bare ${gix_clone_blobless} ${testrepo_url} ${testrepo_path} @@ -797,7 +804,7 @@ title "gix clone (functional tests)" BARE_HEAD=$(cd ../${testrepo_path} && "$exe_plumbing" --no-verbose rev resolve HEAD) expect_run $SUCCESSFULLY test "$BARE_HEAD" != "$WORKTREE_HEAD" } - it "HEAD should match the tag ${testrepo_v4_tag}'s commit ${testrepo_v4_commit}" && { + it "HEAD should match the tag ${testrepo_v4_tag}'s commit" && { expect_run $SUCCESSFULLY test "$WORKTREE_HEAD" = "${testrepo_v4_commit}" } @@ -883,6 +890,10 @@ title "gix clone (functional tests)" testrepo_path="${testrepo_name}-bare-bloblimit" (sandbox + # Create/reuse the source repository using the fixture script (cached read-only) + testrepo_source=$("$jtt" run-script "$root/.." "$testrepo_fixture_script") + testrepo_url="file://${testrepo_source}" + # Test blob-limit (--filter=blob:limit=1024) bare clone it "creates a blob-limit (${gix_clone_limit}) bare clone successfully" && { expect_run $SUCCESSFULLY "$exe_plumbing" --no-verbose clone --bare ${gix_clone_limit} ${testrepo_url} ${testrepo_path} diff --git a/tests/tools/src/main.rs b/tests/tools/src/main.rs index 813fcce3ca7..4c9c07bbd63 100644 --- a/tests/tools/src/main.rs +++ b/tests/tools/src/main.rs @@ -22,12 +22,29 @@ fn umask() -> io::Result<()> { Ok(()) } -fn main() -> Result<(), Box> { +/// Run a fixture script and print the path to the resulting read-only directory. +/// The directory is cached and reused across invocations. +/// `cwd` - the directory to change to before running the script (should be repository root) +/// `script_path` - path relative to tests/fixtures/ +fn run_script(cwd: PathBuf, script_path: PathBuf) -> Result<(), Box> { + std::env::set_current_dir(&cwd)?; + let fixture_path = gix_testtools::scripted_fixture_read_only_with_args(script_path, None::)?; + // Return absolute path since caller may be in a different directory + println!("{}", cwd.join(fixture_path).display()); + Ok(()) +} + +fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let scmd = args.next().expect("sub command"); match &*scmd { "bash-program" | "bp" => bash_program()?, "mess-in-the-middle" => mess_in_the_middle(PathBuf::from(args.next().expect("path to file to mess with")))?, + "run-script" => { + let cwd = PathBuf::from(args.next().expect("working directory (repository root)")); + let script = PathBuf::from(args.next().expect("path to script relative to tests/fixtures/")); + run_script(cwd, script)?; + } #[cfg(unix)] "umask" => umask()?, _ => unreachable!("Unknown subcommand: {}", scmd),