From 9bf6ddf77525fbf9c1b1b0f8e82d73fa1073c68c Mon Sep 17 00:00:00 2001 From: Peter Freiberg <109841928+sabbaticas@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:13:02 +1100 Subject: [PATCH] Exclude .git when a directory has no .gitignore The built-in exclusions (.git, .jekyll-cache, .ruff_cache, .DS_Store) were appended to the patterns read from .gitignore, so a directory without one got no filter at all and the contents of .git were scanned. panopticas assess and urls listed Git's internal files, and panopticas ai could report a file inside .git as an AI artifact. The exclusions now apply whether or not a .gitignore exists, in all four functions that walk a directory. A directory with no .gitignore behaves exactly like one with an empty .gitignore. all_files still turns all filtering off, and load_gitignore_patterns() still returns None when there is no .gitignore. Closes #1 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + ...26-10-exclude-git-dir-without-gitignore.md | 89 +++++++++++++++++++ docs/api.md | 11 ++- src/panopticas/core.py | 46 ++++++---- tests/test_panopticas.py | 65 ++++++++++++++ 5 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 changes/2026-10-exclude-git-dir-without-gitignore.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a8b43..523432d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The format of this changelog is based on [Keep a Changelog](https://keepachangel - The package author email is now `peter@kospex.io`. ### Fixed + - A repository with no `.gitignore` had its `.git` directory scanned. The built-in exclusions (`.git`, `.jekyll-cache`, `.ruff_cache`, `.DS_Store`) were applied only as extra lines on top of a `.gitignore`, so a directory without one got no filter at all: `panopticas assess` listed Git's internal files, `panopticas ai` could report a file inside `.git` as an AI artifact, and `identify_files()` returned them to callers. The exclusions now apply whether or not a `.gitignore` exists. `-all-files` still turns all filtering off, and `load_gitignore_patterns()` still returns `None` when there is no `.gitignore`. kospex already discarded `.git` paths itself, so its file inventory is unchanged. - `load_gitignore_patterns()` built its matcher with pathspec's deprecated `gitwildmatch` factory, raising a `DeprecationWarning` on every call — 168 of them in the test suite — and set to break when pathspec removes the name. It now passes `GitIgnoreSpecPattern` directly, the class the deprecated name was a thin subclass of, so which files are ignored is unchanged. The registered `gitignore` name was deliberately not used: it is a different implementation that matches different files. ## 0.0.20 - 2026-10-05 diff --git a/changes/2026-10-exclude-git-dir-without-gitignore.md b/changes/2026-10-exclude-git-dir-without-gitignore.md new file mode 100644 index 0000000..e99a956 --- /dev/null +++ b/changes/2026-10-exclude-git-dir-without-gitignore.md @@ -0,0 +1,89 @@ +# Exclude `.git` When There Is No `.gitignore` + +Closes [#1](https://github.com/kospex/panopticas/issues/1). + +## Summary + +panopticas has four built-in exclusions — `.git`, `.jekyll-cache`, +`.ruff_cache` and `.DS_Store` — but they were appended to the patterns read +from `.gitignore`. A directory with no `.gitignore` got no matcher at all, so +nothing was excluded and the contents of `.git` were scanned. + + $ git init demo && echo 'print(1)' > demo/app.py + $ panopticas assess demo # before: app.py plus every file under .git/ + +Affected: + +- `panopticas assess` and `panopticas urls` listed Git's internal files. +- `panopticas ai` could report a file inside `.git` as an AI artifact. +- `identify_files()` returned those files to any caller. kospex calls it + during sync but was already guarding against this — see below. + +## Decision + +Issue #1 asked for a decision between three options. Taken: + +- **Exclude `.git` by default — yes.** It is never repository content. +- **A switch to turn filtering off — already exists.** `-all-files` (and + `all_files=True`) disables all filtering and is unchanged, so it still shows + `.git`. +- **Apply all four built-in exclusions, not just `.git`.** A directory with no + `.gitignore` now behaves exactly like one with an empty `.gitignore`. Having + the two differ was the bug. + +## Changes + +In `core.py`: + +- `ALWAYS_IGNORED` holds the four built-in patterns. +- `_scan_spec(directory)` returns the `.gitignore` matcher, or a matcher of + `ALWAYS_IGNORED` alone when there is no `.gitignore`. +- `identify_files()`, `identify_files_with_metrics()`, `find_files()` and + `find_ai_files()` use `_scan_spec()`. + +`load_gitignore_patterns()` is public and documented as returning `None` when +there is no `.gitignore`. That contract is kept; the fallback lives in the +private helper instead. + +## Effect on a real corpus + +Measured across 167 cloned repositories before and after: + +| | Before | After | +|---|---|---| +| Repositories with no root `.gitignore` | 6 | 6 | +| Files under `.git/` scanned | 308 | 0 | + +No other difference in the files selected. + +## Effect on kospex + +None on its file inventory. `get_repo_files()` in `kospex_git.py` builds the +inventory by iterating the result of `identify_files()`, and it already skips +any `.git` path itself, with a comment noting that panopticas "excludes .git +on most repos but not reliably". This fix makes that guard redundant rather +than changing what kospex stores. + +Because kospex takes its file list from panopticas, anything panopticas +excludes is absent from the kospex inventory. That was already true of +`.jekyll-cache`, `.ruff_cache` and `.DS_Store` in every repository with a +`.gitignore`; it now also holds in repositories without one. A committed +`.DS_Store` in such a repository would previously have been inventoried and +no longer is. None of the six corpus repositories has one. + +## Not changed + +- `-all-files` still lists `.git`. Whether "all files" should ever include + Git's internals is a separate question. +- The walk still descends into `.git` and discards each file, rather than + pruning the directory. Correct, but slower than it needs to be on a large + repository. +- The other differences from Git's own file selection — tracked-but-ignored + files, nested `.gitignore` files — are recorded in #46. + +## Tests + +`TestScanWithoutGitignore` in `tests/test_panopticas.py` builds a repository +with a `.git` directory and no `.gitignore`, and covers all four walkers, the +unchanged `all_files` behaviour, and that the result matches a repository with +an empty `.gitignore`. diff --git a/docs/api.md b/docs/api.md index 28a6dfe..4aac6ce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -492,5 +492,12 @@ spec.match_file("build/output.o") # True ``` **Returns:** A `pathspec.PathSpec`, or `None` if the directory has no -`.gitignore`. Used internally by `find_files`, `identify_files` and -`find_ai_files`. +`.gitignore`. + +The spec always includes four built-in exclusions on top of the file's own +patterns: `.git`, `.jekyll-cache`, `.ruff_cache` and `.DS_Store`. + +`find_files`, `identify_files`, `identify_files_with_metrics` and +`find_ai_files` apply those four exclusions even when the directory has no +`.gitignore`, so a repository's `.git` directory is never scanned. Passing +`all_files=True` turns all filtering off, built-in exclusions included. diff --git a/src/panopticas/core.py b/src/panopticas/core.py index 5d2b3c7..0fde28b 100644 --- a/src/panopticas/core.py +++ b/src/panopticas/core.py @@ -236,9 +236,24 @@ def count_lines(file_path): # Handle any other exceptions gracefully return "N/A" +# Excluded from every scan, whether or not the directory has a .gitignore. +# .git is never repository content; the rest are tool caches and OS debris. +ALWAYS_IGNORED = ('.git', '.jekyll-cache', '.ruff_cache', '.DS_Store') + +def _ignore_spec(patterns): + # GitIgnoreSpecPattern follows Git's actual behaviour. Do not swap it + # for the registered 'gitignore' name: that is GitIgnoreBasicPattern, + # which reads patterns literally and matches different files ("foo/*" + # stops ignoring foo/bar/baz.txt, a bare "/" ignores everything). + return pathspec.PathSpec.from_lines( + GitIgnoreSpecPattern, [*patterns, *ALWAYS_IGNORED]) + def load_gitignore_patterns(directory): """ - Load gitignore patterns from a directory + Load gitignore patterns from a directory. + + Returns None when the directory has no .gitignore. The walkers use + _scan_spec() instead, which still applies ALWAYS_IGNORED in that case. """ gitignore_path = os.path.join(directory, '.gitignore') @@ -246,27 +261,24 @@ def load_gitignore_patterns(directory): with open(gitignore_path, 'r', encoding='utf-8') as file: patterns = file.read().splitlines() - # Add .git to the patterns - patterns.append('.git') - # These seem to be ignore by git - patterns.append('.jekyll-cache') - patterns.append('.ruff_cache') - patterns.append('.DS_Store') - - # GitIgnoreSpecPattern follows Git's actual behaviour. Do not swap it - # for the registered 'gitignore' name: that is GitIgnoreBasicPattern, - # which reads patterns literally and matches different files ("foo/*" - # stops ignoring foo/bar/baz.txt, a bare "/" ignores everything). - return pathspec.PathSpec.from_lines(GitIgnoreSpecPattern, patterns) + + return _ignore_spec(patterns) return None +def _scan_spec(directory): + """ + The ignore matcher for walking a directory: its .gitignore plus + ALWAYS_IGNORED, or ALWAYS_IGNORED alone when it has no .gitignore. + """ + return load_gitignore_patterns(directory) or _ignore_spec([]) + def identify_files(directory): """ Identify files in a directory. Returns a dict of the relative path filenames to their file_type """ - gitignore_spec = load_gitignore_patterns(directory) + gitignore_spec = _scan_spec(directory) file_paths = {} @@ -300,7 +312,7 @@ def identify_files_with_metrics(directory): dict: {relative_path: {'type': file_type, 'lines': line_count}} """ - gitignore_spec = load_gitignore_patterns(directory) + gitignore_spec = _scan_spec(directory) file_paths = {} @@ -330,7 +342,7 @@ def find_files(directory,all_files=None): Returns a list of the relative path filenames """ - gitignore_spec = load_gitignore_patterns(directory) + gitignore_spec = _scan_spec(directory) if all_files: gitignore_spec = None @@ -370,7 +382,7 @@ def find_ai_files(directory, all_files=False): surfaces tooling a team has configured locally but excluded from the repo, without flooding the output with arbitrary subdirectories. """ - gitignore_spec = None if all_files else load_gitignore_patterns(directory) + gitignore_spec = None if all_files else _scan_spec(directory) ai_files = {} diff --git a/tests/test_panopticas.py b/tests/test_panopticas.py index cc324a2..e704215 100644 --- a/tests/test_panopticas.py +++ b/tests/test_panopticas.py @@ -1024,3 +1024,68 @@ def test_negation_re_includes_a_file(self, tmp_path): def test_appended_literals_are_ignored(self, tmp_path, path): # Added by panopticas regardless of what the .gitignore says. assert self._spec(tmp_path, "").match_file(path) + + +@pytest.fixture +def repo_without_gitignore(tmp_path): + """A git repository that has no .gitignore. + + Layout: + app.py + CLAUDE.md + .DS_Store + .git/config + .git/HEAD + .git/hooks/pre-commit.sample + .git/objects/ab/cdef + .git/CLAUDE.md -> would be an AI artifact if scanned + """ + (tmp_path / "app.py").write_text("print('hi')\n") + (tmp_path / "CLAUDE.md").write_text("# rules\n") + (tmp_path / ".DS_Store").write_text("x") + git = tmp_path / ".git" + (git / "hooks").mkdir(parents=True) + (git / "objects" / "ab").mkdir(parents=True) + (git / "config").write_text("[core]\n") + (git / "HEAD").write_text("ref: refs/heads/main\n") + (git / "hooks" / "pre-commit.sample").write_text("#!/bin/sh\n") + (git / "objects" / "ab" / "cdef").write_text("blob") + (git / "CLAUDE.md").write_text("# not a real artifact\n") + return str(tmp_path) + + +class TestScanWithoutGitignore: + """A repository with no .gitignore must not have its .git directory scanned. + + The built-in exclusions used to be applied only as extra lines on top of a + .gitignore, so a repository without one got no filter at all. + """ + + def test_identify_files_skips_git_directory(self, repo_without_gitignore): + result = identify_files(repo_without_gitignore) + assert set(result) == {"app.py", "CLAUDE.md"} + + def test_identify_files_with_metrics_skips_git_directory( + self, repo_without_gitignore): + result = identify_files_with_metrics(repo_without_gitignore) + assert set(result) == {"app.py", "CLAUDE.md"} + + def test_find_files_skips_git_directory(self, repo_without_gitignore): + assert set(find_files(repo_without_gitignore)) == {"app.py", "CLAUDE.md"} + + def test_find_ai_files_skips_git_directory(self, repo_without_gitignore): + from panopticas import find_ai_files + assert set(find_ai_files(repo_without_gitignore)) == {"CLAUDE.md"} + + def test_all_files_still_shows_everything(self, repo_without_gitignore): + # all_files means "no filtering", and that is unchanged. + result = find_files(repo_without_gitignore, all_files=True) + assert os.path.join(".git", "config") in result + assert ".DS_Store" in result + + def test_same_exclusions_with_and_without_a_gitignore( + self, repo_without_gitignore): + without = set(find_files(repo_without_gitignore)) + with open(os.path.join(repo_without_gitignore, ".gitignore"), "w") as f: + f.write("") + assert set(find_files(repo_without_gitignore)) - {".gitignore"} == without