fix(gitignore): obey core.ignorecase, as git does

Differential-tested the ignore chain against `git check-ignore` on ~50
patterns — anchored, directory-only, `**` at either end and in the middle,
whitelists under ordinary and excluded directories, character classes,
escaped spaces, comments, CRLF, the `*` / `!*/` / `!*.c` recipe — and it
agreed with git everywhere except case.

`git init` probes the filesystem and sets `core.ignorecase = true` on a
case-insensitive one, which is every stock macOS and Windows install, and
git's ignore matching then folds case. The chain matched case-sensitively no
matter what. So a `.gitignore` whose pattern differs in case from the name on
disk diverged: `Build/` against a `build/`, `*.LOG` against an `a.log`. git
calls those ignored. The tree drew them as tracked, and — worse than a
styling difference — expanded and watched a directory git never descends.
The capitalised build directory is not a corner case; the .gitignore
templates and the tools that create the directory routinely disagree about
it.

Read from `core.ignorecase` rather than probed, because config is what git
obeys and someone who set it false on a case-insensitive disk means it. Once
per root, kept across `clear()`: editing a `.gitignore` cannot change the
setting, and a git spawn per keystroke in the ignore file would buy nothing.
The matcher cache is now keyed by the fold flag too, since a repository
nested inside another can answer differently.

