Skip to content

Commit e343ed9

Browse files
authored
Merge pull request #2283 from GitoxideLabs/copilot/add-optional-prefix-support
Add support for `:(optional)` prefix for path configuration values
2 parents 42cece8 + 580bee9 commit e343ed9

File tree

8 files changed

+232
-5
lines changed

8 files changed

+232
-5
lines changed

gix-config-value/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ rust-version = "1.82"
1212
include = ["src/**/*", "LICENSE-*"]
1313

1414
[lib]
15-
doctest = false
15+
doctest = true
1616

1717
[features]
1818
## Data structures implement `serde::Serialize` and `serde::Deserialize`.

gix-config-value/src/path.rs

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,30 @@ impl AsRef<BStr> for Path<'_> {
108108

109109
impl<'a> From<Cow<'a, BStr>> for Path<'a> {
110110
fn from(value: Cow<'a, BStr>) -> Self {
111-
Path { value }
111+
/// The prefix used to mark a path as optional in Git configuration files.
112+
const OPTIONAL_PREFIX: &[u8] = b":(optional)";
113+
114+
if value.starts_with(OPTIONAL_PREFIX) {
115+
// Strip the prefix while preserving the Cow variant for efficiency:
116+
// - Borrowed data remains borrowed (no allocation)
117+
// - Owned data is modified in-place using drain (no extra allocation)
118+
let stripped = match value {
119+
Cow::Borrowed(b) => Cow::Borrowed(&b[OPTIONAL_PREFIX.len()..]),
120+
Cow::Owned(mut b) => {
121+
b.drain(..OPTIONAL_PREFIX.len());
122+
Cow::Owned(b)
123+
}
124+
};
125+
Path {
126+
value: stripped,
127+
is_optional: true,
128+
}
129+
} else {
130+
Path {
131+
value,
132+
is_optional: false,
133+
}
134+
}
112135
}
113136
}
114137

