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..3289d99b6bf 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,20 @@ 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(); + for spec in ref_specs { + if spec.len() == repo.object_hash().len_in_hex() { + 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 +78,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..08e640c003a 100644 --- a/gix/src/clone/fetch/mod.rs +++ b/gix/src/clone/fetch/mod.rs @@ -20,6 +20,13 @@ 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(transparent)] + ConfigSectionHeader(#[from] gix_config::parse::section::header::Error), #[error(transparent)] RemoteName(#[from] crate::config::remote::symbolic_name::Error), #[error(transparent)] @@ -203,7 +210,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(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 +295,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 +339,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..04720eaf086 100644 --- a/gix/src/clone/fetch/util.rs +++ b/gix/src/clone/fetch/util.rs @@ -16,13 +16,40 @@ 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").expect("known to be valid"); + remote_section.push(partial_clone_filter, Some(filter_spec.into())); + + 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() {} + + let partial_clone = ValueName::try_from("partialClone").expect("known to be valid"); + 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/fixtures/generated-archives/make_gitoxide_testing_repo.tar b/tests/fixtures/generated-archives/make_gitoxide_testing_repo.tar new file mode 100644 index 00000000000..623ff9e4254 Binary files /dev/null and b/tests/fixtures/generated-archives/make_gitoxide_testing_repo.tar differ 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 2bf3f910d8f..4d1e34fe538 100644 --- a/tests/journey/gix.sh +++ b/tests/journey/gix.sh @@ -703,3 +703,223 @@ title "gix commit-graph" ) ) ) + +if [[ "$kind" != "small" && "$kind" != "async" ]]; then +# Testing repository - local reproduction of https://github.com/staehle/gitoxide-testing +testrepo_name="gitoxide-testing" +# 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_v4_tag="v4" +testrepo_v3_tag="v3" +testrepo_v2_tag="v2" +testrepo_v1_tag="v1" +# 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 + # 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} + } + (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" && { + 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 + # 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} + } + + (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 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),