The guard sets `core.ignorecase` explicitly in both directions rather than
leaving it to the probe, so it asserts the same thing on a case-sensitive
disk; its answers are `git check-ignore`'s under each setting. Checked
against both injected regressions — never folding and always folding.
This commit is contained in:
l0ng-ai
2026-08-23 02:53:02 +08:00
parent 3064ce8a68
commit 209ebe6ca5
2 changed files with 106 additions and 4 deletions
+12
View File
@@ -153,6 +153,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **The file tree now ignores the same files git does when a `.gitignore`
pattern and the name on disk differ in case.** `git init` turns on
`core.ignorecase` after probing the filesystem, so it is on for essentially
every repository made on macOS or Windows, and git then matches ignore
patterns without regard to case. tty7 matched case-sensitively regardless. A
`Build/` in `.gitignore` against a `build/` on disk — the everyday version,
since the templates and the tools that create the directory disagree about
the capital — was ignored by git and drawn as tracked here, and the tree
walked into a directory git would not have descended. `core.ignorecase` is
now read per repository and obeyed in both directions, so setting it false
still gets case-sensitive matching.
- **Turning on per-pane history no longer turns off history search.** The
window keeps the input bar's own command store in the file
`<config>/history`, and the daemon put each pane's `HISTFILE` in a
+94 -4
View File
@@ -6,9 +6,14 @@ use ignore::gitignore::{Gitignore, GitignoreBuilder};
#[derive(Default, Clone)]
pub(crate) struct GitignoreChain {
matchers: HashMap<PathBuf, Option<Arc<Gitignore>>>,
/// Keyed by the fold flag as well as the directory, because the same
/// directory can sit under two roots -- a repository inside a repository --
/// that answer `core.ignorecase` differently.
matchers: HashMap<(bool, PathBuf), Option<Arc<Gitignore>>>,
/// `<repo>/.git/info/exclude`, per repository root.
excludes: HashMap<PathBuf, Option<Arc<Gitignore>>>,
/// `core.ignorecase`, per repository root. See `folds_case`.
fold_case: HashMap<PathBuf, bool>,
/// `core.excludesFile`, or the XDG default. One per process.
global: Option<Option<Arc<Gitignore>>>,
}
@@ -28,6 +33,7 @@ impl GitignoreChain {
if parent != root && parent.starts_with(root) && self.is_ignored(parent, true, root) {
return true;
}
let fold = self.folds_case(root);
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
@@ -52,6 +58,7 @@ impl GitignoreChain {
// 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.case_insensitive(fold).ok();
builder.add(&file);
builder.build().ok().map(Arc::new)
})
@@ -71,12 +78,17 @@ impl GitignoreChain {
for dir in chain {
let gi = self
.matchers
.entry(dir.to_path_buf())
.entry((fold, dir.to_path_buf()))
.or_insert_with(|| {
let file = dir.join(".gitignore");
file.is_file().then(|| {
let (gi, _err) = Gitignore::new(&file);
Arc::new(gi)
let mut builder = GitignoreBuilder::new(dir);
builder.case_insensitive(fold).ok();
builder.add(&file);
builder.build().map(Arc::new).unwrap_or_else(|_| {
let (gi, _err) = Gitignore::new(&file);
Arc::new(gi)
})
})
})
.clone();
@@ -93,6 +105,31 @@ impl GitignoreChain {
state
}
/// Whether this repository matches ignore patterns without regard to case.
///
/// git does when `core.ignorecase` is on, and `git init` turns it on by
/// probing the filesystem -- so it is on for every repository made on a
/// stock macOS or Windows, which is most of them. Matching case-sensitively
/// there disagrees with git on any pattern whose case differs from the name
/// on disk: `Build/` against a `build/`, `*.LOG` against an `a.log`. git
/// calls those ignored, the tree drew them as tracked, and worse, walked
/// into a directory git would not have descended.
///
/// Read from config rather than probed, because config is what git obeys:
/// someone who set it false on a case-insensitive disk means it.
///
/// Once per root -- a `.gitignore` edit clears the matchers but not this,
/// since editing one does not change the other, and a spawn per keystroke
/// in the ignore file is not worth an answer that cannot have moved.
fn folds_case(&mut self, root: &Path) -> bool {
*self.fold_case.entry(root.to_path_buf()).or_insert_with(|| {
crate::core::git::git_output(root, &["config", "--type=bool", "--get", "core.ignorecase"])
.ok()
.filter(|out| out.success())
.is_some_and(|out| String::from_utf8_lossy(&out.stdout).trim() == "true")
})
}
/// Unused: the matcher sets are built whole rather than merged. Kept beside
/// `len`/`is_empty`, which are the same story -- a spare accessor is better
/// than half a type.
@@ -208,6 +245,57 @@ mod tests {
use super::*;
/// `core.ignorecase` decides whether case matters, and git is asked.
///
/// `git init` turns it on by probing the filesystem, so it is on for
/// essentially every repository made on macOS or Windows. Matching
/// case-sensitively there disagreed with git wherever a pattern's case
/// differed from the name on disk -- `Build/` against a `build/` is the
/// everyday one, since the templates and the tools that make the directory
/// do not agree on the capital. git called those ignored; the tree drew
/// them as tracked and walked into them.
///
/// Both directions, and set explicitly rather than left to the filesystem
/// probe, so the test says the same thing on a case-sensitive disk: with it
/// on the fold happens, with it off it does not. The answers are
/// `git check-ignore`'s on this layout under each setting.
#[test]
fn case_folding_follows_core_ignorecase() {
for (setting, folded) in [("true", true), ("false", false)] {
let root = scratch(&format!("ignorecase-{setting}"));
std::process::Command::new("git")
.current_dir(&root)
.args(["init", "-q"])
.output()
.unwrap();
std::process::Command::new("git")
.current_dir(&root)
.args(["config", "core.ignorecase", setting])
.output()
.unwrap();
write_ignore(&root, "*.LOG\nBuild/\n");
std::fs::create_dir_all(root.join("build")).unwrap();
let mut chain = GitignoreChain::default();
assert_eq!(
chain.is_ignored(&root.join("a.log"), false, &root),
folded,
"core.ignorecase={setting}: `*.LOG` against a.log"
);
assert_eq!(
chain.is_ignored(&root.join("build"), true, &root),
folded,
"core.ignorecase={setting}: `Build/` against build/"
);
assert!(
chain.is_ignored(&root.join("a.LOG"), false, &root),
"the exact case matches either way"
);
let _ = std::fs::remove_dir_all(&root);
}
}
fn write_ignore(dir: &Path, body: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join(".gitignore"), body).unwrap();
@@ -268,3 +356,5 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
}