Skip to content

Commit c902e71

Browse files
committed
feat!: Add experimental notion of *precious* files.
*Precious* files are ignored files, but those that are not expendable. By default, all ignored files are expendable, but now it's possible to declare ignored files as precious, meaning they will not be removed just like untracked files. See [the technical document][1] for details. [1]: newren/git@0e6e3a6?diff=unified&w=0
1 parent fb3f809 commit c902e71

File tree

8 files changed

+154
-29
lines changed

8 files changed

+154
-29
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-ignore/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ serde = ["dep:serde", "bstr/serde", "gix-glob/serde"]
1919
[dependencies]
2020
gix-glob = { version = "^0.14.1", path = "../gix-glob" }
2121
gix-path = { version = "^0.10.1", path = "../gix-path" }
22+
gix-trace = { version = "^0.1.4", path = "../gix-trace" }
2223

2324
bstr = { version = "1.3.0", default-features = false, features = ["std", "unicode"]}
2425
unicode-bom = "2.0.2"

gix-ignore/src/lib.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,25 @@ pub struct Search {
2525
pub patterns: Vec<gix_glob::search::pattern::List<search::Ignore>>,
2626
}
2727

28+
/// The kind of *ignored* item.
29+
///
30+
/// This classification is obtained when checking if a path matches an ignore pattern.
31+
#[derive(Default, Copy, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug)]
32+
pub enum Kind {
33+
/// The item is ignored and will be removed to make place for tracked items that are to be checked out.
34+
///
35+
/// This is the default for ignored items.
36+
/// Another way of thinking about this class is to consider these files *trashable*, or talk about them as `ignored-and-expendable`.
37+
#[default]
38+
Expendable,
39+
/// An ignored file was additionally marked as *precious* using the `$` prefix to indicate the file shall be kept.
40+
///
41+
/// This means that precious files are treated like untracked files, which also must not be removed, but won't show up by default
42+
/// as they are also ignored.
43+
/// One can also talk about them as `ignored-and-precious`.
44+
Precious,
45+
}
46+
2847
///
2948
pub mod parse;
3049

gix-ignore/src/parse.rs

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,43 @@ impl<'a> Lines<'a> {
1818
}
1919

