Skip to main content

gitoxide_core/repository/attributes/
validate_baseline.rs

1use 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    // See note on `Mismatch`
295    #[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    // We debug-print this structure, which makes all fields 'used', but it doesn't count.
304    // TODO: find a way to not have to do more work, but make the warning go away.
305    #[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    // See note on `Mismatch`
319    #[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}