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