diff --git a/packages/python/e2e-attestations/claude-1286-nested-repo.json b/packages/python/e2e-attestations/claude-1286-nested-repo.json new file mode 100644 index 000000000..a71e61185 --- /dev/null +++ b/packages/python/e2e-attestations/claude-1286-nested-repo.json @@ -0,0 +1,7 @@ +{ + "command": "just test-e2e", + "ran_at": 1791238629, + "exit_code": 0, + "commit": "68858295a0d429345af616a28e2cee3946f186cc", + "branch": "claude/1286-nested-repo" +} diff --git a/packages/rust/changelog.d/2026-10-05-nested-repo-gitignore.md b/packages/rust/changelog.d/2026-10-05-nested-repo-gitignore.md new file mode 100644 index 000000000..35abca6eb --- /dev/null +++ b/packages/rust/changelog.d/2026-10-05-nested-repo-gitignore.md @@ -0,0 +1,3 @@ +**Fixed** Path-table scans stop applying an outer repository's `.gitignore` +rules inside a nested repository. A nested repo now starts a fresh ignore +boundary, matching Git; `'./**'` and narrower globs return the same files. diff --git a/packages/rust/migrations.d/2026-10-05-nested-repo-gitignore.md b/packages/rust/migrations.d/2026-10-05-nested-repo-gitignore.md new file mode 100644 index 000000000..ef79c1fb7 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-05-nested-repo-gitignore.md @@ -0,0 +1,34 @@ +### Core: nested repositories reset `.gitignore` inheritance + +#### Summary + +Path-table scans no longer carry a parent repository's `.gitignore` rules +into a nested repository. This matches Git's ignore boundary behavior and can +make previously hidden files visible to path-table queries. No API changes. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +- When `outer` is a repository with `*.log` and `outer/repo` is also a + repository, `'./**'` now lists `repo/z.log`; the outer rule no longer + applies inside `repo`. + +#### Verification + +```bash +cd "$(mktemp -d)" +git init -q +printf '*.log\n' > .gitignore +mkdir repo +git -C repo init -q +touch repo/z.log +dirsql query "SELECT path FROM './**' ORDER BY path" +# expected: [{"path":"repo/z.log"}] +``` diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 88b2ca45b..4138a8cc2 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -364,10 +364,10 @@ 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 entered_repo = self.gitignore && holds_git(dir); + let was_in_repo = self.in_repo; + let inherited_frames = entered_repo.then(|| std::mem::take(&mut self.frames)); + self.in_repo |= entered_repo; let mut pushed = false; if self.in_repo && let Some(matcher) = load_gitignore(dir) @@ -398,9 +398,10 @@ impl Walk<'_> { if pushed { self.frames.pop(); } - if entered_repo { - self.in_repo = false; + if let Some(frames) = inherited_frames { + self.frames = frames; } + self.in_repo = was_in_repo; } /// Whether the walk takes an entry at `depth`: the skip rules and the diff --git a/packages/rust/tests/cli_e2e.rs b/packages/rust/tests/cli_e2e.rs index eb083d43f..78b04c155 100644 --- a/packages/rust/tests/cli_e2e.rs +++ b/packages/rust/tests/cli_e2e.rs @@ -174,6 +174,34 @@ fn help_flag_lists_the_subcommands_and_default_query_flags() { .stdout(predicates::str::contains("init")); } +#[test] +fn query_glob_does_not_apply_outer_gitignore_inside_nested_repo() { + let outer = TempDir::new().unwrap(); + fs::create_dir(outer.path().join(".git")).unwrap(); + fs::write(outer.path().join(".gitignore"), "*.log\n").unwrap(); + let nested = outer.path().join("repo"); + fs::create_dir(&nested).unwrap(); + fs::create_dir(nested.join(".git")).unwrap(); + fs::write(nested.join("z.log"), "nested").unwrap(); + + let output = std::process::Command::cargo_bin("dirsql") + .unwrap() + .args(["query", "SELECT path FROM './**'", "--format", "json"]) + .current_dir(outer.path()) + .output() + .unwrap(); + + assert!( + output.status.success(), + "query failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_slice::>(&output.stdout).unwrap(), + vec![json!({"path": "repo/z.log"})] + ); +} + #[test] fn server_help_lists_the_bind_flags() { // #662: `--host`/`--port` are server-local now, documented under diff --git a/packages/rust/tests/scanner.rs b/packages/rust/tests/scanner.rs index 9e547b046..891e2e04f 100644 --- a/packages/rust/tests/scanner.rs +++ b/packages/rust/tests/scanner.rs @@ -5,7 +5,7 @@ use std::fs; use dirsql::matcher::TableMatcher; -use dirsql::scanner::{scan_directory, scan_subtree}; +use dirsql::scanner::{compile_glob, scan_directory, scan_glob, scan_subtree}; use tempfile::TempDir; #[test] @@ -177,3 +177,44 @@ fn scan_subtree_prunes_ignored_directories() { assert_eq!(results, vec![(moved.join("keep.csv"), "t".to_string())]); } + +#[test] +fn scan_glob_stops_outer_gitignore_rules_at_nested_repo() { + let outer = TempDir::new().unwrap(); + fs::create_dir(outer.path().join(".git")).unwrap(); + fs::write(outer.path().join(".gitignore"), "repo/*.log\n").unwrap(); + let nested = outer.path().join("repo"); + fs::create_dir(&nested).unwrap(); + fs::create_dir(nested.join(".git")).unwrap(); + fs::write(nested.join("z.log"), "nested").unwrap(); + let sibling = outer.path().join("zz-sibling"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join(".gitignore"), "*.log\n").unwrap(); + fs::write(sibling.join("z.log"), "ignored").unwrap(); + + let ignore = TableMatcher::new(&[], &[]).unwrap(); + let glob = compile_glob("**").unwrap(); + let results = scan_glob(outer.path(), &glob, &ignore, true); + + assert_eq!(results, vec![std::path::PathBuf::from("repo/z.log")]); +} + +#[test] +fn scan_glob_applies_gitignore_only_inside_a_repo() { + let outer = TempDir::new().unwrap(); + let nested = outer.path().join("repo"); + fs::create_dir(&nested).unwrap(); + fs::create_dir(nested.join(".git")).unwrap(); + fs::write(nested.join(".gitignore"), "*.log\n").unwrap(); + fs::write(nested.join("z.log"), "ignored").unwrap(); + let sibling = outer.path().join("zz-sibling"); + fs::create_dir(&sibling).unwrap(); + fs::write(sibling.join(".gitignore"), "*.log\n").unwrap(); + fs::write(sibling.join("z.log"), "visible").unwrap(); + + let ignore = TableMatcher::new(&[], &[]).unwrap(); + let glob = compile_glob("**").unwrap(); + let results = scan_glob(outer.path(), &glob, &ignore, true); + + assert_eq!(results, vec![std::path::PathBuf::from("zz-sibling/z.log")]); +} diff --git a/packages/ts/e2e-attestations/claude-1286-nested-repo.json b/packages/ts/e2e-attestations/claude-1286-nested-repo.json new file mode 100644 index 000000000..90c594e16 --- /dev/null +++ b/packages/ts/e2e-attestations/claude-1286-nested-repo.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1791239115, + "exit_code": 0, + "commit": "0257c015584ee781df5b4cfd11b2dd3f35455f54", + "branch": "claude/1286-nested-repo" +} diff --git a/packages/ts/testing-conventions.toml b/packages/ts/testing-conventions.toml index 3ed868a12..56706c03b 100644 --- a/packages/ts/testing-conventions.toml +++ b/packages/ts/testing-conventions.toml @@ -4,11 +4,11 @@ # start staling this package. # # The napi crate is in scope because the npm launcher runs the CLI in-process -# through it (#721), so every e2e case crosses it -- the whole crate dir plus its -# manifest at the package root, since Cargo.toml and build.rs decide what lands -# in the addon. packages/python/src is not: it cannot reach this suite. +# through it (#721), so every e2e case crosses it. The verifier accepts only +# directory scopes, so the package-root Cargo.toml cannot be listed here. +# packages/python/src is not: it cannot reach this suite. [e2e] -extra_scope = ["packages/rust/src", "packages/ts/napi", "packages/ts/Cargo.toml"] +extra_scope = ["packages/rust/src", "packages/ts/napi"] # The live TypeScript floors; the root file carries no `[typescript]` table. [typescript.coverage] diff --git a/testing-conventions.toml b/testing-conventions.toml index 5c3800036..9fadcb54b 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, 330, 367, 445, 466] +lines = [72, 131, 330, 367, 446, 467] 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 = [384, 385, 388, 446, 449] +lines = [384, 385, 388, 447, 450] 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 = [368, 372, 401, 480, 493] +lines = [370, 372, 404, 481, 494] 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]]