From 1081bf07333831f61d6667c1e766cbfbefddfa95 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:53:07 +0800 Subject: [PATCH] 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. --- crates/tty7-core/src/core/gitignore.rs | 85 +++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/crates/tty7-core/src/core/gitignore.rs b/crates/tty7-core/src/core/gitignore.rs index 4de0b04f..01bbb984 100644 --- a/crates/tty7-core/src/core/gitignore.rs +++ b/crates/tty7-core/src/core/gitignore.rs @@ -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>>, + /// `/.git/info/exclude`, per repository root. + excludes: HashMap>>, + /// `core.excludesFile`, or the XDG default. One per process. + global: Option>>, } 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