diff --git a/docs/context/troubleshooting.md b/docs/context/troubleshooting.md index b8e3b1be7..661c48fa9 100644 --- a/docs/context/troubleshooting.md +++ b/docs/context/troubleshooting.md @@ -45,7 +45,7 @@ uvx dirsql "SELECT path FROM './**'" --format json ### Empty `[]` for files you can see - **You named a directory**, which lists one level only. `'./**'` and `'./docs/**'` are the recursive forms. -- **The files are gitignored.** `--no-ignore` scans them: +- **The files are gitignored**, inside a git repo (outside one, no `.gitignore` applies). `--no-ignore` scans them: ```sh uvx dirsql "SELECT path FROM './**'" --format json --no-ignore diff --git a/docs/context/usage.md b/docs/context/usage.md index 9d34b595f..58a9a9d08 100644 --- a/docs/context/usage.md +++ b/docs/context/usage.md @@ -35,7 +35,7 @@ Every row has `path`, `basename`, `dir`, `ext` (no dot, `NULL` when absent), `si ### What a scan skips -- **`.gitignore` is respected.** Pass `--no-ignore` to scan gitignored files too, or name the ignored directory outright: `'./build'` scans `build/`. +- **`.gitignore` is respected inside a git repo**, as git does; outside one (no enclosing `.git`) no `.gitignore` applies. Pass `--no-ignore` to scan gitignored files too, or name the ignored directory outright: `'./build'` scans `build/`. - **`node_modules` is skipped** unless the path names it: `'./node_modules/**'`. - **`'./**'` does not enter `.git`.** Naming it scans it: `'./.git/**'`. - **To match dotfiles, spell the dot:** `'./.github/**'`, `'./**/.env'`. diff --git a/docs/reference/path-tables.md b/docs/reference/path-tables.md index 2e98a17d8..718ce3fc2 100644 --- a/docs/reference/path-tables.md +++ b/docs/reference/path-tables.md @@ -260,15 +260,21 @@ skipped just like one at the top. ### `.gitignore` -Path-table scans also respect `.gitignore` files by default, the way fd and -ripgrep do: a `.gitignore` anywhere in the tree applies below its own +Path-table scans also respect `.gitignore` files by default, inside a git +repo, the way git, fd and ripgrep do: a `.gitignore` applies below its own directory, deeper files override shallower ones, `!pattern` re-includes, and an ignored directory is pruned rather than walked. In a typical repo this -excludes build output, virtualenvs, and caches with zero ceremony. No `.git` -directory is required — a `.gitignore` in any scanned directory counts — and -the built-in defaults above remain as a floor for directories with no +excludes build output, virtualenvs, and caches with zero ceremony. The +built-in defaults above remain as a floor for directories with no `.gitignore` at all. +A `.gitignore` is in force only when a directory holding `.git` encloses it. +Outside a repo none applies, at the scan's start, above it or below it: a +`~/.claude` whose `.gitignore` is `*` still lists its files when `~/.claude` +is not a repo. Inside one, every `.gitignore` from the repo root down +applies, including those above the directory a scan starts in, so +`'./docs/*.log'` and `'./**/*.log'` agree about `docs/`. + Pass [`--no-ignore`](./cli.md#flags) to restore the full walk — the determinism switch for scripted use, since results otherwise depend on `.gitignore` state. It disables only the `.gitignore` respect; the built-in @@ -285,8 +291,8 @@ SELECT path FROM './node_modules/*/package.json'; -- scans it anyway SELECT path FROM './dist'; -- scans dist/ even when gitignored ``` -A `.gitignore` at or below the directory the scan starts in still filters -beneath it; one above it is never read. +The `.gitignore` files in force there, including those above it, still +filter what lies beneath it. ### Hidden files diff --git a/packages/python/changelog.d/2026-10-05-gitignore-requires-repo.md b/packages/python/changelog.d/2026-10-05-gitignore-requires-repo.md new file mode 100644 index 000000000..4b5cb4ea4 --- /dev/null +++ b/packages/python/changelog.d/2026-10-05-gitignore-requires-repo.md @@ -0,0 +1,5 @@ +**Changed** A `.gitignore` applies to path-table scans only inside a git +repo, as in git, fd and ripgrep: one is in force only when a directory +holding `.git` encloses it. Outside a repo none applies, at the scan's start, +above it or below it, so a `~/.claude` whose `.gitignore` is `*` lists its +files. Inside a repo nothing changes. `--no-ignore` is unchanged. diff --git a/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json b/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json new file mode 100644 index 000000000..175a9a331 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1791218987, + "exit_code": 0, + "commit": "a433cc9c0cf5c32e3d731625dd63ecd040d9f235", + "branch": "claude/1272-ancestor-gitignore" +} diff --git a/packages/python/migrations.d/2026-10-05-gitignore-requires-repo.md b/packages/python/migrations.d/2026-10-05-gitignore-requires-repo.md new file mode 100644 index 000000000..209fdc42f --- /dev/null +++ b/packages/python/migrations.d/2026-10-05-gitignore-requires-repo.md @@ -0,0 +1,36 @@ +### Python SDK: `.gitignore` applies only inside a git repo + +#### Summary + +Path-tables used to apply `.gitignore` files with no git repo present. Now a +`.gitignore` is in force only when a directory holding `.git` encloses it, +as in git, fd and ripgrep. Every surface that resolves a path-table is +affected: the CLI, the REPL, the HTTP server, the SDKs, and the `--on-file` +form. No call changes. Outside a repo a query can return more rows; inside +one nothing changes. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- Outside a git repo, files a `.gitignore` names are listed. To hide them, + add `ignore` patterns in a config file, or run `git init`. + +#### Verification + +```bash +cd "$(mktemp -d)" +printf '*.log\n' > .gitignore +touch a.md z.log +dirsql query "SELECT path FROM './*' ORDER BY path" +# expected: [{"path":"a.md"},{"path":"z.log"}] +git init -q +dirsql query "SELECT path FROM './*' ORDER BY path" +# expected: [{"path":"a.md"}] +``` diff --git a/packages/python/tests/integration/binding/path_table_no_ignore_test.py b/packages/python/tests/integration/binding/path_table_no_ignore_test.py index f91bea1ff..b205e7052 100644 --- a/packages/python/tests/integration/binding/path_table_no_ignore_test.py +++ b/packages/python/tests/integration/binding/path_table_no_ignore_test.py @@ -12,6 +12,7 @@ @pytest.fixture def gitignored_dir(tmp_path): + (tmp_path / ".git").mkdir() (tmp_path / ".gitignore").write_text("ignored.md\n", encoding="utf-8") (tmp_path / "kept.md").write_text("kept", encoding="utf-8") (tmp_path / "ignored.md").write_text("ignored", encoding="utf-8") diff --git a/packages/rust/changelog.d/2026-10-05-gitignore-requires-repo.md b/packages/rust/changelog.d/2026-10-05-gitignore-requires-repo.md new file mode 100644 index 000000000..4b5cb4ea4 --- /dev/null +++ b/packages/rust/changelog.d/2026-10-05-gitignore-requires-repo.md @@ -0,0 +1,5 @@ +**Changed** A `.gitignore` applies to path-table scans only inside a git +repo, as in git, fd and ripgrep: one is in force only when a directory +holding `.git` encloses it. Outside a repo none applies, at the scan's start, +above it or below it, so a `~/.claude` whose `.gitignore` is `*` lists its +files. Inside a repo nothing changes. `--no-ignore` is unchanged. diff --git a/packages/rust/changelog.d/2026-10-05-path-table-ancestor-gitignore.md b/packages/rust/changelog.d/2026-10-05-path-table-ancestor-gitignore.md new file mode 100644 index 000000000..922c3e6bd --- /dev/null +++ b/packages/rust/changelog.d/2026-10-05-path-table-ancestor-gitignore.md @@ -0,0 +1,6 @@ +**Fixed** Inside a git repo, a path-table scan applies the `.gitignore` files +above the directory it starts in, as git does: every one from the repo root +(the nearest directory holding `.git`) down. `'./docs/*.log'` now hides what a +root `*.log` rule ignores, as `'./**'` already did, and an index root inside a +repo honors the repo root's `.gitignore`. Naming a gitignored directory +(`'./dist'`) still scans it. diff --git a/packages/rust/migrations.d/2026-10-05-gitignore-requires-repo.md b/packages/rust/migrations.d/2026-10-05-gitignore-requires-repo.md new file mode 100644 index 000000000..15c72d800 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-05-gitignore-requires-repo.md @@ -0,0 +1,36 @@ +### Core: `.gitignore` applies only inside a git repo + +#### Summary + +Path-tables used to apply `.gitignore` files with no git repo present. Now a +`.gitignore` is in force only when a directory holding `.git` encloses it, +as in git, fd and ripgrep. Every surface that resolves a path-table is +affected: the CLI, the REPL, the HTTP server, the SDKs, and the `--on-file` +form. No call changes. Outside a repo a query can return more rows; inside +one nothing changes. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- Outside a git repo, files a `.gitignore` names are listed. To hide them, + add `ignore` patterns in a config file, or run `git init`. + +#### Verification + +```bash +cd "$(mktemp -d)" +printf '*.log\n' > .gitignore +touch a.md z.log +dirsql query "SELECT path FROM './*' ORDER BY path" +# expected: [{"path":"a.md"},{"path":"z.log"}] +git init -q +dirsql query "SELECT path FROM './*' ORDER BY path" +# expected: [{"path":"a.md"}] +``` diff --git a/packages/rust/migrations.d/2026-10-05-path-table-ancestor-gitignore.md b/packages/rust/migrations.d/2026-10-05-path-table-ancestor-gitignore.md new file mode 100644 index 000000000..71a4f77e6 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-05-path-table-ancestor-gitignore.md @@ -0,0 +1,38 @@ +### Core: path-tables apply `.gitignore` files above the scan start + +#### Summary + +A path-table scan used to read only the `.gitignore` files at or below the +directory it starts in, so `'./docs/*.log'` listed files a root `*.log` rule +hides from `'./**'`. Inside a git repo it now reads every `.gitignore` from the +repo root (the nearest directory holding `.git`) down to that directory. Every +surface that resolves a path-table is affected: the CLI, the REPL, the HTTP +server, the SDKs, and the `--on-file` form. No call changes; a query can return fewer rows. `--no-ignore` restores +the old listing. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- `'./docs/*.log'` no longer lists `docs/z.log` when a root `.gitignore` + ignores `*.log`. +- Run from a subdirectory of a repo, path-tables honor the `.gitignore` files + between the repo root and that subdirectory. + +#### Verification + +```bash +cd "$(mktemp -d)" +git init -q +mkdir docs +printf '*.log\n' > .gitignore +touch docs/a.md docs/z.log +dirsql query "SELECT path FROM './docs/*' ORDER BY path" +# expected: [{"path":"docs/a.md"}] +``` diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 798b2eea0..9d25090c0 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -74,20 +74,28 @@ fn scan_below( // Match against relative path so globs like "comments/**/*.jsonl" work // regardless of the absolute root directory. - walk(root, start, matcher, None, false, &mut |rel_path, entry| { - if matcher.is_ignored(&rel_path) { - return; - } + walk( + root, + start, + matcher, + None, + false, + None, + &mut |rel_path, entry| { + if matcher.is_ignored(&rel_path) { + return; + } - seen += 1; - on_file(seen); + seen += 1; + on_file(seen); - // Fan-out: a file matching N tables' globs yields N (path, table) - // pairs, one per matching table, in declaration order. - for m in matcher.match_all(&rel_path) { - results.push((entry.path(), m.table_name)); - } - }); + // Fan-out: a file matching N tables' globs yields N (path, table) + // pairs, one per matching table, in declaration order. + for m in matcher.match_all(&rel_path) { + results.push((entry.path(), m.table_name)); + } + }, + ); results } @@ -108,16 +116,24 @@ fn scan_below( /// starts at the path's literal prefix, so a dot directory named there is /// already inside. /// -/// With `gitignore` set, `.gitignore` files apply hierarchically (each one -/// below its own directory) and prune traversal, like fd/ripgrep, and no -/// `.git` directory is required. Only files at or below `root` are read; one -/// above it has no say. +/// With `gitignore` set, `.gitignore` files inside a git repo apply as git +/// applies them: hierarchically, each below its own directory, from the repo +/// root (the nearest directory holding `.git`) down, pruning traversal. +/// Outside a repo none applies, as in git, fd and ripgrep. Those above `root` +/// filter what lies below it, never `root` itself, so naming an ignored +/// directory still scans it. pub fn scan_glob( root: &Path, glob: &PathGlob, ignore: &TableMatcher, gitignore: bool, ) -> Vec { + let repo = if gitignore { + enclosing_repo(root, &holds_git) + } else { + None + }; + let repo_frames = repo.map(|top| gitignores_above(root, top)); let mut results = Vec::new(); walk( root, @@ -125,6 +141,7 @@ pub fn scan_glob( ignore, Some(glob), gitignore, + repo_frames, &mut |rel_path, _| { if is_wanted(glob, ignore, &rel_path) { results.push(rel_path); @@ -291,15 +308,19 @@ pub(crate) fn parse_gitignore_arg(arg: &str) -> Result { /// `.dirsql/` subtree and any directory the skip rules ignore wholesale, so an /// ignored tree is never read at all. With `glob` given, a dot-named entry it /// does not spell is skipped; `None` admits them all. With `gitignore` set, -/// entries a `.gitignore` in force ignores are pruned/skipped too. Symlinks -/// are followed only as `glob` allows, and not at all without one; a broken -/// link or an unreadable directory contributes nothing. +/// entries a `.gitignore` in force ignores are pruned/skipped too, starting +/// from `repo_frames`: the ones in force above `start` when a repo encloses +/// it, `None` when none does. Inside a directory holding `.git`, its +/// `.gitignore` files apply. Symlinks are followed only as `glob` allows, and +/// not at all without one; a broken link or an unreadable directory +/// contributes nothing. fn walk( root: &Path, start: &Path, ignore: &TableMatcher, glob: Option<&PathGlob>, gitignore: bool, + repo_frames: Option>, visit: &mut dyn FnMut(PathBuf, &DirEntry), ) { let rel = start.strip_prefix(root).unwrap_or(start); @@ -310,7 +331,8 @@ fn walk( ignore, glob, gitignore, - frames: Vec::new(), + in_repo: repo_frames.is_some(), + frames: repo_frames.unwrap_or_default(), }; let states = glob.map_or_else(Vec::new, PathGlob::start); walk.descend(start, rel, depth, &states, false, visit); @@ -320,6 +342,9 @@ struct Walk<'a> { ignore: &'a TableMatcher, glob: Option<&'a PathGlob>, gitignore: bool, + /// Whether a repo encloses the walk's current position, which is what + /// puts a `.gitignore` in force. + in_repo: bool, /// The `.gitignore` files in force at the walk's current position, root /// first; a directory's own file is pushed on entry and popped on exit. frames: Vec, @@ -335,8 +360,12 @@ impl Walk<'_> { linked: bool, visit: &mut dyn FnMut(PathBuf, &DirEntry), ) { + let entered_repo = self.gitignore && !self.in_repo && holds_git(dir); + if entered_repo { + self.in_repo = true; + } let mut pushed = false; - if self.gitignore + if self.in_repo && let Some(matcher) = load_gitignore(dir) { self.frames.push(matcher); @@ -365,6 +394,9 @@ impl Walk<'_> { if pushed { self.frames.pop(); } + if entered_repo { + self.in_repo = false; + } } /// Whether the walk takes an entry at `depth`: the skip rules and the @@ -431,6 +463,34 @@ fn sorted_entries(dir: &Path) -> Vec<(OsString, DirEntry)> { entries } +/// The `.gitignore` files above `start`, up to and including the repo root +/// `top`, outermost first. +fn gitignores_above(start: &Path, top: &Path) -> Vec { + dirs_above(start, top) + .into_iter() + .filter_map(load_gitignore) + .collect() +} + +/// The root of the git repo enclosing `start`: the nearest directory, `start` +/// included, holding `.git`. +fn enclosing_repo<'a>(start: &'a Path, is_repo_root: &dyn Fn(&Path) -> bool) -> Option<&'a Path> { + start.ancestors().find(|dir| is_repo_root(dir)) +} + +fn holds_git(dir: &Path) -> bool { + dir.join(".git").exists() +} + +/// The directories strictly above `start`, up to and including its ancestor +/// `top`, outermost first. +fn dirs_above<'a>(start: &'a Path, top: &Path) -> Vec<&'a Path> { + let depth = start.ancestors().position(|dir| dir == top).unwrap_or(0); + let mut dirs: Vec<&Path> = start.ancestors().skip(1).take(depth).collect(); + dirs.reverse(); + dirs +} + /// Compile the `.gitignore` in `dir`, if one exists. An unparsable file /// degrades to no matcher rather than failing the scan. fn load_gitignore(dir: &Path) -> Option { @@ -653,6 +713,49 @@ mod tests { assert!(!is_wanted(&glob, &ignore, Path::new("drafts/a.md"))); } + #[test] + fn enclosing_repo_is_the_nearest_directory_holding_git() { + let repo = enclosing_repo(Path::new("/outer/inner/a"), &|dir| { + dir == Path::new("/outer") || dir == Path::new("/outer/inner") + }); + assert_eq!(repo, Some(Path::new("/outer/inner"))); + } + + #[test] + fn enclosing_repo_includes_the_start_itself() { + let repo = enclosing_repo(Path::new("/r"), &|dir| dir == Path::new("/r")); + assert_eq!(repo, Some(Path::new("/r"))); + } + + #[test] + fn enclosing_repo_is_none_outside_any_repo() { + assert_eq!(enclosing_repo(Path::new("/idx/docs"), &|_| false), None); + } + + #[test] + fn dirs_above_lists_the_ancestors_up_to_top_outermost_first() { + assert_eq!( + dirs_above(Path::new("/r/a/b"), Path::new("/r")), + vec![Path::new("/r"), Path::new("/r/a")] + ); + } + + #[test] + fn dirs_above_is_empty_when_the_start_is_the_top() { + assert!(dirs_above(Path::new("/r"), Path::new("/r")).is_empty()); + } + + #[test] + fn an_ancestor_frame_filters_entries_below_the_start_but_not_the_start() { + let frames = vec![frame("/r", &["*.log", "dist/"])]; + assert!(is_gitignored(&frames, Path::new("/r/docs/z.log"), false)); + assert!(!is_gitignored( + &frames, + Path::new("/r/dist/bundle.js"), + false + )); + } + fn walk_with<'a>( ignore: &'a TableMatcher, glob: Option<&'a PathGlob>, @@ -662,6 +765,7 @@ mod tests { ignore, glob, gitignore: !frames.is_empty(), + in_repo: !frames.is_empty(), frames, } } @@ -900,6 +1004,7 @@ mod tests { ignore: &ignore, glob: Some(&glob), gitignore: false, + in_repo: false, frames: Vec::new(), }; assert!(walk.follows(true, &[2], Kind::File)); diff --git a/packages/rust/tests/path_table_gitignore.rs b/packages/rust/tests/path_table_gitignore.rs index f51795c0e..b7aa64248 100644 --- a/packages/rust/tests/path_table_gitignore.rs +++ b/packages/rust/tests/path_table_gitignore.rs @@ -1,7 +1,7 @@ -//! Integration tests for gitignore-by-default in path-table scans: a -//! `.gitignore` anywhere in the tree prunes the files it names below its own -//! directory, hierarchically, like fd/ripgrep — with no `.git` directory -//! required. Dot-named entries are hidden unless the path spells them. +//! Integration tests for gitignore-by-default in path-table scans: inside a +//! git repo, a `.gitignore` anywhere in the tree prunes the files it names +//! below its own directory, hierarchically, like git, fd and ripgrep; with no +//! repo, none applies. Dot-named entries are hidden unless the path spells them. //! Real filesystem, real SQLite, SDK public API. use std::fs; @@ -9,10 +9,27 @@ use std::fs; use dirsql::{DirSQL, Row, Value}; use tempfile::TempDir; +/// An empty directory holding `.git`, which puts its `.gitignore` files in +/// force. +fn repo() -> TempDir { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join(".git")).unwrap(); + root +} + +/// A tree shaped like `~/.claude`: a `.gitignore` of `*` over transcripts. +fn claude_home() -> TempDir { + let home = TempDir::new().unwrap(); + fs::create_dir_all(home.path().join(".claude/projects/p")).unwrap(); + fs::write(home.path().join(".claude/.gitignore"), "*\n").unwrap(); + fs::write(home.path().join(".claude/projects/p/t.jsonl"), "{}").unwrap(); + home +} + /// A tree with a root `.gitignore`, a nested one, ignored and kept files, /// hidden files, and a `node_modules` for the built-in floor. fn fixture() -> TempDir { - let root = TempDir::new().unwrap(); + let root = repo(); fs::create_dir_all(root.path().join("dist")).unwrap(); fs::create_dir_all(root.path().join("src")).unwrap(); fs::create_dir_all(root.path().join("sub")).unwrap(); @@ -100,7 +117,7 @@ fn a_nested_gitignore_applies_below_its_own_directory() { #[test] fn a_nested_gitignore_does_not_reach_outside_its_directory() { - let root = TempDir::new().unwrap(); + let root = repo(); fs::create_dir_all(root.path().join("sub")).unwrap(); fs::write(root.path().join("sub/.gitignore"), "*.md\n").unwrap(); fs::write(root.path().join("sub/inside.md"), "in").unwrap(); @@ -214,7 +231,7 @@ fn no_ignore_restores_gitignored_files_but_keeps_the_built_in_floor() { #[test] fn a_scoped_glob_still_honors_gitignore_rules_beneath_its_base() { - let root = TempDir::new().unwrap(); + let root = repo(); fs::create_dir_all(root.path().join("docs")).unwrap(); fs::write(root.path().join("docs/.gitignore"), "draft.md\n").unwrap(); fs::write(root.path().join("docs/draft.md"), "d").unwrap(); @@ -231,7 +248,7 @@ fn a_scoped_glob_still_honors_gitignore_rules_beneath_its_base() { } #[test] -fn a_gitignore_above_the_named_base_does_not_reach_beneath_it() { +fn a_gitignore_above_the_named_base_applies_beneath_it() { let root = fixture(); fs::create_dir_all(root.path().join("docs")).unwrap(); fs::write(root.path().join("docs/a.md"), "a").unwrap(); @@ -242,7 +259,95 @@ fn a_gitignore_above_the_named_base_does_not_reach_beneath_it() { assert_eq!( scanned, - vec!["docs/a.md", "docs/trace.log"], - "the root `*.log` rule is above the named base, so it does not apply there" + vec!["docs/a.md"], + "the root `*.log` rule applies however the path is spelled" ); } + +#[test] +fn a_repo_root_gitignore_above_the_index_root_applies() { + let repo = TempDir::new().unwrap(); + fs::create_dir_all(repo.path().join(".git")).unwrap(); + fs::create_dir_all(repo.path().join("sub")).unwrap(); + fs::write(repo.path().join(".gitignore"), "*.log\n").unwrap(); + fs::write(repo.path().join("sub/a.md"), "a").unwrap(); + fs::write(repo.path().join("sub/z.log"), "log").unwrap(); + let db = DirSQL::builder() + .root(repo.path().join("sub")) + .build() + .unwrap(); + + let scanned = paths(&db.query("SELECT path FROM './*'").unwrap()); + + assert_eq!( + scanned, + vec!["a.md"], + "git applies every .gitignore from the repo root down" + ); +} + +#[test] +fn a_gitignore_above_the_repo_root_does_not_apply() { + let outer = TempDir::new().unwrap(); + fs::create_dir_all(outer.path().join("repo/.git")).unwrap(); + fs::write(outer.path().join(".gitignore"), "*.log\n").unwrap(); + fs::write(outer.path().join("repo/z.log"), "log").unwrap(); + let db = DirSQL::builder() + .root(outer.path().join("repo")) + .build() + .unwrap(); + + let scanned = paths(&db.query("SELECT path FROM './*'").unwrap()); + + assert_eq!( + scanned, + vec!["z.log"], + "git reads no .gitignore above the repo root" + ); +} + +#[test] +fn with_no_repo_a_gitignore_does_not_apply_at_the_index_root() { + let home = claude_home(); + let db = DirSQL::builder() + .root(home.path().join(".claude")) + .build() + .unwrap(); + + let scanned = paths(&db.query("SELECT path FROM './projects/*/*.jsonl'").unwrap()); + + assert_eq!( + scanned, + vec!["projects/p/t.jsonl"], + "no .git encloses the tree, so its `*` .gitignore is not in force" + ); +} + +#[test] +fn with_no_repo_a_gitignore_does_not_apply_above_the_scan_start() { + let home = claude_home(); + let db = open(&home); + let base = home.path().join(".claude/projects"); + + let sql = format!("SELECT path FROM '{}/*/*.jsonl'", base.display()); + let scanned = paths(&db.query(&sql).unwrap()); + + assert!( + scanned.len() == 1 && scanned[0].ends_with("t.jsonl"), + "no .git encloses the tree, so its `*` .gitignore is not in force, got: {scanned:?}" + ); +} + +#[test] +fn inside_a_repo_the_same_gitignore_hides_everything() { + let home = claude_home(); + fs::create_dir(home.path().join(".claude/.git")).unwrap(); + let db = DirSQL::builder() + .root(home.path().join(".claude")) + .build() + .unwrap(); + + let scanned = paths(&db.query("SELECT path FROM './projects/*/*.jsonl'").unwrap()); + + assert!(scanned.is_empty(), "got: {scanned:?}"); +} diff --git a/packages/rust/tests/path_table_gitignore_cli_e2e.rs b/packages/rust/tests/path_table_gitignore_cli_e2e.rs index 35407d319..824265476 100644 --- a/packages/rust/tests/path_table_gitignore_cli_e2e.rs +++ b/packages/rust/tests/path_table_gitignore_cli_e2e.rs @@ -16,6 +16,7 @@ use tempfile::TempDir; /// and a `node_modules` for the built-in floor. fn fixture() -> TempDir { let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join(".git")).unwrap(); fs::create_dir_all(root.path().join("dist")).unwrap(); fs::create_dir_all(root.path().join(".hidden")).unwrap(); fs::create_dir_all(root.path().join("node_modules/pkg")).unwrap(); @@ -109,3 +110,41 @@ fn no_ignore_works_in_the_default_query_mode() { "the bare `dirsql \"\"` form takes --no-ignore too, got: {names:?}" ); } + +#[test] +fn a_root_gitignore_applies_to_a_scan_started_below_it() { + let dir = TempDir::new().unwrap(); + fs::create_dir(dir.path().join(".git")).unwrap(); + fs::create_dir_all(dir.path().join("docs")).unwrap(); + fs::write(dir.path().join(".gitignore"), "*.log\n").unwrap(); + fs::write(dir.path().join("docs/a.md"), "a").unwrap(); + fs::write(dir.path().join("docs/z.log"), "log").unwrap(); + + let out = run(&dir, &["query", "SELECT basename FROM './docs/*'"]); + + assert_eq!( + basenames(&out), + vec!["a.md"], + "the root `*.log` rule hides docs/z.log, as it does under './**'" + ); +} + +#[test] +fn with_no_repo_a_gitignore_hides_nothing() { + let dir = TempDir::new().unwrap(); + fs::create_dir_all(dir.path().join(".claude/projects/p")).unwrap(); + fs::write(dir.path().join(".claude/.gitignore"), "*\n").unwrap(); + fs::write(dir.path().join(".claude/projects/p/t.jsonl"), "{}").unwrap(); + let sql = format!( + "SELECT basename FROM '{}/*/*.jsonl'", + dir.path().join(".claude/projects").display() + ); + + let out = run(&dir, &["query", &sql]); + + assert_eq!( + basenames(&out), + vec!["t.jsonl"], + "no .git encloses the tree, so its `*` .gitignore is not in force" + ); +} diff --git a/packages/rust/tests/path_table_parallel_scan.rs b/packages/rust/tests/path_table_parallel_scan.rs index fe431ee80..7f69898d4 100644 --- a/packages/rust/tests/path_table_parallel_scan.rs +++ b/packages/rust/tests/path_table_parallel_scan.rs @@ -15,9 +15,11 @@ const PATTERNS: usize = 100; const ROUNDS: usize = 4; /// A tree whose walk costs CPU rather than disk: every directory carries a -/// `.gitignore` the walker compiles on entry, so a scan takes the same time -/// whether or not the page cache already holds the tree. +/// `.gitignore` the walker compiles on entry (`.git` puts them in force), so a +/// scan takes the same time whether or not the page cache already holds the +/// tree. fn tree(root: &Path, name: &str) { + fs::create_dir_all(root.join(".git")).unwrap(); let ignore: String = (0..PATTERNS) .map(|i| format!("build-{i}/**/*.tmp\n")) .collect(); diff --git a/packages/ts/changelog.d/2026-10-05-gitignore-requires-repo.md b/packages/ts/changelog.d/2026-10-05-gitignore-requires-repo.md new file mode 100644 index 000000000..4b5cb4ea4 --- /dev/null +++ b/packages/ts/changelog.d/2026-10-05-gitignore-requires-repo.md @@ -0,0 +1,5 @@ +**Changed** A `.gitignore` applies to path-table scans only inside a git +repo, as in git, fd and ripgrep: one is in force only when a directory +holding `.git` encloses it. Outside a repo none applies, at the scan's start, +above it or below it, so a `~/.claude` whose `.gitignore` is `*` lists its +files. Inside a repo nothing changes. `--no-ignore` is unchanged. diff --git a/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json b/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json new file mode 100644 index 000000000..548439d0b --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791219043, + "exit_code": 0, + "commit": "53cf207014f1279b8b40f19323c4be87c768222e", + "branch": "claude/1272-ancestor-gitignore" +} diff --git a/packages/ts/migrations.d/2026-10-05-gitignore-requires-repo.md b/packages/ts/migrations.d/2026-10-05-gitignore-requires-repo.md new file mode 100644 index 000000000..6d29083bf --- /dev/null +++ b/packages/ts/migrations.d/2026-10-05-gitignore-requires-repo.md @@ -0,0 +1,36 @@ +### TypeScript SDK: `.gitignore` applies only inside a git repo + +#### Summary + +Path-tables used to apply `.gitignore` files with no git repo present. Now a +`.gitignore` is in force only when a directory holding `.git` encloses it, +as in git, fd and ripgrep. Every surface that resolves a path-table is +affected: the CLI, the REPL, the HTTP server, the SDKs, and the `--on-file` +form. No call changes. Outside a repo a query can return more rows; inside +one nothing changes. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- Outside a git repo, files a `.gitignore` names are listed. To hide them, + add `ignore` patterns in a config file, or run `git init`. + +#### Verification + +```bash +cd "$(mktemp -d)" +printf '*.log\n' > .gitignore +touch a.md z.log +dirsql query "SELECT path FROM './*' ORDER BY path" +# expected: [{"path":"a.md"},{"path":"z.log"}] +git init -q +dirsql query "SELECT path FROM './*' ORDER BY path" +# expected: [{"path":"a.md"}] +``` diff --git a/packages/ts/tests/integration/binding/path-table-no-ignore.test.ts b/packages/ts/tests/integration/binding/path-table-no-ignore.test.ts index dd55606d5..6f052fb02 100644 --- a/packages/ts/tests/integration/binding/path-table-no-ignore.test.ts +++ b/packages/ts/tests/integration/binding/path-table-no-ignore.test.ts @@ -13,6 +13,7 @@ describe("path-table gitignore opt-out (#746)", () => { beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), "dirsql-746-")); + await mkdir(join(dir, ".git")); await mkdir(join(dir, "dist"), { recursive: true }); await mkdir(join(dir, "src"), { recursive: true }); await mkdir(join(dir, "node_modules", "pkg"), { recursive: true }); diff --git a/testing-conventions.toml b/testing-conventions.toml index ab8f21c27..24df8735e 100644 --- a/testing-conventions.toml +++ b/testing-conventions.toml @@ -108,25 +108,31 @@ features = ["cli"] [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [72, 121, 305, 338, 402, 423] +lines = [72, 131, 326, 363, 434, 455] reason = "scan_below, scan_glob, walk, descend, kind_of and sorted_entries read the filesystem; tests/scanner.rs (scan_finds_matching_files, scan_recurses_into_subdirectories), tests/vtab.rs (glob_scopes_the_walk, rowids_count_up_from_zero_in_scan_order) and tests/path_table_symlinks.rs assert what they return" [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [351, 352, 355, 403, 406] +lines = [380, 381, 384, 435, 438] reason = "whether a walked entry is a symlink, and whether the walk is below one, is known only from a real tree; tests/path_table_symlinks.rs asserts every outcome against bash, and the decisions themselves are unit-tested in classify, Walk::follows and PathGlob::step" [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [82] +lines = [364, 368, 397, 469, 482] +reason = "whether a repo encloses the walk, and which .gitignore files are in force, is known only from a real tree holding .git; the choice of directories is unit-tested in enclosing_repo and dirs_above, and tests/path_table_gitignore.rs (with_no_repo_a_gitignore_does_not_apply_at_the_index_root, inside_a_repo_the_same_gitignore_hides_everything, a_repo_root_gitignore_above_the_index_root_applies) asserts every outcome" + +[[rust.exempt]] +path = "src/scanner.rs" +rules = ["mutation"] +lines = [89] reason = "the walk's running file count is observable only through the CLI's progress line; tests/scan_progress_e2e.rs forced_progress_reports_the_walk_phase_on_stderr asserts the number" [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [345] +lines = [374] reason = "a child's depth decides the reserved top-level .dirsql rule for a real tree; tests/scanner.rs scan_excludes_top_level_dirsql_directory and tests/vtab.rs reserved_dirsql_directory_is_skipped assert it" [[rust.exempt]]