From 6a10ae222c696afe0b5094d657c8053df679c715 Mon Sep 17 00:00:00 2001 From: Peter Freiberg <109841928+sabbaticas@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:06:09 +1100 Subject: [PATCH] Replace the deprecated pathspec gitwildmatch factory load_gitignore_patterns() built its matcher with a factory pathspec deprecated in 1.0.0, raising a DeprecationWarning on every call and set to break when the name is removed. It now passes GitIgnoreSpecPattern directly. The deprecated class was a thin subclass of it, so which files are ignored is unchanged. The registered 'gitignore' name, which the deprecation message suggests, is deliberately not used. It is GitIgnoreBasicPattern, a different implementation: "foo/*" stops ignoring foo/bar/baz.txt and a bare "/" ignores every file. New tests pin both. GitIgnoreSpecPattern does not exist before pathspec 1.0.0, so the floor moves from >=0.12.1 to >=1.0.0. Closes #25 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + CLAUDE.md | 2 +- ...26-10-pathspec-gitwildmatch-deprecation.md | 93 +++++++++++++++++++ pyproject.toml | 2 +- src/panopticas/core.py | 7 +- tests/test_panopticas.py | 60 ++++++++++++ 6 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 changes/2026-10-pathspec-gitwildmatch-deprecation.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a56549..a0a8b43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 4e7a85f..a1a59b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/changes/2026-10-pathspec-gitwildmatch-deprecation.md b/changes/2026-10-pathspec-gitwildmatch-deprecation.md new file mode 100644 index 0000000..337d9ad --- /dev/null +++ b/changes/2026-10-pathspec-gitwildmatch-deprecation.md @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 9869c10..9fcdd94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/panopticas/core.py b/src/panopticas/core.py index d183101..5d2b3c7 100644 --- a/src/panopticas/core.py +++ b/src/panopticas/core.py @@ -4,6 +4,7 @@ import os import re import pathspec +from pathspec.patterns.gitignore.spec import GitIgnoreSpecPattern from .constants import ( AI_RULES, AI_TAG, @@ -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): diff --git a/tests/test_panopticas.py b/tests/test_panopticas.py index 4af82e1..cc324a2 100644 --- a/tests/test_panopticas.py +++ b/tests/test_panopticas.py @@ -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)