diff --git a/gix-config-value/Cargo.toml b/gix-config-value/Cargo.toml index 16eae9faae9..aa1cdd2bfd8 100644 --- a/gix-config-value/Cargo.toml +++ b/gix-config-value/Cargo.toml @@ -12,7 +12,7 @@ rust-version = "1.82" include = ["src/**/*", "LICENSE-*"] [lib] -doctest = false +doctest = true [features] ## Data structures implement `serde::Serialize` and `serde::Deserialize`. diff --git a/gix-config-value/src/path.rs b/gix-config-value/src/path.rs index 8646a19de79..64b4c828959 100644 --- a/gix-config-value/src/path.rs +++ b/gix-config-value/src/path.rs @@ -108,7 +108,30 @@ impl AsRef for Path<'_> { impl<'a> From> for Path<'a> { fn from(value: Cow<'a, BStr>) -> Self { - Path { value } + /// The prefix used to mark a path as optional in Git configuration files. + const OPTIONAL_PREFIX: &[u8] = b":(optional)"; + + if value.starts_with(OPTIONAL_PREFIX) { + // Strip the prefix while preserving the Cow variant for efficiency: + // - Borrowed data remains borrowed (no allocation) + // - Owned data is modified in-place using drain (no extra allocation) + let stripped = match value { + Cow::Borrowed(b) => Cow::Borrowed(&b[OPTIONAL_PREFIX.len()..]), + Cow::Owned(mut b) => { + b.drain(..OPTIONAL_PREFIX.len()); + Cow::Owned(b) + } + }; + Path { + value: stripped, + is_optional: true, + } + } else { + Path { + value, + is_optional: false, + } + } } } @@ -125,8 +148,8 @@ impl<'a> Path<'a> { /// This location is not known at compile time and therefore need to be /// optionally provided by the caller through `git_install_dir`. /// - /// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if required input - /// wasn't provided. + /// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if the required + /// input wasn't provided. pub fn interpolate( self, interpolate::Context { diff --git a/gix-config-value/src/types.rs b/gix-config-value/src/types.rs index 232a55f3f92..e313960e32a 100644 --- a/gix-config-value/src/types.rs +++ b/gix-config-value/src/types.rs @@ -41,8 +41,35 @@ pub struct Boolean(pub bool); /// Any value that can be interpreted as a path to a resource on disk. /// /// Git represents file paths as byte arrays, modeled here as owned or borrowed byte sequences. +/// +/// ## Optional Paths +/// +/// Paths can be marked as optional by prefixing them with `:(optional)` in the configuration. +/// This indicates that it's acceptable if the file doesn't exist, which is useful for +/// configuration values like `blame.ignoreRevsFile` that may only exist in some repositories. +/// +/// ``` +/// use std::borrow::Cow; +/// use gix_config_value::Path; +/// use bstr::ByteSlice; +/// +/// // Regular path - file is expected to exist +/// let path = Path::from(Cow::Borrowed(b"/etc/gitconfig".as_bstr())); +/// assert!(!path.is_optional); +/// +/// // Optional path - it's okay if the file doesn't exist +/// let path = Path::from(Cow::Borrowed(b":(optional)~/.gitignore".as_bstr())); +/// assert!(path.is_optional); +/// assert_eq!(path.value.as_ref(), b"~/.gitignore"); // prefix is stripped +/// ``` #[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] pub struct Path<'a> { /// The path string, un-interpolated pub value: std::borrow::Cow<'a, bstr::BStr>, + /// Whether this path was prefixed with `:(optional)`, indicating it's acceptable if the file doesn't exist. + /// + /// Optional paths indicate that it's acceptable if the file doesn't exist. + /// This is typically used for configuration like `blame.ignorerevsfile` where + /// the file might not exist in all repositories. + pub is_optional: bool, } diff --git a/gix-config-value/tests/value/path.rs b/gix-config-value/tests/value/path.rs index 999c07b06f9..911e1b9db72 100644 --- a/gix-config-value/tests/value/path.rs +++ b/gix-config-value/tests/value/path.rs @@ -127,3 +127,119 @@ mod interpolate { std::env::current_dir().unwrap().join(name).into() } } + +mod optional_prefix { + use std::borrow::Cow; + + use crate::{b, cow_str}; + use bstr::ByteSlice; + + #[test] + fn path_without_optional_prefix_is_not_optional() { + let path = gix_config_value::Path::from(Cow::Borrowed(b("/some/path"))); + assert!(!path.is_optional, "path without prefix should not be optional"); + assert_eq!(path.value.as_ref(), b"/some/path"); + } + + #[test] + fn path_with_optional_prefix_is_optional() { + let path = gix_config_value::Path::from(cow_str(":(optional)/some/path")); + assert!(path.is_optional, "path with :(optional) prefix should be optional"); + assert_eq!(path.value.as_ref(), b"/some/path", "prefix should be stripped"); + } + + #[test] + fn optional_prefix_with_relative_path() { + let path = gix_config_value::Path::from(cow_str(":(optional)relative/path")); + assert!(path.is_optional); + assert_eq!(path.value.as_ref(), b"relative/path"); + } + + #[test] + fn optional_prefix_with_tilde_expansion() { + let path = gix_config_value::Path::from(cow_str(":(optional)~/config/file")); + assert!(path.is_optional); + assert_eq!( + path.value.as_ref(), + b"~/config/file", + "tilde should be preserved for interpolation" + ); + } + + #[test] + fn optional_prefix_with_prefix_substitution() { + let path = gix_config_value::Path::from(cow_str(":(optional)%(prefix)/share/git")); + assert!(path.is_optional); + assert_eq!( + path.value.as_ref(), + b"%(prefix)/share/git", + "prefix should be preserved for interpolation" + ); + } + + #[test] + fn optional_prefix_with_windows_path() { + let path = gix_config_value::Path::from(cow_str(r":(optional)C:\Users\file")); + assert!(path.is_optional); + assert_eq!(path.value.as_ref(), br"C:\Users\file"); + } + + #[test] + fn optional_prefix_followed_by_empty_path() { + let path = gix_config_value::Path::from(cow_str(":(optional)")); + assert!(path.is_optional); + assert_eq!(path.value.as_ref(), b"", "empty path after prefix is valid"); + } + + #[test] + fn partial_optional_string_is_not_treated_as_prefix() { + let path = gix_config_value::Path::from(cow_str(":(opt)ional/path")); + assert!( + !path.is_optional, + "incomplete prefix should not be treated as optional marker" + ); + assert_eq!(path.value.as_ref(), b":(opt)ional/path"); + } + + #[test] + fn optional_prefix_case_sensitive() { + let path = gix_config_value::Path::from(cow_str(":(OPTIONAL)/some/path")); + assert!(!path.is_optional, "prefix should be case-sensitive"); + assert_eq!(path.value.as_ref(), b":(OPTIONAL)/some/path"); + } + + #[test] + fn optional_prefix_with_spaces() { + let path = gix_config_value::Path::from(cow_str(":(optional) /path/with/space")); + assert!(path.is_optional); + assert_eq!( + path.value.as_ref(), + b" /path/with/space", + "space after prefix should be preserved" + ); + } + + #[test] + fn borrowed_path_stays_borrowed_after_prefix_stripping() { + // Verify that we don't unnecessarily allocate when stripping the prefix from borrowed data + let borrowed_input: &[u8] = b":(optional)/some/path"; + let path = gix_config_value::Path::from(Cow::Borrowed(borrowed_input.as_bstr())); + + assert!(path.is_optional); + assert_eq!(path.value.as_ref(), b"/some/path"); + // Verify it's still borrowed (no unnecessary allocation) + assert!(matches!(path.value, Cow::Borrowed(_))); + } + + #[test] + fn owned_path_stays_owned_after_prefix_stripping() { + // Verify that owned data remains owned after prefix stripping + let owned_input = bstr::BString::from(":(optional)/some/path"); + let path = gix_config_value::Path::from(Cow::Owned(owned_input)); + + assert!(path.is_optional); + assert_eq!(path.value.as_ref(), b"/some/path"); + // Verify it's still owned + assert!(matches!(path.value, Cow::Owned(_))); + } +} diff --git a/gix/src/config/cache/access.rs b/gix/src/config/cache/access.rs index 16bb2ce182b..42d2e9e9fea 100644 --- a/gix/src/config/cache/access.rs +++ b/gix/src/config/cache/access.rs @@ -544,7 +544,19 @@ pub(crate) fn trusted_file_path<'config>( let install_dir = crate::path::install_dir().ok(); let home = home_dir(environment); let ctx = config::cache::interpolate_context(install_dir.as_deref(), home.as_deref()); - Some(path.interpolate(ctx)) + + let is_optional = path.is_optional; + let res = path.interpolate(ctx); + if is_optional { + if let Ok(path) = &res { + // As opposed to Git, for a lack of the right error variant, we ignore everything that can't + // be stat'ed, instead of just checking if it doesn't exist via error code. + if path.metadata().is_err() { + return None; + } + } + } + Some(res) } pub(crate) fn home_dir(environment: crate::open::permissions::Environment) -> Option { diff --git a/gix/src/config/snapshot/access.rs b/gix/src/config/snapshot/access.rs index 91cbfc84d8b..22ee3dd9f81 100644 --- a/gix/src/config/snapshot/access.rs +++ b/gix/src/config/snapshot/access.rs @@ -54,6 +54,12 @@ impl<'repo> Snapshot<'repo> { /// Return the trusted and fully interpolated path at `key`, or `None` if there is no such value /// or if no value was found in a trusted file. /// An error occurs if the path could not be interpolated to its final value. + /// + /// ### Optional paths + /// + /// The path can be prefixed with `:(optional)` which means it won't be returned if the interpolated + /// path couldn't be accessed. Note also that this is different from Git, which ignores it only if + /// it doesn't exist. pub fn trusted_path( &self, key: impl gix_config::AsKey, diff --git a/gix/tests/gix/repository/config/config_snapshot/mod.rs b/gix/tests/gix/repository/config/config_snapshot/mod.rs index 60ac133b926..822e0b8fa13 100644 --- a/gix/tests/gix/repository/config/config_snapshot/mod.rs +++ b/gix/tests/gix/repository/config/config_snapshot/mod.rs @@ -33,6 +33,33 @@ fn commit_auto_rollback() -> crate::Result { Ok(()) } +mod trusted_path { + use crate::util::named_repo; + + #[test] + fn optional_is_respected() -> crate::Result { + let mut repo: gix::Repository = named_repo("make_basic_repo.sh")?; + repo.config_snapshot_mut().set_raw_value(&"my.path", "does-not-exist")?; + + let actual = repo + .config_snapshot() + .trusted_path("my.path") + .transpose()? + .expect("is set"); + assert_eq!( + actual.as_ref(), + "does-not-exist", + "the path isn't evaluated by default, and may not exist" + ); + + repo.config_snapshot_mut() + .set_raw_value(&"my.path", ":(optional)does-not-exist")?; + let actual = repo.config_snapshot().trusted_path("my.path").transpose()?; + assert_eq!(actual, None, "non-existing paths aren't returned to the caller"); + Ok(()) + } +} + #[test] fn snapshot_mut_commit_and_forget() -> crate::Result { let mut repo: gix::Repository = named_repo("make_basic_repo.sh")?; diff --git a/gix/tests/gix/repository/excludes.rs b/gix/tests/gix/repository/excludes.rs index 60f70beed96..ce0ac39bb4e 100644 --- a/gix/tests/gix/repository/excludes.rs +++ b/gix/tests/gix/repository/excludes.rs @@ -27,3 +27,19 @@ fn empty_core_excludes() -> crate::Result { .expect("empty paths are now just skipped"); Ok(()) } + +#[test] +fn missing_core_excludes_is_ignored() -> crate::Result { + let mut repo = named_subrepo_opts( + "make_basic_repo.sh", + "empty-core-excludes", + gix::open::Options::default().strict_config(true), + )?; + repo.config_snapshot_mut() + .set_value(&gix::config::tree::Core::EXCLUDES_FILE, "definitely-missing")?; + + let index = repo.index_or_empty()?; + repo.excludes(&index, None, Source::WorktreeThenIdMappingIfNotSkipped) + .expect("the call works as missing excludes files are ignored"); + Ok(()) +}