@@ -125,8 +148,8 @@ impl<'a> Path<'a> {
125148
/// This location is not known at compile time and therefore need to be
126149
/// optionally provided by the caller through `git_install_dir`.
127150
///
128-
/// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if required input
129-
/// wasn't provided.
151+
/// Any other, non-empty path value is returned unchanged and error is returned in case of an empty path value or if the required
152+
/// input wasn't provided.
130153
pub fn interpolate(
131154
self,
132155
interpolate::Context {

gix-config-value/src/types.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,35 @@ pub struct Boolean(pub bool);
4141
/// Any value that can be interpreted as a path to a resource on disk.
4242
///
4343
/// Git represents file paths as byte arrays, modeled here as owned or borrowed byte sequences.
44+
///
45+
/// ## Optional Paths
46+
///
47+
/// Paths can be marked as optional by prefixing them with `:(optional)` in the configuration.
48+
/// This indicates that it's acceptable if the file doesn't exist, which is useful for
49+
/// configuration values like `blame.ignoreRevsFile` that may only exist in some repositories.
50+
///
51+
/// ```
52+
/// use std::borrow::Cow;
53+
/// use gix_config_value::Path;
54+
/// use bstr::ByteSlice;
55+
///
56+
/// // Regular path - file is expected to exist
57+
/// let path = Path::from(Cow::Borrowed(b"/etc/gitconfig".as_bstr()));
58+
/// assert!(!path.is_optional);
59+
///
60+
/// // Optional path - it's okay if the file doesn't exist
61+
/// let path = Path::from(Cow::Borrowed(b":(optional)~/.gitignore".as_bstr()));
62+
/// assert!(path.is_optional);
63+
/// assert_eq!(path.value.as_ref(), b"~/.gitignore"); // prefix is stripped
64+
/// ```
4465
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
4566
pub struct Path<'a> {
4667
/// The path string, un-interpolated
4768
pub value: std::borrow::Cow<'a, bstr::BStr>,
69+
/// Whether this path was prefixed with `:(optional)`, indicating it's acceptable if the file doesn't exist.
70+
///
71+
/// Optional paths indicate that it's acceptable if the file doesn't exist.
72+
/// This is typically used for configuration like `blame.ignorerevsfile` where
73+
/// the file might not exist in all repositories.
74+
pub is_optional: bool,
4875
}

gix-config-value/tests/value/path.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,119 @@ mod interpolate {
127127
std::env::current_dir().unwrap().join(name).into()
128128
}
129129
}
130+
131+
mod optional_prefix {
132+
use std::borrow::Cow;
133+
134+
use crate::{b, cow_str};
135+
use bstr::ByteSlice;
136+
137+
#[test]
138+
fn path_without_optional_prefix_is_not_optional() {
139+
let path = gix_config_value::Path::from(Cow::Borrowed(b("/some/path")));
140+
assert!(!path.is_optional, "path without prefix should not be optional");
141+
assert_eq!(path.value.as_ref(), b"/some/path");
142+
}
143+
144+
#[test]
145+
fn path_with_optional_prefix_is_optional() {
146+
let path = gix_config_value::Path::from(cow_str(":(optional)/some/path"));
147+
assert!(path.is_optional, "path with :(optional) prefix should be optional");
148+
assert_eq!(path.value.as_ref(), b"/some/path", "prefix should be stripped");
149+
}
150+
151+
#[test]
152+
fn optional_prefix_with_relative_path() {
153+
let path = gix_config_value::Path::from(cow_str(":(optional)relative/path"));
154+
assert!(path.is_optional);
155+
assert_eq!(path.value.as_ref(), b"relative/path");
156+
}
157+
158+
#[test]
159+
fn optional_prefix_with_tilde_expansion() {
160+
let path = gix_config_value::Path::from(cow_str(":(optional)~/config/file"));
161+
assert!(path.is_optional);
162+
assert_eq!(
163+
path.value.as_ref(),
164+
b"~/config/file",
165+
"tilde should be preserved for interpolation"
166+
);
167+
}
168+
169+
#[test]
170+
fn optional_prefix_with_prefix_substitution() {
171+
let path = gix_config_value::Path::from(cow_str(":(optional)%(prefix)/share/git"));
172+
assert!(path.is_optional);
173+
assert_eq!(
174+
path.value.as_ref(),
175+
b"%(prefix)/share/git",
176+
"prefix should be preserved for interpolation"
177+
);
178+
}
179+
180+
#[test]
181+
fn optional_prefix_with_windows_path() {
182+
let path = gix_config_value::Path::from(cow_str(r":(optional)C:\Users\file"));
183+
assert!(path.is_optional);
184+
assert_eq!(path.value.as_ref(), br"C:\Users\file");
185+
}
186+
187+
#[test]
188+
fn optional_prefix_followed_by_empty_path() {
189+
let path = gix_config_value::Path::from(cow_str(":(optional)"));
190+
assert!(path.is_optional);
191+
assert_eq!(path.value.as_ref(), b"", "empty path after prefix is valid");
192+
}
193+
194+
#[test]
195+
fn partial_optional_string_is_not_treated_as_prefix() {
196+
let path = gix_config_value::Path::from(cow_str(":(opt)ional/path"));
197+
assert!(
198+
!path.is_optional,
199+
"incomplete prefix should not be treated as optional marker"
200+
);
201+
assert_eq!(path.value.as_ref(), b":(opt)ional/path");
202+
}
203+
204+
#[test]
205+
fn optional_prefix_case_sensitive() {
206+
let path = gix_config_value::Path::from(cow_str(":(OPTIONAL)/some/path"));
207+
assert!(!path.is_optional, "prefix should be case-sensitive");
208+
assert_eq!(path.value.as_ref(), b":(OPTIONAL)/some/path");
209+
}
210+
211+
#[test]
212+
fn optional_prefix_with_spaces() {
213+
let path = gix_config_value::Path::from(cow_str(":(optional) /path/with/space"));
214+
assert!(path.is_optional);
215+
assert_eq!(
216+
path.value.as_ref(),
217+
b" /path/with/space",
218+
"space after prefix should be preserved"
219+
);
220+
}
221+
222+
#[test]
223+
fn borrowed_path_stays_borrowed_after_prefix_stripping() {
224+
// Verify that we don't unnecessarily allocate when stripping the prefix from borrowed data
225+
let borrowed_input: &[u8] = b":(optional)/some/path";
226+
let path = gix_config_value::Path::from(Cow::Borrowed(borrowed_input.as_bstr()));
227+
228+
assert!(path.is_optional);
229+
assert_eq!(path.value.as_ref(), b"/some/path");
230+
// Verify it's still borrowed (no unnecessary allocation)
231+
assert!(matches!(path.value, Cow::Borrowed(_)));
232+
}
233+
234+
#[test]
235+
fn owned_path_stays_owned_after_prefix_stripping() {
236+
// Verify that owned data remains owned after prefix stripping
237+
let owned_input = bstr::BString::from(":(optional)/some/path");
238+
let path = gix_config_value::Path::from(Cow::Owned(owned_input));
239+
240+
assert!(path.is_optional);
241+
assert_eq!(path.value.as_ref(), b"/some/path");
242+
// Verify it's still owned
243+
assert!(matches!(path.value, Cow::Owned(_)));
244+
}
245+
}

gix/src/config/cache/access.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,19 @@ pub(crate) fn trusted_file_path<'config>(
544544
let install_dir = crate::path::install_dir().ok();
545545
let home = home_dir(environment);
546546
let ctx = config::cache::interpolate_context(install_dir.as_deref(), home.as_deref());
547-
Some(path.interpolate(ctx))
547+
548+
let is_optional = path.is_optional;
549+
let res = path.interpolate(ctx);
550+
if is_optional {
551+
if let Ok(path) = &res {
552+
// As opposed to Git, for a lack of the right error variant, we ignore everything that can't
553+
// be stat'ed, instead of just checking if it doesn't exist via error code.
554+
if path.metadata().is_err() {
555+
return None;
556+
}
557+
}
558+
}
559+
Some(res)
548560
}
549561

