1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use anyhow::{Context, Result, bail};
5use globset::{Glob, GlobSet, GlobSetBuilder};
6
7#[derive(Debug)]
9pub struct ExcludePolicy {
10 patterns: Vec<String>,
11 matcher: GlobSet,
12}
13
14impl ExcludePolicy {
15 pub fn new(patterns: &[String]) -> Result<Self> {
21 let mut builder = GlobSetBuilder::new();
22 for pattern in patterns {
23 builder.add(
24 Glob::new(pattern)
25 .with_context(|| format!("invalid exclude pattern `{pattern}`"))?,
26 );
27 }
28 Ok(Self {
29 patterns: patterns.to_vec(),
30 matcher: builder.build()?,
31 })
32 }
33
34 #[must_use]
36 pub fn matches(&self, path: &Path, roots: &[PathBuf]) -> bool {
37 if self.patterns.is_empty() {
38 return false;
39 }
40 if self.matcher.is_match(normalize(path)) {
41 return true;
42 }
43 if path
44 .file_name()
45 .is_some_and(|name| self.matcher.is_match(name))
46 {
47 return true;
48 }
49 roots.iter().any(|root| {
50 path.strip_prefix(root)
51 .is_ok_and(|relative| self.matcher.is_match(normalize(relative)))
52 })
53 }
54}
55
56pub fn contains_git_tracked_files(path: &Path) -> Result<bool> {
62 let probe = path.parent().unwrap_or(path);
63 if !probe
64 .ancestors()
65 .any(|ancestor| ancestor.join(".git").exists())
66 {
67 return Ok(false);
68 }
69 let root_output = Command::new("git")
70 .args(["-C"])
71 .arg(probe)
72 .args(["rev-parse", "--show-toplevel"])
73 .output()
74 .context("failed to run git tracked-file guard")?;
75 if !root_output.status.success() {
76 return Ok(false);
77 }
78 let root_text =
79 String::from_utf8(root_output.stdout).context("git returned a non-UTF-8 root")?;
80 let root = PathBuf::from(root_text.trim())
81 .canonicalize()
82 .context("failed to normalize Git root")?;
83 let relative = path
84 .strip_prefix(&root)
85 .with_context(|| format!("{} escaped Git root {}", path.display(), root.display()))?;
86 let output = Command::new("git")
87 .args(["-C"])
88 .arg(&root)
89 .args(["ls-files", "--"])
90 .arg(relative)
91 .output()
92 .context("failed to list Git-tracked files")?;
93 if !output.status.success() {
94 bail!("git ls-files failed for {}", path.display());
95 }
96 Ok(!output.stdout.is_empty())
97}
98
99fn normalize(path: &Path) -> String {
100 path.to_string_lossy().replace('\\', "/")
101}
102
103#[cfg(test)]
104mod tests {
105 use tempfile::tempdir;
106
107 use super::*;
108
109 #[test]
110 fn exclude_policy_should_match_root_relative_glob() -> Result<()> {
111 let temporary = tempdir()?;
112 let path = temporary.path().join("vendor/node_modules");
113 let policy = ExcludePolicy::new(&["vendor/**".to_owned()])?;
114
115 assert!(policy.matches(&path, &[temporary.path().to_path_buf()]));
116 Ok(())
117 }
118}