From 3460500fff04226552188bb06e64423c1564854c Mon Sep 17 00:00:00 2001 From: safishamsi Date: Sat, 15 Aug 2026 19:20:34 +0100 Subject: [PATCH] perf(detect): skip the git ls-files probe when no .gitignore contributes (#2759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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) --- CHANGELOG.md | 2 ++ graphify/detect.py | 14 ++++++++++++-- tests/test_detect.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e6954c..d1bb228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/graphify/detect.py b/graphify/detect.py index 606a0d3..f76a425 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -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] = {} diff --git a/tests/test_detect.py b/tests/test_detect.py index 9aff565..9729552 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -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"