2020
impl<'a> Iterator for Lines<'a> {
21-
type Item = (gix_glob::Pattern, usize);
21+
type Item = (gix_glob::Pattern, usize, crate::Kind);
2222

2323
fn next(&mut self) -> Option<Self::Item> {
24-
for line in self.lines.by_ref() {
24+
for mut line in self.lines.by_ref() {
2525
self.line_no += 1;
26-
if line.first() == Some(&b'#') {
27-
continue;
28-
}
29-
match gix_glob::Pattern::from_bytes(truncate_non_escaped_trailing_spaces(line)) {
26+
let first = match line.first().copied() {
27+
Some(b'#') | None => continue,
28+
Some(c) => c,
29+
};
30+
let (kind, can_negate) = if first == b'$' {
31+
line = &line[1..];
32+
(crate::Kind::Precious, false)
33+
} else {
34+
let second = line.get(1);
35+
if first == b'!' && second == Some(&b'$') {
36+
gix_trace::error!(
37+
"Line {} starts with !$ which is not allowed ('{}')",
38+
self.line_no,
39+
line.as_bstr()
40+
);
41+
continue;
42+
}
43+
if first == b'\\' && second == Some(&b'$') {
44+
line = &line[1..];
45+
}
46+
(crate::Kind::Expendable, true)
47+
};
48+
49+
line = truncate_non_escaped_trailing_spaces(line);
50+
let res = if can_negate {
51+
gix_glob::Pattern::from_bytes(line)
52+
} else {
53+
gix_glob::Pattern::from_bytes_without_negation(line)
54+
};
55+
match res {
3056
None => continue,
31-
Some(pattern) => return Some((pattern, self.line_no)),
57+
Some(pattern) => return Some((pattern, self.line_no, kind)),
3258
}
3359
}
3460
None

gix-ignore/src/search.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ pub struct Match<'a> {
1515
pub pattern: &'a gix_glob::Pattern,
1616
/// The path to the source from which the pattern was loaded, or `None` if it was specified by other means.
1717
pub source: Option<&'a Path>,
18+
/// The kind of pattern this match represents.
19+
pub kind: crate::Kind,
1820
/// The line at which the pattern was found in its `source` file, or the occurrence in which it was provided.
1921
pub sequence_number: usize,
2022
}
@@ -24,13 +26,13 @@ pub struct Match<'a> {
2426
pub struct Ignore;
2527

2628
impl Pattern for Ignore {
27-
type Value = ();
29+
type Value = crate::Kind;
2830

2931
fn bytes_to_patterns(bytes: &[u8], _source: &std::path::Path) -> Vec<pattern::Mapping<Self::Value>> {
3032
crate::parse(bytes)
31-
.map(|(pattern, line_number)| pattern::Mapping {
33+
.map(|(pattern, line_number, kind)| pattern::Mapping {
3234
pattern,
33-
value: (),
35+
value: kind,
3436
sequence_number: line_number,
3537
})
3638
.collect()
@@ -61,7 +63,7 @@ impl Search {
6163
Ok(group)
6264
}
6365

64-
/// Parse a list of patterns, using slashes as path separators
66+
/// Parse a list of ignore patterns, using slashes as path separators.
6567
pub fn from_overrides(patterns: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
6668
Self::from_overrides_inner(&mut patterns.into_iter().map(Into::into))
6769
}
@@ -73,11 +75,13 @@ impl Search {
7375
.enumerate()
7476
.filter_map(|(seq_id, pattern)| {
7577
let pattern = gix_path::try_into_bstr(PathBuf::from(pattern)).ok()?;
76-
gix_glob::parse(pattern.as_ref()).map(|p| pattern::Mapping {
77-
pattern: p,
78-
value: (),
79-
sequence_number: seq_id,
80-
})
78+
crate::parse(pattern.as_ref())
79+
.next()
80+
.map(|(p, _seq_id, kind)| pattern::Mapping {
81+
pattern: p,
82+
value: kind,
83+
sequence_number: seq_id + 1,
84+
})
8185
})
8286
.collect(),
8387
source: None,
@@ -112,7 +116,7 @@ pub fn pattern_matching_relative_path<'a>(
112116
list.patterns.iter().rev().find_map(
113117
|pattern::Mapping {
114118
pattern,
115-
value: (),
119+
value: kind,
116120
sequence_number,
117121
}| {
118122
pattern
@@ -125,6 +129,7 @@ pub fn pattern_matching_relative_path<'a>(
125129
)
126130
.then_some(Match {
127131
pattern,
132+
kind: *kind,
128133
source: list.source.as_deref(),
129134
sequence_number: *sequence_number,
130135
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
$.config
2+
\$starts-with-dollar
3+
# html files are now precious and won't be discarded
4+
$*.html
5+
6+
!foo.html
7+
8+
# this isn't allowed and ignored
9+
!$foo.html
10+
11+
# but this is a literal !/* that is precious
12+
$!/*

gix-ignore/tests/parse/mod.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@ use bstr::BString;
22
use gix_glob::{pattern::Mode, Pattern};
33
use gix_testtools::fixture_bytes;
44

5+
#[test]
6+
fn precious() {
7+
let input = fixture_bytes("ignore/precious.txt");
8+
let actual: Vec<_> = gix_ignore::parse(&input).map(flat_map).collect();
9+
assert_eq!(
10+
actual,
11+
vec![
12+
pat_precious(".config", Mode::NO_SUB_DIR, 1),
13+
pat("$starts-with-dollar", Mode::NO_SUB_DIR, 2),
14+
pat_precious("*.html", Mode::NO_SUB_DIR | Mode::ENDS_WITH, 4),
15+
pat("foo.html", Mode::NO_SUB_DIR | Mode::NEGATIVE, 6),
16+
pat_precious("!/*", Mode::empty(), 12),
17+
]
18+
);
19+
}
20+
521
#[test]
622
fn byte_order_marks_are_no_patterns() {
723
assert_eq!(
@@ -58,7 +74,7 @@ fn backslashes_before_hashes_are_no_comments() {
5874

5975
#[test]
6076
fn trailing_spaces_can_be_escaped_to_be_literal() {
61-
fn parse_one(input: &str) -> (BString, Mode, usize) {
77+
fn parse_one(input: &str) -> (BString, Mode, usize, gix_ignore::Kind) {
6278
let actual: Vec<_> = gix_ignore::parse(input.as_bytes()).map(flat_map).collect();
6379
assert_eq!(actual.len(), 1, "{input:?} should match");
6480
actual.into_iter().next().expect("present")
@@ -101,14 +117,20 @@ fn trailing_spaces_can_be_escaped_to_be_literal() {
101117
);
102118
}
103119

104-
fn flatten(input: Option<(Pattern, usize)>) -> Option<(BString, gix_glob::pattern::Mode, usize)> {
120+
fn flatten(
121+
input: Option<(Pattern, usize, gix_ignore::Kind)>,
122+
) -> Option<(BString, gix_glob::pattern::Mode, usize, gix_ignore::Kind)> {
105123
input.map(flat_map)
106124
}
107125

108-
fn flat_map(input: (Pattern, usize)) -> (BString, gix_glob::pattern::Mode, usize) {
109-
(input.0.text, input.0.mode, input.1)
126+
fn flat_map(input: (Pattern, usize, gix_ignore::Kind)) -> (BString, gix_glob::pattern::Mode, usize, gix_ignore::Kind) {
127+
(input.0.text, input.0.mode, input.1, input.2)
128+
}
129+
130+
fn pat(pattern: &str, mode: Mode, pos: usize) -> (BString, Mode, usize, gix_ignore::Kind) {
131+
(pattern.into(), mode, pos, gix_ignore::Kind::Expendable)
110132
}
111133

112-
fn pat(pattern: &str, mode: Mode, pos: usize) -> (BString, Mode, usize) {
113-
(pattern.into(), mode, pos)
134+
fn pat_precious(pattern: &str, mode: Mode, pos: usize) -> (BString, Mode, usize, gix_ignore::Kind) {
135+
(pattern.into(), mode, pos, gix_ignore::Kind::Precious)
114136
}

gix-ignore/tests/search/mod.rs

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ fn baseline_from_git_dir() -> crate::Result {
8181
sequence_number,
8282
pattern: _,
8383
source,
84+
kind: gix_ignore::Kind::Expendable,
8485
}),
8586
Some((expected_source, line, _expected_pattern)),
8687
) => {
@@ -100,28 +101,66 @@ fn baseline_from_git_dir() -> crate::Result {
100101
}
101102

102103
#[test]
103-
fn from_overrides() {
104-
let input = ["simple", "pattern/"];
104+
fn from_overrides_with_precious() {
105+
let input = ["$s?mple", "pattern/"];
105106
let group = gix_ignore::Search::from_overrides(input.iter());
107+
108+
assert_eq!(
109+
group.pattern_matching_relative_path("Simple".into(), None, gix_glob::pattern::Case::Fold),
110+
Some(pattern_to_match(
111+
&gix_glob::parse("s?mple").unwrap(),
112+
1,
113+
gix_ignore::Kind::Precious
114+
)),
115+
""
116+
);
117+
}
118+
119+
#[test]
120+
fn from_overrides_with_excludes() {
121+
let group = gix_ignore::Search::from_overrides(["$simple", "!simple", "pattern/"]);
122+
assert_eq!(
123+
group.pattern_matching_relative_path("Simple".into(), None, gix_glob::pattern::Case::Fold),
124+
Some(pattern_to_match(
125+
&gix_glob::parse("!simple").unwrap(),
126+
2,
127+
gix_ignore::Kind::Expendable
128+
)),
129+
"Now the negative pattern matches - the sequence numbers are 1-based"
130+
);
131+
}
132+
133+
#[test]
134+
fn from_overrides() {
135+
let group = gix_ignore::Search::from_overrides(["simple", "pattern/"]);
106136
assert_eq!(
107137
group.pattern_matching_relative_path("Simple".into(), None, gix_glob::pattern::Case::Fold),
108-
Some(pattern_to_match(&gix_glob::parse("simple").unwrap(), 0))
138+
Some(pattern_to_match(
139+
&gix_glob::parse("simple").unwrap(),
140+
1,
141+
gix_ignore::Kind::Expendable
142+
))
109143
);
110144
assert_eq!(
111145
group.pattern_matching_relative_path("pattern".into(), Some(true), gix_glob::pattern::Case::Sensitive),
112-
Some(pattern_to_match(&gix_glob::parse("pattern/").unwrap(), 1))
146+
Some(pattern_to_match(
147+
&gix_glob::parse("pattern/").unwrap(),
148+
2,
149+
gix_ignore::Kind::Expendable
150+
))
113151
);
114152
assert_eq!(group.patterns.len(), 1);
115153
assert_eq!(
116-
gix_ignore::Search::from_overrides(input).patterns[0],
154+
gix_ignore::Search::from_overrides(["simple", "pattern/"]).patterns[0],
117155
group.patterns.into_iter().next().unwrap()
118156
);
119157
}
120158

121-
fn pattern_to_match(pattern: &gix_glob::Pattern, sequence_number: usize) -> Match<'_> {
159+
fn pattern_to_match(pattern: &gix_glob::Pattern, sequence_number: usize, kind: gix_ignore::Kind) -> Match<'_> {
122160
Match {
123161
pattern,
124162
source: None,
125163
sequence_number,
164+
kind,
126165
}
127166
}

0 commit comments

Comments
 (0)