gitoxide_core/repository/attributes/
validate_baseline.rs1use crate::OutputFormat;
2
3pub struct Options {
4 pub format: OutputFormat,
5 pub statistics: bool,
6 pub ignore: bool,
7}
8
9pub(crate) mod function {
10 use std::{
11 collections::BTreeSet,
12 io,
13 io::{BufRead, Write},
14 iter::Peekable,
15 ops::Sub,
16 path::PathBuf,
17 sync::atomic::Ordering,
18 };
19
20 use anyhow::{anyhow, bail};
21 use gix::{
22 Count, Progress,
23 attrs::{Assignment, NameRef},
24 bstr::{BString, ByteSlice},
25 };
26
27 use crate::{
28 OutputFormat,
29 repository::attributes::{query::attributes_cache, validate_baseline::Options},
30 };
31
32 pub fn validate_baseline(
33 repo: gix::Repository,
34 paths: Option<impl Iterator<Item = BString> + Send + 'static>,
35 mut progress: impl gix::NestedProgress + 'static,
36 mut out: impl io::Write,
37 mut err: impl io::Write,
38 Options {
39 format,
40 statistics,
41 mut ignore,
42 }: Options,
43 ) -> anyhow::Result<()> {
44 if format != OutputFormat::Human {
45 bail!("JSON output isn't implemented yet");
46 }
47
48 if repo.is_bare() {
49 writeln!(
50 err,
51 "Repo at '{repo}' is bare - disabling git-ignore baseline as `git check-ignore` needs a worktree",
52 repo = repo.path().display()
53 )
54 .ok();
55 ignore = false;
56 }
57 let mut num_entries = None;
58 let paths = paths.map_or_else(
59 {
60 let repo = repo.clone();
61 let num_entries = &mut num_entries;
62 move || -> anyhow::Result<_> {
63 let index = repo.index_or_load_from_head()?.into_owned();
64 let (entries, path_backing) = index.into_parts().0.into_entries();
65 *num_entries = Some(entries.len());
66 let iter = Box::new(entries.into_iter().map(move |e| e.path_in(&path_backing).to_owned()));
67 Ok(iter as Box<dyn Iterator<Item = BString> + Send + 'static>)
68 }
69 },
70 |paths| anyhow::Result::Ok(Box::new(paths)),
71 )?;
72
73 let (tx_base, rx_base) = std::sync::mpsc::channel::<(String, Baseline)>();
74 let feed_attrs = {
75 let (tx, rx) = std::sync::mpsc::sync_channel::<BString>(100);
76 std::thread::spawn({
77 let path = repo.path().to_owned();
78 let tx_base = tx_base.clone();
79 let mut progress = progress.add_child("attributes");
80 move || -> anyhow::Result<()> {
81 let mut child =
82 std::process::Command::from(gix::command::prepare(gix::path::env::exe_invocation()))
83 .args(["check-attr", "--stdin", "-a"])
84 .stdin(std::process::Stdio::piped())
85 .stdout(std::process::Stdio::piped())
86 .stderr(std::process::Stdio::null())
87 .current_dir(path)
88 .spawn()?;
89
90 std::thread::spawn({
91 let mut stdin = child.stdin.take().expect("we configured it");
92 move || -> anyhow::Result<()> {
93 progress.init(num_entries, gix::progress::count("paths"));
94 let start = std::time::Instant::now();
95 for path in rx {
96 progress.inc();
97 stdin.write_all(&path)?;
98 stdin.write_all(b"\n")?;
99 }
100 progress.show_throughput(start);
101 Ok(())
102 }
103 });
104
105 let stdout = std::io::BufReader::new(child.stdout.take().expect("we configured it"));
106 let mut lines = stdout.lines().map_while(Result::ok).peekable();
107 while let Some(baseline) = parse_attributes(&mut lines) {
108 if tx_base.send(baseline).is_err() {
109 child.kill().ok();
110 break;
111 }
112 }
113
114 Ok(())
115 }
116 });
117 tx
118 };
119 let work_dir = ignore
120 .then(|| {
121 #[expect(clippy::unnecessary_debug_formatting)]
122 repo.workdir()
123 .map(ToOwned::to_owned)
124 .ok_or_else(|| anyhow!("repository at {:?} must have a worktree checkout", repo.path()))
125 })
126 .transpose()?;
127 let feed_excludes = ignore.then(|| {
128 let (tx, rx) = std::sync::mpsc::sync_channel::<BString>(100);
129 std::thread::spawn({
130 let path = work_dir.expect("present if we are here");
131 let tx_base = tx_base.clone();
132 let mut progress = progress.add_child("excludes");
133 move || -> anyhow::Result<()> {
134 let mut child =
135 std::process::Command::from(gix::command::prepare(gix::path::env::exe_invocation()))
136 .args(["check-ignore", "--stdin", "-nv", "--no-index"])
137 .stdin(std::process::Stdio::piped())
138 .stdout(std::process::Stdio::piped())
139 .stderr(std::process::Stdio::null())
140 .current_dir(path)
141 .spawn()?;
142
143 std::thread::spawn({
144 let mut stdin = child.stdin.take().expect("we configured it");
145 move || -> anyhow::Result<()> {
146 progress.init(num_entries, gix::progress::count("paths"));
147 let start = std::time::Instant::now();
148 for path in rx {
149 progress.inc();
150 stdin.write_all(path.as_ref())?;
151 stdin.write_all(b"\n")?;
152 }
153 progress.show_throughput(start);
154 Ok(())
155 }
156 });
157
158 let stdout = std::io::BufReader::new(child.stdout.take().expect("we configured it"));
159 for line in stdout.lines() {
160 let line = line?;
161 if let Some(baseline) = parse_exclude(&line) {
162 if tx_base.send(baseline).is_err() {
163 child.kill().ok();
164 break;
165 }
166 } else {
167 eprintln!("Failed to parse line {line:?} - ignored");
168 }
169 }
170
171 Ok(())
172 }
173 });
174 tx
175 });
176 drop(tx_base);
177
178 std::thread::spawn(move || {
179 for path in paths {
180 if feed_attrs.send(path.clone()).is_err() {
181 break;
182 }
183 if feed_excludes.as_ref().is_some_and(|ch| ch.send(path).is_err()) {
184 break;
185 }
186 }
187 });
188
189 let (mut cache, _index) = attributes_cache(&repo)?;
190 let mut matches = cache.attribute_matches();
191 let mut progress = progress.add_child("validate");
192 let mut mismatches = Vec::new();
193 let start = std::time::Instant::now();
194 progress.init(
195 num_entries.map(|n| n + if ignore { n } else { 0 }),
196 gix::progress::count("paths"),
197 );
198
199 for (rela_path, baseline) in rx_base {
200 let entry = cache.at_entry(rela_path.as_str(), None)?;
201 match baseline {
202 Baseline::Attribute { assignments: expected } => {
203 entry.matching_attributes(&mut matches);
204 let fast_path_mismatch = matches
205 .iter()
206 .map(|m| m.assignment)
207 .zip(expected.iter().map(ThreadSafeAssignment::as_ref))
208 .any(|(a, b)| a != b);
209 if fast_path_mismatch {
210 let actual_set = BTreeSet::from_iter(matches.iter().map(|m| m.assignment));
211 let expected_set = BTreeSet::from_iter(expected.iter().map(ThreadSafeAssignment::as_ref));
212 let too_few_or_too_many =
213 !(expected_set.sub(&actual_set).is_empty() && actual_set.sub(&expected_set).is_empty());
214 if too_few_or_too_many {
215 mismatches.push((
216 rela_path,
217 Mismatch::Attributes {
218 actual: matches.iter().map(|m| m.assignment.to_owned()).collect(),
219 expected: expected.into_iter().map(Into::into).collect(),
220 },
221 ));
222 }
223 }
224 }
225 Baseline::Exclude { location } => {
226 let match_ = entry.matching_exclude_pattern();
227 if match_.is_some() != location.is_some() {
228 mismatches.push((
229 rela_path,
230 Mismatch::Exclude {
231 actual: match_.map(Into::into),
232 expected: location,
233 },
234 ));
235 }
236 }
237 }
238 progress.inc();
239 }
240
241 if let Some(stats) = statistics.then(|| cache.take_statistics()) {
242 out.flush()?;
243 writeln!(err, "{stats:#?}").ok();
244 }
245 progress.show_throughput(start);
246
247 if mismatches.is_empty() {
248 Ok(())
249 } else {
250 for (rela_path, mm) in &mismatches {
251 writeln!(err, "{rela_path}: {mm:#?}").ok();
252 }
253 bail!(
254 "{}: Validation failed with {} mismatches out of {}",
255 gix::path::realpath(repo.workdir().unwrap_or(repo.git_dir()))?.display(),
256 mismatches.len(),
257 progress.counter().load(Ordering::Relaxed)
258 );
259 }
260 }
261
262 enum Baseline {
263 Attribute { assignments: Vec<ThreadSafeAssignment> },
264 Exclude { location: Option<ExcludeLocation> },
265 }
266
267 struct ThreadSafeAssignment {
268 name: String,
269 state: gix::attrs::State,
270 }
271
272 impl ThreadSafeAssignment {
273 fn as_ref(&self) -> gix::attrs::AssignmentRef<'_> {
274 gix::attrs::AssignmentRef {
275 name: NameRef::try_from(self.name.as_bytes().as_bstr())
276 .expect("names from git check-attr were validated while parsing"),
277 state: self.state.as_ref(),
278 }
279 }
280 }
281
282 impl From<ThreadSafeAssignment> for Assignment {
283 fn from(value: ThreadSafeAssignment) -> Self {
284 Assignment {
285 name: NameRef::try_from(value.name.as_bytes().as_bstr())
286 .expect("names from git check-attr were validated while parsing")
287 .to_owned(),
288 state: value.state,
289 }
290 }
291 }
292
293 #[derive(Debug)]
294 #[expect(dead_code)]
296 pub struct ExcludeLocation {
297 pub line: usize,
298 pub rela_source_file: String,
299 pub pattern: String,
300 }
301
302 #[derive(Debug)]
303 #[expect(dead_code)]
306 pub enum Mismatch {
307 Attributes {
308 actual: Vec<gix::attrs::Assignment>,
309 expected: Vec<gix::attrs::Assignment>,
310 },
311 Exclude {
312 actual: Option<ExcludeMatch>,
313 expected: Option<ExcludeLocation>,
314 },
315 }
316
317 #[derive(Debug)]
318 #[expect(dead_code)]
320 pub struct ExcludeMatch {
321 pub pattern: gix::glob::Pattern,
322 pub source: Option<PathBuf>,
323 pub sequence_number: usize,
324 }
325
326 impl From<gix::ignore::search::Match<'_>> for ExcludeMatch {
327 fn from(value: gix::ignore::search::Match<'_>) -> Self {
328 ExcludeMatch {
329 pattern: value.pattern.clone(),
330 source: value.source.map(ToOwned::to_owned),
331 sequence_number: value.sequence_number,
332 }
333 }
334 }
335
336 fn parse_exclude(line: &str) -> Option<(String, Baseline)> {
337 let (left, value) = line.split_at(line.find('\t')?);
338 let value = &value[1..];
339
340 let location = if left == "::" {
341 None
342 } else {
343 let mut tokens = left.split(':');
344 let source = tokens.next()?;
345 let line_number: usize = tokens.next()?.parse().ok()?;
346 let pattern = tokens.next()?;
347 Some(ExcludeLocation {
348 line: line_number,
349 rela_source_file: source.into(),
350 pattern: pattern.into(),
351 })
352 };
353 Some((value.to_string(), Baseline::Exclude { location }))
354 }
355
356 fn parse_attributes(lines: &mut Peekable<impl Iterator<Item = String>>) -> Option<(String, Baseline)> {
357 let first = lines.next()?;
358 let mut out = Vec::new();
359 let (path, assignment) = parse_attribute_line(&first)?;
360
361 let current = path.to_owned();
362 out.push(assignment);
363 loop {
364 let next_line = match lines.peek() {
365 None => break,
366 Some(l) => l,
367 };
368 let (next_path, next_assignment) = parse_attribute_line(next_line)?;
369 if next_path != current {
370 return Some((current, Baseline::Attribute { assignments: out }));
371 } else {
372 out.push(next_assignment);
373 lines.next();
374 }
375 }
376 Some((current, Baseline::Attribute { assignments: out }))
377 }
378
379 fn parse_attribute_line(line: &str) -> Option<(&str, ThreadSafeAssignment)> {
380 use gix::attrs::StateRef;
381
382 let mut prev = None;
383 let mut tokens = line.splitn(3, |b| {
384 let is_match = b == ' ' && prev.take() == Some(':');
385 prev = Some(b);
386 is_match
387 });
388 if let Some(((mut path, attr), info)) = tokens.next().zip(tokens.next()).zip(tokens.next()) {
389 let state = match info {
390 "set" => StateRef::Set,
391 "unset" => StateRef::Unset,
392 "unspecified" => StateRef::Unspecified,
393 _ => StateRef::from_bytes(info.as_bytes()),
394 };
395 path = path.trim_end_matches(':');
396 let attr = attr.trim_end_matches(':');
397 let assignment = ThreadSafeAssignment {
398 name: NameRef::try_from(attr.as_bytes().as_bstr()).ok()?.as_str().to_owned(),
399 state: state.to_owned(),
400 };
401 Some((path, assignment))
402 } else {
403 None
404 }
405 }
406}