Skip to content

Commit b8d3d3e

Browse files
CopilotByron
andcommitted
feat!: Add support for :(optional) prefix for path values in gix-config-value
It's breaking because it adds the `optional` field to the `Path` type. Co-authored-by: Byron <[email protected]>
1 parent 42cece8 commit b8d3d3e

File tree

4 files changed

+192
-1
lines changed

4 files changed

+192
-1
lines changed

gix-config-value/src/path.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ use bstr::BStr;
44

55
use crate::Path;
66

7+
/// The prefix used to mark a path as optional in Git configuration files.
8+
const OPTIONAL_PREFIX: &[u8] = b":(optional)";
9+
710
///
811
pub mod interpolate {
912
use std::path::PathBuf;
@@ -108,11 +111,41 @@ impl AsRef<BStr> for Path<'_> {
108111

109112
impl<'a> From<Cow<'a, BStr>> for Path<'a> {
110113
fn from(value: Cow<'a, BStr>) -> Self {
111-
Path { value }
114+
// Check if the value starts with ":(optional)" prefix
115+
if value.starts_with(OPTIONAL_PREFIX) {
116+
// Strip the prefix while preserving the Cow variant for efficiency:
117+
// - Borrowed data remains borrowed (no allocation)
118+
// - Owned data is modified in-place using drain (no extra allocation)
119+
let stripped = match value {
120+
Cow::Borrowed(b) => Cow::Borrowed(&b[OPTIONAL_PREFIX.len()..]),
121+
Cow::Owned(mut b) => {
122+
b.drain(..OPTIONAL_PREFIX.len());
123+
Cow::Owned(b)
124+
}
125+
};
126+
Path {
127+
value: stripped,
128+
optional: true,
129+
}
130+
} else {
131+
Path {
132+
value,
133+
optional: false,
134+
}
135+
}
112136
}
113137
}
114138

115139
impl<'a> Path<'a> {
140+
/// Returns `true` if this path was prefixed with `:(optional)`.
141+
///
142+
/// Optional paths indicate that it's acceptable if the file doesn't exist.
143+
/// This is typically used for configuration like `blame.ignorerevsfile` where
144+
/// the file might not exist in all repositories.
145+
pub fn is_optional(&self) -> bool {
146+
self.optional
147+
}
148+
116149
/// Interpolates this path into a path usable on the file system.
117150
///
118151
/// If this path starts with `~/` or `~user/` or `%(prefix)/`

gix-config-value/src/types.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,32 @@ 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+
/// Use `is_optional()` method to check this value.
71+
pub(crate) optional: bool,
4872
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,23 @@ mod boolean;
1515
mod color;
1616
mod integer;
1717
mod path;
18+
19+
/// Ensure that the `:(optional)` prefix is only recognized for Path types, not for other types
20+
mod optional_prefix_only_for_paths {
21+
use std::borrow::Cow;
22+
use gix_config_value::{Boolean, Integer};
23+
24+
#[test]
25+
fn optional_prefix_not_recognized_in_boolean() {
26+
// Boolean should fail to parse this because it's not a valid boolean value
27+
let result = Boolean::try_from(Cow::Borrowed(crate::b(":(optional)true")));
28+
assert!(result.is_err(), "Boolean should not recognize :(optional) prefix");
29+
}
30+
31+
#[test]
32+
fn optional_prefix_not_recognized_in_integer() {
33+
// Integer should fail to parse this because it's not a valid integer value
34+
let result = Integer::try_from(Cow::Borrowed(crate::b(":(optional)42")));
35+
assert!(result.is_err(), "Integer should not recognize :(optional) prefix");
36+
}
37+
}

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,117 @@ 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 bstr::ByteSlice;
135+
use crate::{b, cow_str};
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!(path.value.as_ref(), b"~/config/file", "tilde should be preserved for interpolation");
163+
}
164+
165+
#[test]
166+
fn optional_prefix_with_prefix_substitution() {
167+
let path = gix_config_value::Path::from(cow_str(":(optional)%(prefix)/share/git"));
168+
assert!(path.is_optional());
169+
assert_eq!(path.value.as_ref(), b"%(prefix)/share/git", "prefix should be preserved for interpolation");
170+
}
171+
172+
#[test]
173+
fn optional_prefix_with_windows_path() {
174+
let path = gix_config_value::Path::from(cow_str(r":(optional)C:\Users\file"));
175+
assert!(path.is_optional());
176+
assert_eq!(path.value.as_ref(), br"C:\Users\file");
177+
}
178+
179+
#[test]
180+
fn optional_prefix_followed_by_empty_path() {
181+
let path = gix_config_value::Path::from(cow_str(":(optional)"));
182+
assert!(path.is_optional());
183+
assert_eq!(path.value.as_ref(), b"", "empty path after prefix is valid");
184+
}
185+
186+
#[test]
187+
fn partial_optional_string_is_not_treated_as_prefix() {
188+
let path = gix_config_value::Path::from(cow_str(":(opt)ional/path"));
189+
assert!(!path.is_optional(), "incomplete prefix should not be treated as optional marker");
190+
assert_eq!(path.value.as_ref(), b":(opt)ional/path");
191+
}
192+
193+
#[test]
194+
fn optional_prefix_case_sensitive() {
195+
let path = gix_config_value::Path::from(cow_str(":(OPTIONAL)/some/path"));
196+
assert!(!path.is_optional(), "prefix should be case-sensitive");
197+
assert_eq!(path.value.as_ref(), b":(OPTIONAL)/some/path");
198+
}
199+
200+
#[test]
201+
fn optional_prefix_with_spaces() {
202+
let path = gix_config_value::Path::from(cow_str(":(optional) /path/with/space"));
203+
assert!(path.is_optional());
204+
assert_eq!(path.value.as_ref(), b" /path/with/space", "space after prefix should be preserved");
205+
}
206+
207+
#[test]
208+
fn interpolate_preserves_optional_flag() -> crate::Result {
209+
use gix_config_value::path;
210+
211+
let path = gix_config_value::Path::from(cow_str(":(optional)/absolute/path"));
212+
assert!(path.is_optional());
213+
214+
let interpolated = path.interpolate(path::interpolate::Context::default())?;
215+
assert_eq!(interpolated.as_ref(), std::path::Path::new("/absolute/path"));
216+
217+
Ok(())
218+
}
219+
220+
#[test]
221+
fn borrowed_path_stays_borrowed_after_prefix_stripping() {
222+
// Verify that we don't unnecessarily allocate when stripping the prefix from borrowed data
223+
let borrowed_input: &[u8] = b":(optional)/some/path";
224+
let path = gix_config_value::Path::from(Cow::Borrowed(borrowed_input.as_bstr()));
225+
226+
assert!(path.is_optional());
227+
assert_eq!(path.value.as_ref(), b"/some/path");
228+
// Verify it's still borrowed (no unnecessary allocation)
229+
assert!(matches!(path.value, Cow::Borrowed(_)));
230+
}
231+
232+
#[test]
233+
fn owned_path_stays_owned_after_prefix_stripping() {
234+
// Verify that owned data remains owned after prefix stripping (but efficiently)
235+
let owned_input = bstr::BString::from(":(optional)/some/path");
236+
let path = gix_config_value::Path::from(Cow::Owned(owned_input));
237+
238+
assert!(path.is_optional());
239+
assert_eq!(path.value.as_ref(), b"/some/path");
240+
// Verify it's still owned
241+
assert!(matches!(path.value, Cow::Owned(_)));
242+
}
243+
}

0 commit comments

Comments
 (0)