1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6use globset::{Glob, GlobSet, GlobSetBuilder};
7
8#[derive(Debug)]
10pub struct ExcludePolicy {
11 patterns: Vec<String>,
12 matcher: GlobSet,
13}
14
15#[derive(Debug, Default)]
17pub struct GitTrackedGuard {
18 roots_by_directory: HashMap<PathBuf, Option<PathBuf>>,
19}
20
21impl GitTrackedGuard {
22 pub fn contains_tracked_files(&mut self, path: &Path) -> Result<bool> {
28 let probe = path.parent().unwrap_or(path);
29 let Some(root) = self.repository_root(probe)? else {
30 return Ok(false);
31 };
32 let relative = path
33 .strip_prefix(&root)
34 .with_context(|| format!("{} escaped Git root {}", path.display(), root.display()))?;
35 let output = Command::new("git")
36 .args(["-C"])
37 .arg(&root)
38 .args(["ls-files", "--"])
39 .arg(relative)
40 .output()
41 .context("failed to list Git-tracked files")?;
42 if !output.status.success() {
43 bail!("git ls-files failed for {}", path.display());
44 }
45 Ok(!output.stdout.is_empty())
46 }
47
48 fn repository_root(&mut self, probe: &Path) -> Result<Option<PathBuf>> {
49 let mut visited = Vec::new();
50 for ancestor in probe.ancestors() {
51 if let Some(cached) = self.roots_by_directory.get(ancestor).cloned() {
52 for directory in visited {
53 self.roots_by_directory.insert(directory, cached.clone());
54 }
55 return Ok(cached);
56 }
57 visited.push(ancestor.to_path_buf());
58 if ancestor.join(".git").exists() {
59 let root = ancestor
60 .canonicalize()
61 .context("failed to normalize Git root")?;
62 for directory in visited {
63 self.roots_by_directory
64 .insert(directory, Some(root.clone()));
65 }
66 return Ok(Some(root));
67 }
68 }
69 for directory in visited {
70 self.roots_by_directory.insert(directory, None);
71 }
72 Ok(None)
73 }
74}
75
76impl ExcludePolicy {
77 pub fn new(patterns: &[String]) -> Result<Self> {
83 let mut builder = GlobSetBuilder::new();
84 for pattern in patterns {
85 builder.add(
86 Glob::new(pattern)
87 .with_context(|| format!("invalid exclude pattern `{pattern}`"))?,
88 );
89 }
90 Ok(Self {
91 patterns: patterns.to_vec(),
92 matcher: builder.build()?,
93 })
94 }
95
96 #[must_use]
98 pub fn matches(&self, path: &Path, roots: &[PathBuf]) -> bool {
99 if self.patterns.is_empty() {
100 return false;
101 }
102 if self.matcher.is_match(normalize(path)) {
103 return true;
104 }
105 if path
106 .file_name()
107 .is_some_and(|name| self.matcher.is_match(name))
108 {
109 return true;
110 }
111 roots.iter().any(|root| {
112 path.strip_prefix(root)
113 .is_ok_and(|relative| self.matcher.is_match(normalize(relative)))
114 })
115 }
116}
117
118pub fn contains_git_tracked_files(path: &Path) -> Result<bool> {
124 GitTrackedGuard::default().contains_tracked_files(path)
125}
126
127fn normalize(path: &Path) -> String {
128 path.to_string_lossy().replace('\\', "/")
129}
130
131#[cfg(test)]
132mod tests {
133 use tempfile::tempdir;
134
135 use super::*;
136
137 #[test]
138 fn exclude_policy_should_match_root_relative_glob() -> Result<()> {
139 let temporary = tempdir()?;
140 let path = temporary.path().join("vendor/node_modules");
141 let policy = ExcludePolicy::new(&["vendor/**".to_owned()])?;
142
143 assert!(policy.matches(&path, &[temporary.path().to_path_buf()]));
144 Ok(())
145 }
146}