mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
fix(gitignore): read .git/info/exclude and the global excludes file
The chain walked `.gitignore` files and nothing else, so two of the three
places git looks were invisible. `git check-ignore` on the same directory:
secret.txt git -> IGNORED tty7 -> false
globalignore.tmp git -> IGNORED tty7 -> false
`.git/info/exclude` is where a person puts an ignore they do not want in
the file everyone shares, and `core.excludesFile` is where `.DS_Store`
usually lives. `LocalHost` marks every listed entry with this, so both
sets of files showed in the tree as though git tracked them.
Order matters and is git's: the global file, then the exclude file, then
the `.gitignore` chain, each overriding the last. Confirmed with
`check-ignore` rather than assumed -- `!both.txt` in a `.gitignore`
re-includes a name `info/exclude` lists.
The exclude file is built through `GitignoreBuilder` rooted at the
repository, not `Gitignore::new`, which would root it at `.git/info` and
read every pattern against the wrong directory. The global file is
`Gitignore::global()`, which already resolves `GIT_CONFIG_GLOBAL` and the
XDG path the way git does; verified against `check-ignore` under a
throwaway `GIT_CONFIG_GLOBAL`, though the test leaves it alone because
setting an environment variable is not safe in a suite this parallel.
One gap stays: the watcher invalidates on `.gitignore` writes, so editing
`.git/info/exclude` is picked up at the next `.gitignore` change rather
than at once.
This commit is contained in:
@@ -2,11 +2,15 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use ignore::gitignore::Gitignore;
|
||||
use ignore::gitignore::{Gitignore, GitignoreBuilder};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub(crate) struct GitignoreChain {
|
||||
matchers: HashMap<PathBuf, Option<Arc<Gitignore>>>,
|
||||
/// `<repo>/.git/info/exclude`, per repository root.
|
||||
excludes: HashMap<PathBuf, Option<Arc<Gitignore>>>,
|
||||
/// `core.excludesFile`, or the XDG default. One per process.
|
||||
global: Option<Option<Arc<Gitignore>>>,
|
||||
}
|
||||
|
||||
impl GitignoreChain {
|
||||
@@ -25,6 +29,40 @@ impl GitignoreChain {
|
||||
return true;
|
||||
}
|
||||
let mut state = false;
|
||||
// git reads these below every `.gitignore`, so they are consulted
|
||||
// first and a `.gitignore` further down can still whitelist what they
|
||||
// exclude — checked against `git check-ignore`, which calls a file
|
||||
// `!both.txt` re-includes not ignored even with `both.txt` in
|
||||
// `info/exclude`.
|
||||
let global = self
|
||||
.global
|
||||
.get_or_insert_with(|| {
|
||||
let (gi, _err) = Gitignore::global();
|
||||
(gi.num_ignores() > 0 || gi.num_whitelists() > 0).then(|| Arc::new(gi))
|
||||
})
|
||||
.clone();
|
||||
let exclude = self
|
||||
.excludes
|
||||
.entry(root.to_path_buf())
|
||||
.or_insert_with(|| {
|
||||
let file = root.join(".git/info/exclude");
|
||||
if !file.is_file() {
|
||||
return None;
|
||||
}
|
||||
// Rooted at the repository, not at `.git/info`, so its patterns
|
||||
// are read against the paths they are written about.
|
||||
let mut builder = GitignoreBuilder::new(root);
|
||||
builder.add(&file);
|
||||
builder.build().ok().map(Arc::new)
|
||||
})
|
||||
.clone();
|
||||
for gi in [global, exclude].into_iter().flatten() {
|
||||
match gi.matched(path, is_dir) {
|
||||
ignore::Match::Ignore(_) => state = true,
|
||||
ignore::Match::Whitelist(_) => state = false,
|
||||
ignore::Match::None => {}
|
||||
}
|
||||
}
|
||||
let mut chain: Vec<&Path> = parent
|
||||
.ancestors()
|
||||
.take_while(|a| a.starts_with(root))
|
||||
@@ -65,6 +103,8 @@ impl GitignoreChain {
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.matchers.clear();
|
||||
self.excludes.clear();
|
||||
self.global = None;
|
||||
}
|
||||
|
||||
/// Unused, like `absorb` above and for the same reason.
|
||||
@@ -83,6 +123,49 @@ impl GitignoreChain {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// `.git/info/exclude` counts, and a `.gitignore` still outranks it.
|
||||
///
|
||||
/// It is where a person puts an ignore they do not want in the shared
|
||||
/// file, so a tree that reads only `.gitignore` marks files git does not.
|
||||
/// Checked against `git check-ignore` on this layout, including the
|
||||
/// precedence: `!both.txt` in `.gitignore` re-includes a name that
|
||||
/// `info/exclude` lists, because git reads the exclude file below every
|
||||
/// `.gitignore`.
|
||||
///
|
||||
/// The global `core.excludesFile` is the third source and is left to
|
||||
/// `Gitignore::global()`, which reads `GIT_CONFIG_GLOBAL` and the XDG
|
||||
/// path the way git does. It is not exercised here: proving it means
|
||||
/// setting an environment variable, and this suite runs in parallel in
|
||||
/// one process.
|
||||
#[test]
|
||||
fn a_repo_exclude_file_is_read_and_a_gitignore_still_wins() {
|
||||
let root = std::env::temp_dir().join(format!("tty7-exclude-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join(".git/info")).unwrap();
|
||||
std::fs::write(root.join(".git/info/exclude"), "secret.txt\nboth.txt\n").unwrap();
|
||||
std::fs::write(root.join(".gitignore"), "!both.txt\n").unwrap();
|
||||
for name in ["secret.txt", "both.txt", "normal.txt"] {
|
||||
std::fs::write(root.join(name), "x").unwrap();
|
||||
}
|
||||
|
||||
let mut chain = GitignoreChain::default();
|
||||
let ignored = |chain: &mut GitignoreChain, name: &str| {
|
||||
chain.is_ignored(&root.join(name), false, &root)
|
||||
};
|
||||
|
||||
assert!(
|
||||
ignored(&mut chain, "secret.txt"),
|
||||
"a name in .git/info/exclude is ignored"
|
||||
);
|
||||
assert!(
|
||||
!ignored(&mut chain, "both.txt"),
|
||||
"a .gitignore whitelist outranks the exclude file"
|
||||
);
|
||||
assert!(!ignored(&mut chain, "normal.txt"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Nothing under an excluded directory can be brought back.
|
||||
///
|
||||
/// gitignore(5): "It is not possible to re-include a file if a parent
|
||||
|
||||
Reference in New Issue
Block a user