550562
pub(crate) fn home_dir(environment: crate::open::permissions::Environment) -> Option<PathBuf> {

gix/src/config/snapshot/access.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ impl<'repo> Snapshot<'repo> {
5454
/// Return the trusted and fully interpolated path at `key`, or `None` if there is no such value
5555
/// or if no value was found in a trusted file.
5656
/// An error occurs if the path could not be interpolated to its final value.
57+
///
58+
/// ### Optional paths
59+
///
60+
/// The path can be prefixed with `:(optional)` which means it won't be returned if the interpolated
61+
/// path couldn't be accessed. Note also that this is different from Git, which ignores it only if
62+
/// it doesn't exist.
5763
pub fn trusted_path(
5864
&self,
5965
key: impl gix_config::AsKey,

gix/tests/gix/repository/config/config_snapshot/mod.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ fn commit_auto_rollback() -> crate::Result {
3333
Ok(())
3434
}
3535

36+
mod trusted_path {
37+
use crate::util::named_repo;
38+
39+
#[test]
40+
fn optional_is_respected() -> crate::Result {
41+
let mut repo: gix::Repository = named_repo("make_basic_repo.sh")?;
42+
repo.config_snapshot_mut().set_raw_value(&"my.path", "does-not-exist")?;
43+
44+
let actual = repo
45+
.config_snapshot()
46+
.trusted_path("my.path")
47+
.transpose()?
48+
.expect("is set");
49+
assert_eq!(
50+
actual.as_ref(),
51+
"does-not-exist",
52+
"the path isn't evaluated by default, and may not exist"
53+
);
54+
55+
repo.config_snapshot_mut()
56+
.set_raw_value(&"my.path", ":(optional)does-not-exist")?;
57+
let actual = repo.config_snapshot().trusted_path("my.path").transpose()?;
58+
assert_eq!(actual, None, "non-existing paths aren't returned to the caller");
59+
Ok(())
60+
}
61+
}
62+
3663
#[test]
3764
fn snapshot_mut_commit_and_forget() -> crate::Result {
3865
let mut repo: gix::Repository = named_repo("make_basic_repo.sh")?;

gix/tests/gix/repository/excludes.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,19 @@ fn empty_core_excludes() -> crate::Result {
2727
.expect("empty paths are now just skipped");
2828
Ok(())
2929
}
30+
31+
#[test]
32+
fn missing_core_excludes_is_ignored() -> crate::Result {
33+
let mut repo = named_subrepo_opts(
34+
"make_basic_repo.sh",
35+
"empty-core-excludes",
36+
gix::open::Options::default().strict_config(true),
37+
)?;
38+
repo.config_snapshot_mut()
39+
.set_value(&gix::config::tree::Core::EXCLUDES_FILE, "definitely-missing")?;
40+
41+
let index = repo.index_or_empty()?;
42+
repo.excludes(&index, None, Source::WorktreeThenIdMappingIfNotSkipped)
43+
.expect("the call works as missing excludes files are ignored");
44+
Ok(())
45+
}

0 commit comments

Comments
 (0)