perf(detect): skip the git ls-files probe when no .gitignore contributes (#2759)

#2766 ran `git ls-files` on every scan of a git repo. Gate it on .gitignore
actually contributing patterns beyond the explicit (.graphifyignore/--exclude)
set — with no .gitignore in play nothing can be gitignore-dropped, so the
tracked-file exemption is moot and an ordinary corpus pays no subprocess cost.
Adds a spy test asserting the probe is skipped without a .gitignore and runs
once with one. Also adds the CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
safishamsi
2026-08-15 19:20:34 +01:00
co-authored by Claude Opus 4.8
parent 95ad0adcaa
commit 3460500fff
3 changed files with 43 additions and 2 deletions
+2
View File
@@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu
## 0.9.44 (unreleased)
- Fix: a git-tracked file that also matches a `.gitignore` pattern (a committed file later added to `.gitignore`, or a force-added one) is no longer dropped from the corpus, matching git's own behavior of never un-tracking such a file; `.graphifyignore`/`--exclude` stay authoritative and a non-git corpus is unaffected (#2759, thanks @NithishKumar04). The `git ls-files` probe is skipped entirely when no `.gitignore` is in play, so ordinary corpora pay nothing for it.
- Fix: doctest/Catch2 string-named test cases (`TEST_CASE("...")`, `SCENARIO`, `TEST_CASE_TEMPLATE`), which tree-sitter-cpp drops as ERROR nodes, are recovered as callable nodes contained by the file (#2594, thanks @ousamabenyounes); a punctuation-only test name gets a distinct line-positional id instead of collapsing onto the file-stem id.
- Fix: `graphify affected` resolves an absolute-path seed against the repo root derived from the graph's own location instead of the current working directory, so a blast-radius query with an absolute seed run from anywhere (an editor, a script) no longer silently returns nothing; a seed outside the root still misses cleanly (#2706, thanks @ousamabenyounes).
- Fix: a lazy CommonJS `require(...)` inside a function body (the idiom for breaking circular dependencies) now emits the same `imports_from`/`imports` dependency edges as a top-level require, attributed to the enclosing function, instead of being silently dropped; a dynamic `require(variable)` is still skipped (#2700, thanks @rajanpanth).
+12 -2
View File
@@ -1372,8 +1372,14 @@ def ignored_predicate(
root = root.resolve()
patterns = _load_graphifyignore(root, gitignore=gitignore)
explicit_patterns = _load_graphifyignore(root, gitignore=False)
# Only shell out to git when .gitignore actually contributes patterns beyond
# the explicit (.graphifyignore/--exclude) set: with no .gitignore in play,
# nothing is dropped by gitignore and the tracked-file exemption is moot, so a
# non-.gitignore corpus pays no `git ls-files` cost.
tracked_files, tracked_dirs = (
_git_tracked_path_keys(root) if gitignore else (set(), set())
_git_tracked_path_keys(root)
if gitignore and len(patterns) > len(explicit_patterns)
else (set(), set())
)
if extra_excludes:
for pat in extra_excludes:
@@ -1503,8 +1509,12 @@ def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace:
pruned_noise: list[str] = []
ignore_patterns = _load_graphifyignore(root, gitignore=gitignore)
explicit_ignore_patterns = _load_graphifyignore(root, gitignore=False)
# See ignored_predicate: skip the `git ls-files` subprocess when .gitignore
# contributes no patterns, so a non-.gitignore corpus pays nothing for it.
tracked_files, tracked_dirs = (
_git_tracked_path_keys(root) if gitignore else (set(), set())
_git_tracked_path_keys(root)
if gitignore and len(ignore_patterns) > len(explicit_ignore_patterns)
else (set(), set())
)
ignore_cache: dict[Path, bool] = {} # shared across all _is_ignored calls in this scan
explicit_ignore_cache: dict[Path, bool] = {}
+29
View File
@@ -539,6 +539,35 @@ def test_git_tracking_probe_failure_preserves_ignore_behavior(
assert str(ignored_file) in result["ignored"]
def test_git_lsfiles_skipped_when_no_gitignore_contributes(tmp_path, monkeypatch):
"""Optimization (#2759): a git repo with no .gitignore in play must not pay
the `git ls-files` subprocess — nothing can be gitignore-dropped, so the
tracked-exemption is moot. A .gitignore that DOES contribute still probes."""
_git(tmp_path, "init", "-q")
(tmp_path / "app.py").write_text("value = 1\n", encoding="utf-8")
_git(tmp_path, "add", "app.py")
real_run = detect_mod.subprocess.run
calls = {"ls_files": 0}
def _spy(args, *a, **k):
if isinstance(args, list) and "ls-files" in args:
calls["ls_files"] += 1
return real_run(args, *a, **k)
monkeypatch.setattr(detect_mod.subprocess, "run", _spy)
# No .gitignore anywhere -> gitignore contributes nothing -> no probe.
detect(tmp_path)
assert calls["ls_files"] == 0, "git ls-files ran despite no .gitignore in play"
# Add a .gitignore -> gitignore now contributes -> probe happens (once).
(tmp_path / ".gitignore").write_text("build/\n", encoding="utf-8")
calls["ls_files"] = 0
detect(tmp_path)
assert calls["ls_files"] >= 1, "git ls-files skipped even though .gitignore is present"
def test_gitignore_nested_below_root_prunes_whole_directory(tmp_path):
"""A nested .gitignore excluding a directory prevents descending into it."""
sub = tmp_path / "vendor" / "sub"