mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user