Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gix-config-value/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
29 changes: 26 additions & 3 deletions gix-config-value/src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,30 @@ impl AsRef<BStr> for Path<'_> {

impl<'a> From<Cow<'a, BStr>> 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,
}
}
}
}

Expand All @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions gix-config-value/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
116 changes: 116 additions & 0 deletions gix-config-value/tests/value/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(_)));
}
}
14 changes: 13 additions & 1 deletion gix/src/config/cache/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +548 to +559
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Write tests for accessing optional configuration paths, as added here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 4 comprehensive tests for accessing optional configuration paths in commit 4ffcaa4:

  • optional_path_with_existing_file_is_returned - verifies optional paths with existing files return Some(Ok(path))
  • optional_path_with_missing_file_returns_none - verifies optional paths with missing files return None
  • required_path_with_existing_file_is_returned - verifies required paths with existing files work correctly
  • required_path_with_missing_file_returns_some_in_lenient_mode - verifies lenient mode behavior

The tests use the trusted_path() method on config snapshots and validate the :(optional) prefix behavior through the trusted_file_path() function. Test fixtures are generated with absolute paths using the make_config_repos.sh script.

}

pub(crate) fn home_dir(environment: crate::open::permissions::Environment) -> Option<PathBuf> {
Expand Down
6 changes: 6 additions & 0 deletions gix/src/config/snapshot/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions gix/tests/gix/repository/config/config_snapshot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")?;
Expand Down
16 changes: 16 additions & 0 deletions gix/tests/gix/repository/excludes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Loading