Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions changes/2026-10-exclude-git-dir-without-gitignore.md
Original file line number Diff line number Diff line change
@@ -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`.
11 changes: 9 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
46 changes: 29 additions & 17 deletions src/panopticas/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,37 +236,49 @@ 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')

if os.path.exists(gitignore_path):

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 = {}

Expand Down Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 = {}

Expand Down
65 changes: 65 additions & 0 deletions tests/test_panopticas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading