From a47edc47d407d5d9e45cff31b2713bbeb5c966f8 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:39:36 -0400 Subject: [PATCH 01/11] Test that a .gitignore above the scan start applies Red: a path-table scan starting below the index root, or an index root inside a repo, ignores the .gitignore files above it. --- packages/rust/tests/path_table_gitignore.rs | 48 +++++++++++++++++-- .../tests/path_table_gitignore_cli_e2e.rs | 17 +++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/rust/tests/path_table_gitignore.rs b/packages/rust/tests/path_table_gitignore.rs index 83fbe67bc..6883cfe7c 100644 --- a/packages/rust/tests/path_table_gitignore.rs +++ b/packages/rust/tests/path_table_gitignore.rs @@ -196,7 +196,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(); @@ -207,7 +207,49 @@ 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" ); } diff --git a/packages/rust/tests/path_table_gitignore_cli_e2e.rs b/packages/rust/tests/path_table_gitignore_cli_e2e.rs index 3f29b6f04..cc95fb997 100644 --- a/packages/rust/tests/path_table_gitignore_cli_e2e.rs +++ b/packages/rust/tests/path_table_gitignore_cli_e2e.rs @@ -109,3 +109,20 @@ 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_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 './**'" + ); +} From 15662a86bf901e3067d9342ce6768efc80eb0b09 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:44:20 -0400 Subject: [PATCH 02/11] Apply .gitignore files above a path-table's scan start Load every .gitignore from the nearest repo root, or the index root with no repo, down to the directory the scan starts in. The stat module now takes the index root, as the parsed module already did. --- docs/reference/path-tables.md | 9 +- ...026-10-05-path-table-ancestor-gitignore.md | 6 + ...026-10-05-path-table-ancestor-gitignore.md | 38 +++++ packages/rust/src/db.rs | 17 +-- packages/rust/src/parsed_vtab.rs | 2 +- packages/rust/src/scanner.rs | 141 +++++++++++++++--- packages/rust/src/vtab.rs | 80 +++++++--- .../rust/tests/path_table_content_prefetch.rs | 2 +- packages/rust/tests/vtab.rs | 8 +- 9 files changed, 250 insertions(+), 53 deletions(-) create mode 100644 packages/rust/changelog.d/2026-10-05-path-table-ancestor-gitignore.md create mode 100644 packages/rust/migrations.d/2026-10-05-path-table-ancestor-gitignore.md diff --git a/docs/reference/path-tables.md b/docs/reference/path-tables.md index fbeadd4be..0b936099c 100644 --- a/docs/reference/path-tables.md +++ b/docs/reference/path-tables.md @@ -266,6 +266,11 @@ directory is required — a `.gitignore` in any scanned directory counts — and the built-in defaults above remain as a floor for directories with no `.gitignore` at all. +The `.gitignore` files above the directory a scan starts in apply too, as +they do in git: every one from the repo root (the nearest directory holding +`.git`) down, or from the index root when there is no repo. 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 @@ -282,8 +287,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/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..2ac224e5f --- /dev/null +++ b/packages/rust/changelog.d/2026-10-05-path-table-ancestor-gitignore.md @@ -0,0 +1,6 @@ +**Fixed** 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, or from the index root when there is +no repo. `'./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-path-table-ancestor-gitignore.md b/packages/rust/migrations.d/2026-10-05-path-table-ancestor-gitignore.md new file mode 100644 index 000000000..d7a2873c9 --- /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 `'./**'`. It now reads every `.gitignore` from the repo root (the +nearest directory holding `.git`) down to that directory, or from the index +root when no repo encloses it. 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)" +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/db.rs b/packages/rust/src/db.rs index 40ad4b2d3..9a6899447 100644 --- a/packages/rust/src/db.rs +++ b/packages/rust/src/db.rs @@ -144,8 +144,9 @@ fn no_home_path_table(name: &str) -> String { /// the stat columns, and the `path_prefix` is irrelevant (a parser wanting a /// path emits it). Both forms carry the same ignore rules. /// -/// A parsed table also carries `index_root`, where the parser is spawned. The -/// stat module takes no such argument: it spawns nothing. +/// Both carry `index_root`: the top of the `.gitignore` files that apply when +/// no repo encloses the scan, and for a parsed table where its parser is +/// spawned. fn path_table_ddl( name: &str, table: &PathTable, @@ -177,9 +178,7 @@ fn path_table_ddl( } else { scanner::NO_GITIGNORE_ARG })); - if parser.is_some() { - args.push(quote_literal(&index_root.to_string_lossy())); - } + args.push(quote_literal(&index_root.to_string_lossy())); args.extend(ignore.iter().map(|p| quote_literal(p))); format!( @@ -3107,7 +3106,7 @@ mod tests { assert_eq!( ddl, "CREATE VIRTUAL TABLE IF NOT EXISTS temp.\"./docs/*.md\" \ - USING dirsql_path('/root', 'docs/*.md', '', 'gitignore')" + USING dirsql_path('/root', 'docs/*.md', '', 'gitignore', '/root')" ); } @@ -3127,7 +3126,7 @@ mod tests { None, Path::new("/root"), ) - .contains("'/var/log', '*.log', '/var/log', 'gitignore')"), + .contains("'/var/log', '*.log', '/var/log', 'gitignore', '/root')"), "got: {}", path_table_ddl( "/var/log/*.log", @@ -3151,7 +3150,7 @@ mod tests { Path::new("/root"), ); assert!( - ddl.ends_with("'', 'no-gitignore')"), + ddl.ends_with("'', 'no-gitignore', '/root')"), "gitignore off must emit the no-gitignore switch, got: {ddl}" ); } @@ -3167,7 +3166,7 @@ mod tests { Path::new("/root"), ); assert!( - ddl.ends_with("'', 'gitignore', 'node_modules/**', '*.tmp')"), + ddl.ends_with("'', 'gitignore', '/root', 'node_modules/**', '*.tmp')"), "got: {ddl}" ); } diff --git a/packages/rust/src/parsed_vtab.rs b/packages/rust/src/parsed_vtab.rs index e07a4c64b..61fb56807 100644 --- a/packages/rust/src/parsed_vtab.rs +++ b/packages/rust/src/parsed_vtab.rs @@ -152,7 +152,7 @@ impl TableSource for ParsedTable { // A parsed path-table honors the same skip rules a stat path-table does // (node_modules/.git, gitignore, plus any configured ignore), so a // parsed `SELECT * FROM './'` doesn't drown in dependency trees. - let rel_paths = scan_glob(&root, &glob, &ignore, gitignore); + let rel_paths = scan_glob(&root, &index_root, &glob, &ignore, gitignore); let run = |rel: &[PathBuf]| run_parser(&command, &index_root, &root, rel); let rows = collect_rows(&rel_paths, &run)?; diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 397ae89a0..b7fdaac38 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, + Vec::new(), + &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 } @@ -109,13 +117,22 @@ fn scan_below( /// With `gitignore` set, `.gitignore` files apply hierarchically (each one /// below its own directory) and prune traversal, like fd/ripgrep — except /// that hidden files are still scanned, and no `.git` directory is required. -/// Only files at or below `root` are read; one above it has no say. +/// Those above `root` apply too, as in git: up to the nearest directory +/// holding `.git`, or with no repo up to `index_root`. They filter what lies +/// below `root`, never `root` itself, so naming an ignored directory still +/// scans it. pub fn scan_glob( root: &Path, + index_root: &Path, glob: &PathGlob, ignore: &TableMatcher, gitignore: bool, ) -> Vec { + let frames = if gitignore { + gitignores_above(root, index_root) + } else { + Vec::new() + }; let mut results = Vec::new(); walk( root, @@ -123,6 +140,7 @@ pub fn scan_glob( ignore, Some(glob), gitignore, + frames, &mut |rel_path, _| { if is_wanted(glob, ignore, &rel_path) { results.push(rel_path); @@ -272,15 +290,17 @@ pub(crate) fn parse_gitignore_arg(arg: &str) -> Result { /// in path order without a sort at the end. Prunes the reserved top-level /// `.dirsql/` subtree and any directory the skip rules ignore wholesale, so an /// ignored tree is never read at 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. +/// `.gitignore` in force ignores are pruned/skipped too, starting from +/// `frames`, the ones in force above `start`. 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, + frames: Vec, visit: &mut dyn FnMut(PathBuf, &DirEntry), ) { let rel = start.strip_prefix(root).unwrap_or(start); @@ -291,7 +311,7 @@ fn walk( ignore, glob, gitignore, - frames: Vec::new(), + frames, }; let states = glob.map_or_else(Vec::new, PathGlob::start); walk.descend(start, rel, depth, &states, false, visit); @@ -403,6 +423,41 @@ fn sorted_entries(dir: &Path) -> Vec<(OsString, DirEntry)> { entries } +/// The `.gitignore` files above `start` that are in force at it, outermost +/// first. +fn gitignores_above(start: &Path, index_root: &Path) -> Vec { + let top = gitignore_top(start, index_root, &|dir| dir.join(".git").exists()); + dirs_above(start, top) + .into_iter() + .filter_map(load_gitignore) + .collect() +} + +/// The highest directory whose `.gitignore` reaches `start`: the nearest one +/// holding `.git`, where git stops looking. With no repo, the index root, +/// matching a scan that starts there; a `start` outside the index root reads +/// nothing above itself. +fn gitignore_top<'a>( + start: &'a Path, + index_root: &Path, + is_repo_root: &dyn Fn(&Path) -> bool, +) -> &'a Path { + start + .ancestors() + .find(|dir| is_repo_root(dir)) + .or_else(|| start.ancestors().find(|dir| *dir == index_root)) + .unwrap_or(start) +} + +/// 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 { @@ -625,6 +680,58 @@ mod tests { assert!(!is_wanted(&glob, &ignore, Path::new("drafts/a.md"))); } + #[test] + fn gitignore_top_is_the_nearest_repo_root() { + let top = gitignore_top(Path::new("/repo/a/b"), Path::new("/repo/a"), &|dir| { + dir == Path::new("/repo") + }); + assert_eq!(top, Path::new("/repo")); + } + + #[test] + fn gitignore_top_stops_at_a_repo_nested_below_another() { + let top = gitignore_top(Path::new("/outer/inner/a"), Path::new("/"), &|dir| { + dir == Path::new("/outer") || dir == Path::new("/outer/inner") + }); + assert_eq!(top, Path::new("/outer/inner")); + } + + #[test] + fn gitignore_top_is_the_index_root_with_no_repo() { + let top = gitignore_top(Path::new("/idx/docs/sub"), Path::new("/idx"), &|_| false); + assert_eq!(top, Path::new("/idx")); + } + + #[test] + fn gitignore_top_is_the_start_outside_the_index_root_with_no_repo() { + let top = gitignore_top(Path::new("/var/log"), Path::new("/idx"), &|_| false); + assert_eq!(top, Path::new("/var/log")); + } + + #[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, frames: Vec) -> Walk<'a> { Walk { ignore, diff --git a/packages/rust/src/vtab.rs b/packages/rust/src/vtab.rs index 7cdd34429..8b63dbdfe 100644 --- a/packages/rust/src/vtab.rs +++ b/packages/rust/src/vtab.rs @@ -19,11 +19,11 @@ pub use crate::vtab_scaffold::StatementScope; /// SQL module name a path-table is created with: /// `CREATE VIRTUAL TABLE t USING dirsql_path('', '', '', -/// ''[, ''...])`. +/// '', ''[, ''...])`. pub const MODULE_NAME: &str = "dirsql_path"; /// Number of module arguments that are not ignore patterns. -const FIXED_ARGS: usize = 4; +const FIXED_ARGS: usize = 5; /// The seven stat columns, in declaration order. pub const STAT_COLUMNS: [&str; 7] = ["path", "basename", "dir", "ext", "size", "mtime", "ctime"]; @@ -144,6 +144,7 @@ struct ScanSpec { ignore: TableMatcher, /// Whether the scan respects `.gitignore` files (off under `--no-ignore`). gitignore: bool, + index_root: PathBuf, } /// One matched file, as compact as the seven stat columns allow: the three @@ -299,16 +300,24 @@ fn cell(row: &FileRow, i: c_int) -> Option> { /// Parse a path-table's own `CREATE VIRTUAL TABLE` arguments into its scan /// spec. `args[0..3]` are the module, database and table names; the module's -/// own arguments follow — root, glob, path prefix, the gitignore switch, then -/// any ignore patterns. +/// own arguments follow — root, glob, path prefix, the gitignore switch, the +/// index root, then any ignore patterns. fn parse_module_args(args: &[&[u8]]) -> Result { let user_args = vtab_scaffold::user_args(args); - let [root, pattern, path_prefix, gitignore, ignore @ ..] = user_args.as_slice() else { + let [ + root, + pattern, + path_prefix, + gitignore, + index_root, + ignore @ .., + ] = user_args.as_slice() + else { return Err(vtab_scaffold::arity_error( MODULE_NAME, FIXED_ARGS, - "root, glob, path prefix, gitignore switch", + "root, glob, path prefix, gitignore switch, index root", user_args.len(), )); }; @@ -319,6 +328,7 @@ fn parse_module_args(args: &[&[u8]]) -> Result { path_prefix: PathBuf::from(path_prefix), ignore: vtab_scaffold::compile_ignore(ignore)?, gitignore: vtab_scaffold::parse_gitignore(gitignore)?, + index_root: PathBuf::from(index_root), }) } @@ -356,7 +366,13 @@ impl TableSource for ScanSpec { fn rows(&self) -> Arc> { // The scan runs per statement rather than at CREATE, which is what // makes reads live: each statement sees the filesystem as it is now. - let rel_paths = scan_glob(&self.root, &self.glob, &self.ignore, self.gitignore); + let rel_paths = scan_glob( + &self.root, + &self.index_root, + &self.glob, + &self.ignore, + self.gitignore, + ); let stat = |rel: &Path| { fs::metadata(self.root.join(rel)).map_or_else( |_| StatFacts::default(), @@ -482,7 +498,13 @@ mod tests { #[test] fn parse_module_args_extracts_root_and_glob() { - let args = args_with(&[b"'/tmp/notes'", b"'**/*.md'", b"''", b"'gitignore'"]); + let args = args_with(&[ + b"'/tmp/notes'", + b"'**/*.md'", + b"''", + b"'gitignore'", + b"'/idx'", + ]); let spec = parse_module_args(&args).unwrap(); assert_eq!(spec.root, PathBuf::from("/tmp/notes")); @@ -492,7 +514,7 @@ mod tests { #[test] fn parse_module_args_accepts_unquoted_arguments() { - let args = args_with(&[b"/tmp/notes", b"**/*", b"", b"gitignore"]); + let args = args_with(&[b"/tmp/notes", b"**/*", b"", b"gitignore", b"/idx"]); assert_eq!( parse_module_args(&args).unwrap().root, PathBuf::from("/tmp/notes") @@ -501,7 +523,13 @@ mod tests { #[test] fn parse_module_args_extracts_the_path_prefix() { - let args = args_with(&[b"'/var/log'", b"'*.log'", b"'/var/log'", b"'gitignore'"]); + let args = args_with(&[ + b"'/var/log'", + b"'*.log'", + b"'/var/log'", + b"'gitignore'", + b"'/idx'", + ]); assert_eq!( parse_module_args(&args).unwrap().path_prefix, PathBuf::from("/var/log") @@ -510,16 +538,16 @@ mod tests { #[test] fn parse_module_args_reads_the_gitignore_switch() { - let on = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'"]); + let on = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'", b"'/idx'"]); assert!(parse_module_args(&on).unwrap().gitignore); - let off = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'no-gitignore'"]); + let off = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'no-gitignore'", b"'/idx'"]); assert!(!parse_module_args(&off).unwrap().gitignore); } #[test] fn parse_module_args_rejects_an_unknown_gitignore_switch() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'sometimes'"]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'sometimes'", b"'/idx'"]); let err = parse_module_args(&args) .err() .expect("an unknown switch must be rejected"); @@ -533,6 +561,7 @@ mod tests { b"'**/*'", b"''", b"'gitignore'", + b"'/idx'", b"'node_modules/**'", ]); let spec = parse_module_args(&args).unwrap(); @@ -543,32 +572,39 @@ mod tests { #[test] fn parse_module_args_accepts_no_ignore_patterns() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'"]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'", b"'/idx'"]); let spec = parse_module_args(&args).unwrap(); assert!(!spec.ignore.is_ignored(Path::new("node_modules/a.js"))); } #[test] fn parse_module_args_rejects_too_few_arguments() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''"]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'"]); let err = parse_module_args(&args) .err() .expect("arity must be enforced"); assert!( - err.to_string().contains("at least 4 arguments"), + err.to_string().contains("at least 5 arguments"), "error should name the arity, got: {err}" ); } #[test] fn parse_module_args_rejects_an_invalid_glob() { - let args = args_with(&[b"'/tmp'", b"'['", b"''", b"'gitignore'"]); + let args = args_with(&[b"'/tmp'", b"'['", b"''", b"'gitignore'", b"'/idx'"]); assert!(parse_module_args(&args).is_err()); } #[test] fn parse_module_args_rejects_an_invalid_ignore_pattern() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'", b"'['"]); + let args = args_with(&[ + b"'/tmp'", + b"'**/*'", + b"''", + b"'gitignore'", + b"'/idx'", + b"'['", + ]); assert!(parse_module_args(&args).is_err()); } @@ -650,7 +686,13 @@ mod tests { #[test] fn a_scan_spec_keys_a_row_by_the_text_of_the_lookup_column() { - let args = args_with(&[b"'/tmp/notes'", b"'**/*.md'", b"''", b"'gitignore'"]); + let args = args_with(&[ + b"'/tmp/notes'", + b"'**/*.md'", + b"''", + b"'gitignore'", + b"'/idx'", + ]); let spec = parse_module_args(&args).unwrap(); let mut row = row_for("", "docs/a.md"); row.facts = facts(3); diff --git a/packages/rust/tests/path_table_content_prefetch.rs b/packages/rust/tests/path_table_content_prefetch.rs index 36477ba9c..fb15cbf3c 100644 --- a/packages/rust/tests/path_table_content_prefetch.rs +++ b/packages/rust/tests/path_table_content_prefetch.rs @@ -40,7 +40,7 @@ fn open(root: &Path) -> Connection { load_module(&conn, StatementScope::new()).unwrap(); for (name, glob) in [("a", "*/abstract.md"), ("t", "*/title.md")] { conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE {name} USING dirsql_path('{}', '{glob}', '', 'gitignore')", + "CREATE VIRTUAL TABLE {name} USING dirsql_path('{0}', '{glob}', '', 'gitignore', '{0}')", root.display() )) .unwrap(); diff --git a/packages/rust/tests/vtab.rs b/packages/rust/tests/vtab.rs index 72ad2c87d..4cdcf07f5 100644 --- a/packages/rust/tests/vtab.rs +++ b/packages/rust/tests/vtab.rs @@ -35,7 +35,7 @@ fn open_scoped(dir: &TempDir, glob: &str) -> (Connection, Arc) { let scope = StatementScope::new(); load_module(&conn, Arc::clone(&scope)).unwrap(); conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE t USING dirsql_path('{}', '{}', '', 'gitignore')", + "CREATE VIRTUAL TABLE t USING dirsql_path('{0}', '{1}', '', 'gitignore', '{0}')", dir.path().display(), glob )) @@ -53,7 +53,7 @@ fn open_over_with(dir: &TempDir, glob: &str, prefix: &str, ignore: &[&str]) -> C let conn = Connection::open_in_memory().unwrap(); load_module(&conn, StatementScope::new()).unwrap(); let mut args = format!( - "'{}', '{}', '{}', 'gitignore'", + "'{0}', '{1}', '{2}', 'gitignore', '{0}'", dir.path().display(), glob, prefix @@ -366,7 +366,7 @@ fn a_table_rooted_in_an_ignored_directory_still_scans_it() { let conn = Connection::open_in_memory().unwrap(); load_module(&conn, StatementScope::new()).unwrap(); conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE t USING dirsql_path('{}', '**/*', 'node_modules', 'gitignore', '**/node_modules/**')", + "CREATE VIRTUAL TABLE t USING dirsql_path('{0}', '**/*', 'node_modules', 'gitignore', '{0}', '**/node_modules/**')", dir.path().join("node_modules").display() )) .unwrap(); @@ -490,7 +490,7 @@ fn open_pair_scoped( load_module(&conn, Arc::clone(&scope)).unwrap(); for (name, glob) in [("a", glob_a), ("b", glob_b)] { conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE {name} USING dirsql_path('{}', '{glob}', '', 'gitignore')", + "CREATE VIRTUAL TABLE {name} USING dirsql_path('{0}', '{glob}', '', 'gitignore', '{0}')", dir.path().display(), )) .unwrap(); From 8479371fd57f71dc06671f8041b4280deb7716fd Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:52:11 -0400 Subject: [PATCH 03/11] Move mutation exemptions to the shifted scanner and vtab lines --- testing-conventions.toml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/testing-conventions.toml b/testing-conventions.toml index 58bc9b587..8c6d01ea4 100644 --- a/testing-conventions.toml +++ b/testing-conventions.toml @@ -108,31 +108,37 @@ features = ["cli"] [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [72, 119, 286, 319, 374, 395] +lines = [72, 131, 306, 339, 394, 415] 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 = [332, 333, 336, 375, 378] +lines = [352, 353, 356, 395, 398] 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 = [429] +reason = "gitignores_above probes a real tree for .git and reads the .gitignore files it finds; the choice of directories is unit-tested in gitignore_top and dirs_above, and tests/path_table_gitignore.rs (a_gitignore_above_the_named_base_applies_beneath_it, a_repo_root_gitignore_above_the_index_root_applies) asserts the result" + +[[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 = [326] +lines = [346] 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]] path = "src/vtab.rs" rules = ["mutation"] -lines = [360, 371] +lines = [369, 376, 387] reason = "rows walks and stats a real tree and column hands a cell to SQLite; the cell choice is unit-tested in `cell`, and tests/vtab.rs stat_columns_carry_real_values and content_is_excluded_from_star_but_selectable_by_name assert both through a real connection" [[rust.exempt]] @@ -144,7 +150,7 @@ reason = "run_default and run_query build a real index and drive stdout, stderr [[rust.exempt]] path = "src/vtab.rs" rules = ["mutation"] -lines = [81, 328, 382] +lines = [81, 338, 398] reason = "read_text and ScanSpec::read open a real file and prefetch reads a real tree ahead of the cursor; the buffer logic is unit-tested in `read_all` and `presize`, tests/vtab.rs content_is_excluded_from_star_but_selectable_by_name, content_is_null_for_non_utf8_files and unreadable_file_yields_null_content_without_erroring_the_row assert the cell, and tests/path_table_content_prefetch.rs content_is_read_for_every_row_before_the_first_is_delivered and a_join_has_the_content_it_names_read_ahead assert the read-ahead" [[rust.exempt]] From e72a8787aab05b139ca590fca9cc1d42137f2842 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:01:28 -0400 Subject: [PATCH 04/11] e2e attestation for claude/1272-ancestor-gitignore --- .../e2e-attestations/claude-1272-ancestor-gitignore.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json 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..a341e6bb5 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1791216088, + "exit_code": 0, + "commit": "8479371fd57f71dc06671f8041b4280deb7716fd", + "branch": "claude/1272-ancestor-gitignore" +} From d4308bba013276eb1f97ad52a3be0755a9783db1 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:02:41 -0400 Subject: [PATCH 05/11] e2e attestation for claude/1272-ancestor-gitignore --- .../e2e-attestations/claude-1272-ancestor-gitignore.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json 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..28dcb2659 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791216160, + "exit_code": 0, + "commit": "e72a8787aab05b139ca590fca9cc1d42137f2842", + "branch": "claude/1272-ancestor-gitignore" +} From 4d3c700e9d135926c733ca4d5c7b6a058f021b74 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:35:09 -0400 Subject: [PATCH 06/11] Test that a .gitignore applies only inside a git repo Red: with no enclosing .git, a .gitignore of * still hides a ~/.claude-shaped tree. Gitignore fixtures now hold .git so their rules stay in force. --- .../binding/path_table_no_ignore_test.py | 1 + packages/rust/tests/path_table_gitignore.rs | 77 +++++++++++++++++-- .../tests/path_table_gitignore_cli_e2e.rs | 22 ++++++ .../rust/tests/path_table_parallel_scan.rs | 6 +- .../binding/path-table-no-ignore.test.ts | 1 + 5 files changed, 98 insertions(+), 9 deletions(-) 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/tests/path_table_gitignore.rs b/packages/rust/tests/path_table_gitignore.rs index b635f3444..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(); @@ -288,3 +305,49 @@ fn a_gitignore_above_the_repo_root_does_not_apply() { "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 9ed662a98..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(); @@ -113,6 +114,7 @@ fn no_ignore_works_in_the_default_query_mode() { #[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(); @@ -126,3 +128,23 @@ fn a_root_gitignore_applies_to_a_scan_started_below_it() { "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/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 }); From e2cd6373ab1d71218df1b673c7b9e8ff2262cddd Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:46:44 -0400 Subject: [PATCH 07/11] Apply .gitignore only inside a git repo --- docs/reference/path-tables.md | 17 +-- .../2026-10-05-gitignore-requires-repo.md | 5 + .../2026-10-05-gitignore-requires-repo.md | 36 +++++++ .../2026-10-05-gitignore-requires-repo.md | 5 + ...026-10-05-path-table-ancestor-gitignore.md | 12 +-- .../2026-10-05-gitignore-requires-repo.md | 36 +++++++ ...026-10-05-path-table-ancestor-gitignore.md | 10 +- packages/rust/src/db.rs | 17 +-- packages/rust/src/parsed_vtab.rs | 2 +- packages/rust/src/scanner.rs | 100 +++++++++--------- packages/rust/src/vtab.rs | 80 ++++---------- .../rust/tests/path_table_content_prefetch.rs | 2 +- packages/rust/tests/vtab.rs | 8 +- .../2026-10-05-gitignore-requires-repo.md | 5 + .../2026-10-05-gitignore-requires-repo.md | 36 +++++++ testing-conventions.toml | 14 +-- 16 files changed, 233 insertions(+), 152 deletions(-) create mode 100644 packages/python/changelog.d/2026-10-05-gitignore-requires-repo.md create mode 100644 packages/python/migrations.d/2026-10-05-gitignore-requires-repo.md create mode 100644 packages/rust/changelog.d/2026-10-05-gitignore-requires-repo.md create mode 100644 packages/rust/migrations.d/2026-10-05-gitignore-requires-repo.md create mode 100644 packages/ts/changelog.d/2026-10-05-gitignore-requires-repo.md create mode 100644 packages/ts/migrations.d/2026-10-05-gitignore-requires-repo.md diff --git a/docs/reference/path-tables.md b/docs/reference/path-tables.md index 581fac10f..07b5f241a 100644 --- a/docs/reference/path-tables.md +++ b/docs/reference/path-tables.md @@ -257,18 +257,19 @@ 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. -The `.gitignore` files above the directory a scan starts in apply too, as -they do in git: every one from the repo root (the nearest directory holding -`.git`) down, or from the index root when there is no repo. So +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 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/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/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 index 2ac224e5f..922c3e6bd 100644 --- 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 @@ -1,6 +1,6 @@ -**Fixed** 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, or from the index root when there is -no repo. `'./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. +**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 index d7a2873c9..71a4f77e6 100644 --- 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 @@ -4,11 +4,10 @@ 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 `'./**'`. It now reads every `.gitignore` from the repo root (the -nearest directory holding `.git`) down to that directory, or from the index -root when no repo encloses it. 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 +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 @@ -30,6 +29,7 @@ _None._ ```bash cd "$(mktemp -d)" +git init -q mkdir docs printf '*.log\n' > .gitignore touch docs/a.md docs/z.log diff --git a/packages/rust/src/db.rs b/packages/rust/src/db.rs index 9a6899447..40ad4b2d3 100644 --- a/packages/rust/src/db.rs +++ b/packages/rust/src/db.rs @@ -144,9 +144,8 @@ fn no_home_path_table(name: &str) -> String { /// the stat columns, and the `path_prefix` is irrelevant (a parser wanting a /// path emits it). Both forms carry the same ignore rules. /// -/// Both carry `index_root`: the top of the `.gitignore` files that apply when -/// no repo encloses the scan, and for a parsed table where its parser is -/// spawned. +/// A parsed table also carries `index_root`, where the parser is spawned. The +/// stat module takes no such argument: it spawns nothing. fn path_table_ddl( name: &str, table: &PathTable, @@ -178,7 +177,9 @@ fn path_table_ddl( } else { scanner::NO_GITIGNORE_ARG })); - args.push(quote_literal(&index_root.to_string_lossy())); + if parser.is_some() { + args.push(quote_literal(&index_root.to_string_lossy())); + } args.extend(ignore.iter().map(|p| quote_literal(p))); format!( @@ -3106,7 +3107,7 @@ mod tests { assert_eq!( ddl, "CREATE VIRTUAL TABLE IF NOT EXISTS temp.\"./docs/*.md\" \ - USING dirsql_path('/root', 'docs/*.md', '', 'gitignore', '/root')" + USING dirsql_path('/root', 'docs/*.md', '', 'gitignore')" ); } @@ -3126,7 +3127,7 @@ mod tests { None, Path::new("/root"), ) - .contains("'/var/log', '*.log', '/var/log', 'gitignore', '/root')"), + .contains("'/var/log', '*.log', '/var/log', 'gitignore')"), "got: {}", path_table_ddl( "/var/log/*.log", @@ -3150,7 +3151,7 @@ mod tests { Path::new("/root"), ); assert!( - ddl.ends_with("'', 'no-gitignore', '/root')"), + ddl.ends_with("'', 'no-gitignore')"), "gitignore off must emit the no-gitignore switch, got: {ddl}" ); } @@ -3166,7 +3167,7 @@ mod tests { Path::new("/root"), ); assert!( - ddl.ends_with("'', 'gitignore', '/root', 'node_modules/**', '*.tmp')"), + ddl.ends_with("'', 'gitignore', 'node_modules/**', '*.tmp')"), "got: {ddl}" ); } diff --git a/packages/rust/src/parsed_vtab.rs b/packages/rust/src/parsed_vtab.rs index 61fb56807..e07a4c64b 100644 --- a/packages/rust/src/parsed_vtab.rs +++ b/packages/rust/src/parsed_vtab.rs @@ -152,7 +152,7 @@ impl TableSource for ParsedTable { // A parsed path-table honors the same skip rules a stat path-table does // (node_modules/.git, gitignore, plus any configured ignore), so a // parsed `SELECT * FROM './'` doesn't drown in dependency trees. - let rel_paths = scan_glob(&root, &index_root, &glob, &ignore, gitignore); + let rel_paths = scan_glob(&root, &glob, &ignore, gitignore); let run = |rel: &[PathBuf]| run_parser(&command, &index_root, &root, rel); let rows = collect_rows(&rel_paths, &run)?; diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 6159bb7ff..4d1dfa832 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -80,7 +80,7 @@ fn scan_below( matcher, None, false, - Vec::new(), + None, &mut |rel_path, entry| { if matcher.is_ignored(&rel_path) { return; @@ -116,24 +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. Those above `root` apply too, as in git: up -/// to the nearest directory holding `.git`, or with no repo up to -/// `index_root`. They filter what lies below `root`, never `root` itself, so -/// naming an ignored directory still scans it. +/// 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, - index_root: &Path, glob: &PathGlob, ignore: &TableMatcher, gitignore: bool, ) -> Vec { - let frames = if gitignore { - gitignores_above(root, index_root) + let repo = if gitignore { + enclosing_repo(root, &holds_git) } else { - Vec::new() + None }; + let repo_frames = repo.map(|top| gitignores_above(root, top)); let mut results = Vec::new(); walk( root, @@ -141,7 +141,7 @@ pub fn scan_glob( ignore, Some(glob), gitignore, - frames, + repo_frames, &mut |rel_path, _| { if is_wanted(glob, ignore, &rel_path) { results.push(rel_path); @@ -309,7 +309,8 @@ pub(crate) fn parse_gitignore_arg(arg: &str) -> Result { /// 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, starting -/// from `frames`, the ones in force above `start`. Symlinks are followed only +/// from `repo_frames`: the ones in force above `start` when a repo encloses +/// it, `None` when none does. Below a directory holding `.git` a repo does. Symlinks are followed only /// as `glob` allows, and not at all without one; a broken link or an /// unreadable directory contributes nothing. fn walk( @@ -318,7 +319,7 @@ fn walk( ignore: &TableMatcher, glob: Option<&PathGlob>, gitignore: bool, - frames: Vec, + repo_frames: Option>, visit: &mut dyn FnMut(PathBuf, &DirEntry), ) { let rel = start.strip_prefix(root).unwrap_or(start); @@ -329,7 +330,8 @@ fn walk( ignore, glob, gitignore, - frames, + 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); @@ -339,6 +341,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, @@ -354,8 +359,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); @@ -384,6 +393,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 @@ -450,30 +462,23 @@ fn sorted_entries(dir: &Path) -> Vec<(OsString, DirEntry)> { entries } -/// The `.gitignore` files above `start` that are in force at it, outermost -/// first. -fn gitignores_above(start: &Path, index_root: &Path) -> Vec { - let top = gitignore_top(start, index_root, &|dir| dir.join(".git").exists()); +/// 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 highest directory whose `.gitignore` reaches `start`: the nearest one -/// holding `.git`, where git stops looking. With no repo, the index root, -/// matching a scan that starts there; a `start` outside the index root reads -/// nothing above itself. -fn gitignore_top<'a>( - start: &'a Path, - index_root: &Path, - is_repo_root: &dyn Fn(&Path) -> bool, -) -> &'a Path { - start - .ancestors() - .find(|dir| is_repo_root(dir)) - .or_else(|| start.ancestors().find(|dir| *dir == index_root)) - .unwrap_or(start) +/// 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 @@ -708,31 +713,22 @@ mod tests { } #[test] - fn gitignore_top_is_the_nearest_repo_root() { - let top = gitignore_top(Path::new("/repo/a/b"), Path::new("/repo/a"), &|dir| { - dir == Path::new("/repo") - }); - assert_eq!(top, Path::new("/repo")); - } - - #[test] - fn gitignore_top_stops_at_a_repo_nested_below_another() { - let top = gitignore_top(Path::new("/outer/inner/a"), Path::new("/"), &|dir| { + 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!(top, Path::new("/outer/inner")); + assert_eq!(repo, Some(Path::new("/outer/inner"))); } #[test] - fn gitignore_top_is_the_index_root_with_no_repo() { - let top = gitignore_top(Path::new("/idx/docs/sub"), Path::new("/idx"), &|_| false); - assert_eq!(top, Path::new("/idx")); + 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 gitignore_top_is_the_start_outside_the_index_root_with_no_repo() { - let top = gitignore_top(Path::new("/var/log"), Path::new("/idx"), &|_| false); - assert_eq!(top, Path::new("/var/log")); + fn enclosing_repo_is_none_outside_any_repo() { + assert_eq!(enclosing_repo(Path::new("/idx/docs"), &|_| false), None); } #[test] @@ -768,6 +764,7 @@ mod tests { ignore, glob, gitignore: !frames.is_empty(), + in_repo: !frames.is_empty(), frames, } } @@ -1006,6 +1003,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/src/vtab.rs b/packages/rust/src/vtab.rs index 8b63dbdfe..7cdd34429 100644 --- a/packages/rust/src/vtab.rs +++ b/packages/rust/src/vtab.rs @@ -19,11 +19,11 @@ pub use crate::vtab_scaffold::StatementScope; /// SQL module name a path-table is created with: /// `CREATE VIRTUAL TABLE t USING dirsql_path('', '', '', -/// '', ''[, ''...])`. +/// ''[, ''...])`. pub const MODULE_NAME: &str = "dirsql_path"; /// Number of module arguments that are not ignore patterns. -const FIXED_ARGS: usize = 5; +const FIXED_ARGS: usize = 4; /// The seven stat columns, in declaration order. pub const STAT_COLUMNS: [&str; 7] = ["path", "basename", "dir", "ext", "size", "mtime", "ctime"]; @@ -144,7 +144,6 @@ struct ScanSpec { ignore: TableMatcher, /// Whether the scan respects `.gitignore` files (off under `--no-ignore`). gitignore: bool, - index_root: PathBuf, } /// One matched file, as compact as the seven stat columns allow: the three @@ -300,24 +299,16 @@ fn cell(row: &FileRow, i: c_int) -> Option> { /// Parse a path-table's own `CREATE VIRTUAL TABLE` arguments into its scan /// spec. `args[0..3]` are the module, database and table names; the module's -/// own arguments follow — root, glob, path prefix, the gitignore switch, the -/// index root, then any ignore patterns. +/// own arguments follow — root, glob, path prefix, the gitignore switch, then +/// any ignore patterns. fn parse_module_args(args: &[&[u8]]) -> Result { let user_args = vtab_scaffold::user_args(args); - let [ - root, - pattern, - path_prefix, - gitignore, - index_root, - ignore @ .., - ] = user_args.as_slice() - else { + let [root, pattern, path_prefix, gitignore, ignore @ ..] = user_args.as_slice() else { return Err(vtab_scaffold::arity_error( MODULE_NAME, FIXED_ARGS, - "root, glob, path prefix, gitignore switch, index root", + "root, glob, path prefix, gitignore switch", user_args.len(), )); }; @@ -328,7 +319,6 @@ fn parse_module_args(args: &[&[u8]]) -> Result { path_prefix: PathBuf::from(path_prefix), ignore: vtab_scaffold::compile_ignore(ignore)?, gitignore: vtab_scaffold::parse_gitignore(gitignore)?, - index_root: PathBuf::from(index_root), }) } @@ -366,13 +356,7 @@ impl TableSource for ScanSpec { fn rows(&self) -> Arc> { // The scan runs per statement rather than at CREATE, which is what // makes reads live: each statement sees the filesystem as it is now. - let rel_paths = scan_glob( - &self.root, - &self.index_root, - &self.glob, - &self.ignore, - self.gitignore, - ); + let rel_paths = scan_glob(&self.root, &self.glob, &self.ignore, self.gitignore); let stat = |rel: &Path| { fs::metadata(self.root.join(rel)).map_or_else( |_| StatFacts::default(), @@ -498,13 +482,7 @@ mod tests { #[test] fn parse_module_args_extracts_root_and_glob() { - let args = args_with(&[ - b"'/tmp/notes'", - b"'**/*.md'", - b"''", - b"'gitignore'", - b"'/idx'", - ]); + let args = args_with(&[b"'/tmp/notes'", b"'**/*.md'", b"''", b"'gitignore'"]); let spec = parse_module_args(&args).unwrap(); assert_eq!(spec.root, PathBuf::from("/tmp/notes")); @@ -514,7 +492,7 @@ mod tests { #[test] fn parse_module_args_accepts_unquoted_arguments() { - let args = args_with(&[b"/tmp/notes", b"**/*", b"", b"gitignore", b"/idx"]); + let args = args_with(&[b"/tmp/notes", b"**/*", b"", b"gitignore"]); assert_eq!( parse_module_args(&args).unwrap().root, PathBuf::from("/tmp/notes") @@ -523,13 +501,7 @@ mod tests { #[test] fn parse_module_args_extracts_the_path_prefix() { - let args = args_with(&[ - b"'/var/log'", - b"'*.log'", - b"'/var/log'", - b"'gitignore'", - b"'/idx'", - ]); + let args = args_with(&[b"'/var/log'", b"'*.log'", b"'/var/log'", b"'gitignore'"]); assert_eq!( parse_module_args(&args).unwrap().path_prefix, PathBuf::from("/var/log") @@ -538,16 +510,16 @@ mod tests { #[test] fn parse_module_args_reads_the_gitignore_switch() { - let on = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'", b"'/idx'"]); + let on = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'"]); assert!(parse_module_args(&on).unwrap().gitignore); - let off = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'no-gitignore'", b"'/idx'"]); + let off = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'no-gitignore'"]); assert!(!parse_module_args(&off).unwrap().gitignore); } #[test] fn parse_module_args_rejects_an_unknown_gitignore_switch() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'sometimes'", b"'/idx'"]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'sometimes'"]); let err = parse_module_args(&args) .err() .expect("an unknown switch must be rejected"); @@ -561,7 +533,6 @@ mod tests { b"'**/*'", b"''", b"'gitignore'", - b"'/idx'", b"'node_modules/**'", ]); let spec = parse_module_args(&args).unwrap(); @@ -572,39 +543,32 @@ mod tests { #[test] fn parse_module_args_accepts_no_ignore_patterns() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'", b"'/idx'"]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'"]); let spec = parse_module_args(&args).unwrap(); assert!(!spec.ignore.is_ignored(Path::new("node_modules/a.js"))); } #[test] fn parse_module_args_rejects_too_few_arguments() { - let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'"]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''"]); let err = parse_module_args(&args) .err() .expect("arity must be enforced"); assert!( - err.to_string().contains("at least 5 arguments"), + err.to_string().contains("at least 4 arguments"), "error should name the arity, got: {err}" ); } #[test] fn parse_module_args_rejects_an_invalid_glob() { - let args = args_with(&[b"'/tmp'", b"'['", b"''", b"'gitignore'", b"'/idx'"]); + let args = args_with(&[b"'/tmp'", b"'['", b"''", b"'gitignore'"]); assert!(parse_module_args(&args).is_err()); } #[test] fn parse_module_args_rejects_an_invalid_ignore_pattern() { - let args = args_with(&[ - b"'/tmp'", - b"'**/*'", - b"''", - b"'gitignore'", - b"'/idx'", - b"'['", - ]); + let args = args_with(&[b"'/tmp'", b"'**/*'", b"''", b"'gitignore'", b"'['"]); assert!(parse_module_args(&args).is_err()); } @@ -686,13 +650,7 @@ mod tests { #[test] fn a_scan_spec_keys_a_row_by_the_text_of_the_lookup_column() { - let args = args_with(&[ - b"'/tmp/notes'", - b"'**/*.md'", - b"''", - b"'gitignore'", - b"'/idx'", - ]); + let args = args_with(&[b"'/tmp/notes'", b"'**/*.md'", b"''", b"'gitignore'"]); let spec = parse_module_args(&args).unwrap(); let mut row = row_for("", "docs/a.md"); row.facts = facts(3); diff --git a/packages/rust/tests/path_table_content_prefetch.rs b/packages/rust/tests/path_table_content_prefetch.rs index fb15cbf3c..36477ba9c 100644 --- a/packages/rust/tests/path_table_content_prefetch.rs +++ b/packages/rust/tests/path_table_content_prefetch.rs @@ -40,7 +40,7 @@ fn open(root: &Path) -> Connection { load_module(&conn, StatementScope::new()).unwrap(); for (name, glob) in [("a", "*/abstract.md"), ("t", "*/title.md")] { conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE {name} USING dirsql_path('{0}', '{glob}', '', 'gitignore', '{0}')", + "CREATE VIRTUAL TABLE {name} USING dirsql_path('{}', '{glob}', '', 'gitignore')", root.display() )) .unwrap(); diff --git a/packages/rust/tests/vtab.rs b/packages/rust/tests/vtab.rs index 4cdcf07f5..72ad2c87d 100644 --- a/packages/rust/tests/vtab.rs +++ b/packages/rust/tests/vtab.rs @@ -35,7 +35,7 @@ fn open_scoped(dir: &TempDir, glob: &str) -> (Connection, Arc) { let scope = StatementScope::new(); load_module(&conn, Arc::clone(&scope)).unwrap(); conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE t USING dirsql_path('{0}', '{1}', '', 'gitignore', '{0}')", + "CREATE VIRTUAL TABLE t USING dirsql_path('{}', '{}', '', 'gitignore')", dir.path().display(), glob )) @@ -53,7 +53,7 @@ fn open_over_with(dir: &TempDir, glob: &str, prefix: &str, ignore: &[&str]) -> C let conn = Connection::open_in_memory().unwrap(); load_module(&conn, StatementScope::new()).unwrap(); let mut args = format!( - "'{0}', '{1}', '{2}', 'gitignore', '{0}'", + "'{}', '{}', '{}', 'gitignore'", dir.path().display(), glob, prefix @@ -366,7 +366,7 @@ fn a_table_rooted_in_an_ignored_directory_still_scans_it() { let conn = Connection::open_in_memory().unwrap(); load_module(&conn, StatementScope::new()).unwrap(); conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE t USING dirsql_path('{0}', '**/*', 'node_modules', 'gitignore', '{0}', '**/node_modules/**')", + "CREATE VIRTUAL TABLE t USING dirsql_path('{}', '**/*', 'node_modules', 'gitignore', '**/node_modules/**')", dir.path().join("node_modules").display() )) .unwrap(); @@ -490,7 +490,7 @@ fn open_pair_scoped( load_module(&conn, Arc::clone(&scope)).unwrap(); for (name, glob) in [("a", glob_a), ("b", glob_b)] { conn.execute_batch(&format!( - "CREATE VIRTUAL TABLE {name} USING dirsql_path('{0}', '{glob}', '', 'gitignore', '{0}')", + "CREATE VIRTUAL TABLE {name} USING dirsql_path('{}', '{glob}', '', 'gitignore')", dir.path().display(), )) .unwrap(); 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/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/testing-conventions.toml b/testing-conventions.toml index d9e3120cd..4a9739932 100644 --- a/testing-conventions.toml +++ b/testing-conventions.toml @@ -108,20 +108,20 @@ features = ["cli"] [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [72, 132, 324, 357, 421, 442] +lines = [72, 131, 325, 362, 433, 454] 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 = [370, 371, 374, 422, 425] +lines = [379, 380, 383, 434, 437] 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 = [456] -reason = "gitignores_above probes a real tree for .git and reads the .gitignore files it finds; the choice of directories is unit-tested in gitignore_top and dirs_above, and tests/path_table_gitignore.rs (a_gitignore_above_the_named_base_applies_beneath_it, a_repo_root_gitignore_above_the_index_root_applies) asserts the result" +lines = [363, 367, 396, 468, 481] +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" @@ -132,13 +132,13 @@ reason = "the walk's running file count is observable only through the CLI's pro [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [364] +lines = [373] 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]] path = "src/vtab.rs" rules = ["mutation"] -lines = [369, 376, 387] +lines = [360, 371] reason = "rows walks and stats a real tree and column hands a cell to SQLite; the cell choice is unit-tested in `cell`, and tests/vtab.rs stat_columns_carry_real_values and content_is_excluded_from_star_but_selectable_by_name assert both through a real connection" [[rust.exempt]] @@ -150,7 +150,7 @@ reason = "run_default and run_query build a real index and drive stdout, stderr [[rust.exempt]] path = "src/vtab.rs" rules = ["mutation"] -lines = [81, 338, 398] +lines = [81, 328, 382] reason = "read_text and ScanSpec::read open a real file and prefetch reads a real tree ahead of the cursor; the buffer logic is unit-tested in `read_all` and `presize`, tests/vtab.rs content_is_excluded_from_star_but_selectable_by_name, content_is_null_for_non_utf8_files and unreadable_file_yields_null_content_without_erroring_the_row assert the cell, and tests/path_table_content_prefetch.rs content_is_read_for_every_row_before_the_first_is_delivered and a_join_has_the_content_it_names_read_ahead assert the read-ahead" [[rust.exempt]] From a433cc9c0cf5c32e3d731625dd63ecd040d9f235 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:47:46 -0400 Subject: [PATCH 08/11] Say the context guide's .gitignore rule holds only inside a git repo --- docs/context/troubleshooting.md | 2 +- docs/context/usage.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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'`. From 53cf207014f1279b8b40f19323c4be87c768222e Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:49:47 -0400 Subject: [PATCH 09/11] e2e attestation for claude/1272-ancestor-gitignore --- .../e2e-attestations/claude-1272-ancestor-gitignore.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json b/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json index a341e6bb5..175a9a331 100644 --- a/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json +++ b/packages/python/e2e-attestations/claude-1272-ancestor-gitignore.json @@ -1,7 +1,7 @@ { "command": "just test-e2e", - "ran_at": 1791216088, + "ran_at": 1791218987, "exit_code": 0, - "commit": "8479371fd57f71dc06671f8041b4280deb7716fd", + "commit": "a433cc9c0cf5c32e3d731625dd63ecd040d9f235", "branch": "claude/1272-ancestor-gitignore" } From 0e2e30c53826164b3154f4edcd3924dab575c905 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:50:43 -0400 Subject: [PATCH 10/11] e2e attestation for claude/1272-ancestor-gitignore --- .../ts/e2e-attestations/claude-1272-ancestor-gitignore.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json b/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json index 28dcb2659..548439d0b 100644 --- a/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json +++ b/packages/ts/e2e-attestations/claude-1272-ancestor-gitignore.json @@ -1,7 +1,7 @@ { "command": "pnpm test:e2e", - "ran_at": 1791216160, + "ran_at": 1791219043, "exit_code": 0, - "commit": "e72a8787aab05b139ca590fca9cc1d42137f2842", + "commit": "53cf207014f1279b8b40f19323c4be87c768222e", "branch": "claude/1272-ancestor-gitignore" } From eb2c24903421688590acb353fc4cf122ad58d9d3 Mon Sep 17 00:00:00 2001 From: thekevinbot <248217931+thekevinbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:13:32 -0400 Subject: [PATCH 11/11] Reword the walk's in-repo doc sentence --- packages/rust/src/scanner.rs | 7 ++++--- testing-conventions.toml | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 4d1dfa832..9d25090c0 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -310,9 +310,10 @@ pub(crate) fn parse_gitignore_arg(arg: &str) -> Result { /// does not spell is skipped; `None` admits them all. With `gitignore` set, /// 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. Below a directory holding `.git` a repo does. Symlinks are followed only -/// as `glob` allows, and not at all without one; a broken link or an -/// unreadable directory contributes nothing. +/// 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, diff --git a/testing-conventions.toml b/testing-conventions.toml index 4a9739932..24df8735e 100644 --- a/testing-conventions.toml +++ b/testing-conventions.toml @@ -108,19 +108,19 @@ features = ["cli"] [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [72, 131, 325, 362, 433, 454] +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 = [379, 380, 383, 434, 437] +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 = [363, 367, 396, 468, 481] +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]] @@ -132,7 +132,7 @@ reason = "the walk's running file count is observable only through the CLI's pro [[rust.exempt]] path = "src/scanner.rs" rules = ["mutation"] -lines = [373] +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]]