1 //! Implements parsing and applying .gitignore files.
2
3 use {
4 git2,
5 glob,
6 id_arena::{Arena, Id},
7 lazy_regex::regex,
8 once_cell::sync::Lazy,
9 std::{
10 fmt,
11 fs::File,
12 io::{BufRead, BufReader, Result},
13 path::{Path, PathBuf},
14 },
15 };
16
is_repo(root: &Path) -> bool17 pub fn is_repo(root: &Path) -> bool {
18 root.join(".git").exists()
19 }
20
21 /// a simple rule of a gitignore file
22 #[derive(Clone)]
23 struct GitIgnoreRule {
24 ok: bool, // does this rule when matched means the file is good? (usually false)
25 directory: bool, // whether this rule only applies to directories
26 filename: bool, // does this rule apply to just the filename
27 pattern: glob::Pattern,
28 pattern_options: glob::MatchOptions,
29 }
30
31 impl fmt::Debug for GitIgnoreRule {
fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result32 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33 f.debug_struct("GitIgnoreRule")
34 .field("ok", &self.ok)
35 .field("directory", &self.directory)
36 .field("filename", &self.filename)
37 .field("pattern", &self.pattern.as_str())
38 .finish()
39 }
40 }
41
42 impl GitIgnoreRule {
43 /// parse a line of a .gitignore file.
44 /// The ref_dir is used if the line starts with '/'
from(line: &str, ref_dir: &Path) -> Option<GitIgnoreRule>45 fn from(line: &str, ref_dir: &Path) -> Option<GitIgnoreRule> {
46 if line.starts_with('#') {
47 return None; // comment line
48 }
49 let r = regex!(
50 r"(?x)
51 ^\s*
52 (!)? # 1 : negation
53 (.+?) # 2 : pattern
54 (/)? # 3 : directory
55 \s*$
56 "
57 );
58 if let Some(c) = r.captures(line) {
59 if let Some(p) = c.get(2) {
60 let mut p = p.as_str().to_string();
61 let has_separator = p.contains('/');
62 if has_separator && p.starts_with('/') {
63 p = ref_dir.to_string_lossy().to_string() + &p;
64 }
65 match glob::Pattern::new(&p) {
66 Ok(pattern) => {
67 let pattern_options = glob::MatchOptions {
68 case_sensitive: true,
69 require_literal_leading_dot: false,
70 require_literal_separator: has_separator,
71 };
72 return Some(GitIgnoreRule {
73 ok: c.get(1).is_some(), // if negation
74 pattern,
75 directory: c.get(3).is_some(),
76 filename: !has_separator,
77 pattern_options,
78 });
79 }
80 Err(e) => {
81 debug!(" wrong glob pattern {:?} : {}", &p, e);
82 }
83 }
84 }
85 }
86 None
87 }
88 }
89
90 /// The rules of a gitignore file
91 #[derive(Debug, Clone)]
92 pub struct GitIgnoreFile {
93 rules: Vec<GitIgnoreRule>,
94 }
95 impl GitIgnoreFile {
96 /// build a new gitignore file, from either a global ignore file or
97 /// a .gitignore file found inside a git repository.
98 /// The ref_dir is either:
99 /// - the path of the current repository for the global gitignore
100 /// - the directory containing the .gitignore file
new(file_path: &Path, ref_dir: &Path) -> Result<GitIgnoreFile>101 pub fn new(file_path: &Path, ref_dir: &Path) -> Result<GitIgnoreFile> {
102 let f = File::open(file_path)?;
103 let mut rules: Vec<GitIgnoreRule> = Vec::new();
104 for line in BufReader::new(f).lines() {
105 if let Some(rule) = GitIgnoreRule::from(&line?, ref_dir) {
106 rules.push(rule);
107 }
108 }
109 // the last rule applicable to a path is the right one. So
110 // we reverse the list to easily iterate from the last one to the first one
111 rules.reverse();
112 Ok(GitIgnoreFile { rules })
113 }
114 /// return the global gitignore file interpreted for
115 /// the given repo dir
global(repo_dir: &Path) -> Option<GitIgnoreFile>116 pub fn global(repo_dir: &Path) -> Option<GitIgnoreFile> {
117 static GLOBAL_GI_PATH: Lazy<Option<PathBuf>> = Lazy::new(find_global_ignore);
118 if let Some(path) = &*GLOBAL_GI_PATH {
119 GitIgnoreFile::new(path, repo_dir).ok()
120 } else {
121 None
122 }
123 }
124 }
125
find_global_ignore() -> Option<PathBuf>126 pub fn find_global_ignore() -> Option<PathBuf> {
127 git2::Config::open_default()
128 .and_then(|global_config| global_config.get_path("core.excludesfile"))
129 .ok()
130 .or_else(|| {
131 directories::BaseDirs::new().map(|base_dirs| base_dirs.config_dir().join("git/ignore"))
132 })
133 .or_else(|| {
134 directories::UserDirs::new()
135 .map(|user_dirs| user_dirs.home_dir().join(".config/git/ignore"))
136 })
137 }
138
139 #[derive(Debug, Clone, Default)]
140 pub struct GitIgnoreChain {
141 in_repo: bool,
142 file_ids: Vec<Id<GitIgnoreFile>>,
143 }
144 impl GitIgnoreChain {
push(&mut self, id: Id<GitIgnoreFile>)145 pub fn push(&mut self, id: Id<GitIgnoreFile>) {
146 self.file_ids.push(id);
147 }
148 }
149
150 #[derive(Default)]
151 pub struct GitIgnorer {
152 files: Arena<GitIgnoreFile>,
153 }
154
155 impl GitIgnorer {
root_chain(&mut self, mut dir: &Path) -> GitIgnoreChain156 pub fn root_chain(&mut self, mut dir: &Path) -> GitIgnoreChain {
157 let mut chain = GitIgnoreChain::default();
158 loop {
159 let ignore_file = dir.join(".gitignore");
160 let is_repo = is_repo(dir);
161 if is_repo {
162 if let Some(gif) = GitIgnoreFile::global(dir) {
163 chain.push(self.files.alloc(gif));
164 }
165 }
166 if let Ok(gif) = GitIgnoreFile::new(&ignore_file, dir) {
167 debug!("pushing GIF {:#?}", &gif);
168 chain.push(self.files.alloc(gif));
169 }
170 if is_repo {
171 chain.in_repo = true;
172 break;
173 }
174 if let Some(parent) = dir.parent() {
175 dir = parent;
176 } else {
177 break;
178 }
179 }
180 chain
181 }
deeper_chain(&mut self, parent_chain: &GitIgnoreChain, dir: &Path) -> GitIgnoreChain182 pub fn deeper_chain(&mut self, parent_chain: &GitIgnoreChain, dir: &Path) -> GitIgnoreChain {
183 // if the current folder is a repository, then
184 // we reset the chain to the root one:
185 // we don't want the .gitignore files of super repositories
186 // (see https://github.com/Canop/broot/issues/160)
187 let mut chain = if is_repo(dir) {
188 let mut chain = GitIgnoreChain::default();
189 if let Some(gif) = GitIgnoreFile::global(dir) {
190 chain.push(self.files.alloc(gif));
191 }
192 chain.in_repo = true;
193 chain
194 } else {
195 parent_chain.clone()
196 };
197 if chain.in_repo {
198 let ignore_file = dir.join(".gitignore");
199 if let Ok(gif) = GitIgnoreFile::new(&ignore_file, dir) {
200 chain.push(self.files.alloc(gif));
201 }
202 }
203 chain
204 }
205 /// return true if the given path should not be ignored
accepts( &self, chain: &GitIgnoreChain, path: &Path, filename: &str, directory: bool, ) -> bool206 pub fn accepts(
207 &self,
208 chain: &GitIgnoreChain,
209 path: &Path,
210 filename: &str,
211 directory: bool,
212 ) -> bool {
213 if !chain.in_repo {
214 // if we're not in a git repository, then .gitignore files, including
215 // the global ones, are irrelevant
216 return true;
217 }
218 // we start with deeper files: deeper rules have a bigger priority
219 for id in chain.file_ids.iter().rev() {
220 let file = &self.files[*id];
221 for rule in &file.rules {
222 if rule.directory && !directory {
223 continue;
224 }
225 let ok = if rule.filename {
226 rule.pattern.matches_with(filename, rule.pattern_options)
227 } else {
228 rule.pattern.matches_path_with(path, rule.pattern_options)
229 };
230 if ok {
231 // as we read the rules in reverse, the first applying is OK
232 return rule.ok;
233 }
234 }
235 }
236 true
237 }
238 }
239