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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,12 @@ The format of this changelog is based on [Keep a Changelog](https://keepachangel
### Changed
- The tag vocabulary grew from 119 to 123 tags, the filetype vocabulary from 86 to 89 and the language vocabulary from 32 to 34.
- The Click floor moved from `>=8.3.1` to `>=8.3.3`, so an install can no longer resolve to a vulnerable Click release.
- The pathspec floor moved from `>=0.12.1` to `>=1.0.0`. The `.gitignore` matcher now names a class that only exists in pathspec 1.x. Installs already resolved to 1.x, so this only affects an environment that pins pathspec below 1.0.
- The package author email is now `peter@kospex.io`.

### Fixed
- `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

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Jekyll copies the file verbatim instead of rendering it — and a nav entry in

- **Click** (>=8.3.3) — CLI framework
- **rich** (>=14.0.0) — Table formatting for CLI output
- **pathspec** (>=0.12.1) — Git-style path matching for `.gitignore` support
- **pathspec** (>=1.0.0) — Git-style path matching for `.gitignore` support

## Code Style

Expand Down
93 changes: 93 additions & 0 deletions changes/2026-10-pathspec-gitwildmatch-deprecation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# Replace the Deprecated pathspec `gitwildmatch` Factory

Closes [#25](https://github.com/kospex/panopticas/issues/25).

## Summary

`load_gitignore_patterns()` built its `.gitignore` matcher with a pattern
factory that pathspec deprecated in 1.0.0:

pathspec.PathSpec.from_lines('gitwildmatch', patterns)

Every call raised a `DeprecationWarning` — 168 in the test suite — and the
call would fail outright once pathspec removes the name.

## Changes

from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern
...
pathspec.PathSpec.from_lines(GitIgnoreSpecPattern, patterns)

The pathspec floor in `pyproject.toml` moves from `>=0.12.1` to `>=1.0.0`.

## Why not the obvious fix

The deprecation message says to use `'gitignore'` instead, and the issue
proposed exactly that swap. It is the wrong one: it changes which files are
ignored.

pathspec offers three replacements. They were compared against the current
matcher on this repository's files and on a set of awkward patterns:

| Replacement | Warning gone | Same results as before |
|---|---|---|
| `'gitignore'` (registered name → `GitIgnoreBasicPattern`) | Yes | **No** — differs on two patterns |
| `GitIgnoreSpecPattern` passed directly | Yes | Yes |
| `pathspec.GitIgnoreSpec.from_lines(...)` | Yes | Yes, in every case tested |

The two differences with `'gitignore'`:

| Pattern | Before, and Git | With `'gitignore'` |
|---|---|---|
| `foo/*` | Ignores everything under `foo/`, including `foo/bar/baz.txt` | Ignores only direct children; `foo/bar/baz.txt` is scanned |
| a bare `/` | Ignores nothing | Ignores every file |

The cause is in pathspec's source. In 1.x the deprecated `GitWildMatchPattern`
is declared as `class GitWildMatchPattern(GitIgnoreSpecPattern)` and adds only
the warning. `GitIgnoreSpecPattern` replicates Git's actual behaviour;
`GitIgnoreBasicPattern` implements the gitignore documentation literally, and
pathspec's own docstring says it "differs from how Git actually behaves".

`foo/*` is a common shape in real `.gitignore` files, so the obvious fix would
have made panopticas scan and tag files a repository meant to exclude, with no
test failing.

Passing `GitIgnoreSpecPattern` directly is therefore identical to the old
behaviour by construction, not merely by testing. `GitIgnoreSpec.from_lines`
was the alternative; it needs no floor change but is a different class, so its
equivalence rests only on the cases tried.

## The floor change

`GitIgnoreSpecPattern` does not exist before pathspec 1.0.0 — importing it
under 0.12.1 raises `ModuleNotFoundError` — so the floor had to move.

- A fresh install already resolved to 1.x; `requirements.txt` pins `1.1.1`.
- kospex pins `pathspec==1.1.1` in its `requirements.txt`, so it is unaffected.
- Only an environment that pins pathspec below 1.0 would now fail to resolve.

The old `>=0.12.1` floor is how the deprecation arrived unannounced: the
resolver crossed the 0.x → 1.x boundary on its own.

## Verification

- The full suite passes with `-W error::DeprecationWarning`, on pathspec 1.1.1
and on 1.0.0, the new floor. The warning count went from 168 to 0.
- `identify_files()` over this repository returns the same 113 files and
filetypes before and after.
- Across 167 cloned repositories (237,318 files), the matcher's verdict was
compared with `git check-ignore`. There was no case where a root
`.gitignore` pattern was interpreted differently. The structural differences
that comparison did find — tracked-but-ignored files, nested `.gitignore`
files — are unrelated to this change and are recorded in #46.
- New tests in `tests/test_panopticas.py` (`TestLoadGitignorePatterns`) pin
the two patterns above, negation, directory patterns and the four literals
panopticas appends (`.git`, `.jekyll-cache`, `.ruff_cache`, `.DS_Store`).
With `'gitignore'` swapped in, the `foo/*` and bare `/` tests fail.

## Not covered

Two malformed patterns raise from pathspec with every matcher, old and new: an
invalid range such as `a[b-a]c`, and a lone backslash. A `.gitignore`
containing one makes the scan fail. That is existing behaviour and is not
changed here.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description = "The panopticas file type and CLI tool."
authors = [{ name = "Peter Freiberg", email = "peter@kospex.io" }]
dependencies = [
"Click>=8.3.3",
"pathspec>=0.12.1",
"pathspec>=1.0.0",
"rich>=14.0.0",
]
requires-python = ">=3.12"
Expand Down
7 changes: 6 additions & 1 deletion src/panopticas/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import os
import re
import pathspec
from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern
from .constants import (
AI_RULES,
AI_TAG,
Expand Down Expand Up @@ -252,7 +253,11 @@ def load_gitignore_patterns(directory):
patterns.append('.ruff_cache')
patterns.append('.DS_Store')

return pathspec.PathSpec.from_lines('gitwildmatch', 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)
return None

def identify_files(directory):
Expand Down
60 changes: 60 additions & 0 deletions tests/test_panopticas.py
Original file line number Diff line number Diff line change
Expand Up @@ -964,3 +964,63 @@ def test_implicit_tags_exported(self):
assert AI_TAG == "AI"
assert LICENSE_TAG == "license"
assert set(IMPLICIT_TAGS) == {"AI", "license"}


class TestLoadGitignorePatterns:
"""Tests for load_gitignore_patterns() — the matcher built from .gitignore.

The matcher decides which files are scanned at all, so a change in its
semantics alters detection results without failing anything else. These
pin the behaviour panopticas depends on.
"""

@staticmethod
def _spec(tmp_path, gitignore):
from panopticas.core import load_gitignore_patterns
(tmp_path / ".gitignore").write_text(gitignore)
return load_gitignore_patterns(str(tmp_path))

def test_no_gitignore_returns_none(self, tmp_path):
from panopticas.core import load_gitignore_patterns
assert load_gitignore_patterns(str(tmp_path)) is None

def test_does_not_use_a_deprecated_pathspec_factory(self, tmp_path):
import warnings
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
self._spec(tmp_path, "*.log\n")

def test_plain_file_and_directory_patterns(self, tmp_path):
spec = self._spec(tmp_path, "secret.txt\nbuild/\n*.log\n")
assert spec.match_file("secret.txt")
assert spec.match_file("build/out.o")
assert spec.match_file("sub/a.log")
assert not spec.match_file("app.py")

def test_star_pattern_ignores_nested_files(self, tmp_path):
# Git ignores foo/bar/ under "foo/*" and never descends into it. A
# matcher that reads the pattern literally stops at one level and
# would start scanning files the repository meant to exclude.
spec = self._spec(tmp_path, "foo/*\n")
assert spec.match_file("foo/x.txt")
assert spec.match_file("foo/bar/baz.txt")

def test_bare_slash_ignores_nothing(self, tmp_path):
# Git treats a lone "/" as matching nothing. A literal reading
# ignores every file in the repository.
spec = self._spec(tmp_path, "/\n")
assert not spec.match_file("app.py")
assert not spec.match_file("sub/app.py")

def test_negation_re_includes_a_file(self, tmp_path):
spec = self._spec(tmp_path, "*.log\n!important.log\n")
assert spec.match_file("a.log")
assert not spec.match_file("important.log")

@pytest.mark.parametrize("path", [
".git/config", ".jekyll-cache/x", ".ruff_cache/x", ".DS_Store",
"sub/.DS_Store",
])
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)
Loading