Skip to content

Fix GitIgnoreSpec re-including files under an excluded directory (#129) - #132

Open
youdie006 wants to merge 1 commit into
cpburnz:masterfrom
youdie006:fix/129-reinclude-under-excluded-dir
Open

Fix GitIgnoreSpec re-including files under an excluded directory (#129)#132
youdie006 wants to merge 1 commit into
cpburnz:masterfrom
youdie006:fix/129-reinclude-under-excluded-dir

Conversation

@youdie006

Copy link
Copy Markdown

Fixes #129. Thanks to @eeshsaxena for the clear report and the directory-vs-contents distinction -- it made this straightforward to reproduce.

Problem

GitIgnoreSpec resolves patterns with a flat last-match, so a file-level negation can re-include a file whose parent directory is excluded, which git forbids ("It is not possible to re-include a file if a parent directory of that file is excluded"):

GitIgnoreSpec.from_lines(["build", "!keep.log"]).match_file("build/keep.log")
# -> False, but real `git check-ignore` treats build/keep.log as ignored

Proposed approach (open to a different design)

I want to be upfront that this is a core match-resolution change, so please treat the approach as a proposal -- I am happy to restructure it however you prefer.

The fix wraps the gitignore backend (_AncestorDirBackend). When a file is not already ignored by its own resolution, it walks the file's ancestor directory prefixes and, for each, asks whether that directory is excluded -- resolved as a directory (ancestor + "/") using git's plain last-match order via the existing util.check_match_file. If any ancestor directory is excluded, the file is ignored regardless of a later file-level negation.

Resolving the ancestor as a directory (rather than reusing the leaf-file resolution) is what keeps directory-level re-inclusions working, so this correctly distinguishes:

.gitignore path result
build + !keep.log build/keep.log ignored
build/* + !build/keep.log build/keep.log re-included

build/* only excludes the contents (it does not match the build directory itself), so re-inclusion is preserved -- that case, plus the * + !libfoo + !libfoo/** whitelist idiom (test_08_issue_81), the !*/ "scan all directories" idiom (test_07_issue_74), and !*.yaml/ (test_02_issue_41), all still behave as before.

Notes / tradeoffs I would value your opinion on:

  • The check is O(path-depth) per file, and only runs when the file was not already ignored. It uses the plain per-pattern last-match (check_match_file) for the ancestor directory resolution rather than the compiled re2/hyperscan path, so it is backend-agnostic but does not use the fast combined regex for those extra directory lookups.
  • To apply the wrapper uniformly (including through the internal _test_backend_factory hook used by the tests), I added a small _wrap_backend extension point on PathSpec (a no-op by default, overridden by GitIgnoreSpec). If you would rather fold the check in elsewhere -- e.g. inside each backend, or only in _make_backend -- I am glad to change it.

Tests

Added test_10_issue_129_{a,b,c} to tests/test_06_gitignore.py, running across all backends via parameterize_from_lines. Verified red-green: with the fix reverted the build/keep.log and a/keep.log ignore cases fail across every backend; with the fix they pass, and the build/* re-inclusion case passes both ways. Expected results confirmed against git check-ignore (2.54.0). Full suite (200 passed) and the strict docs build stay green.


This change was prepared with AI assistance and reviewed by me before submission.

GitIgnoreSpec resolves patterns with a flat last-match, so a file-level
negation could re-include a file whose parent directory is excluded, which git
forbids ("It is not possible to re-include a file if a parent directory of that
file is excluded"). For example ["build", "!keep.log"] wrongly treated
build/keep.log as not-ignored, while real git check-ignore ignores it.

Wrap the gitignore backend (_AncestorDirBackend): when a file is not already
ignored by its own resolution, walk the file's ancestor directory prefixes and,
for each, ask whether that directory is excluded, resolved as a directory
(ancestor + "/") using git's plain last-match order via util.check_match_file.
If any ancestor directory is excluded, the file is ignored regardless of a later
file-level negation. Resolving the ancestor as a directory preserves
directory-level re-inclusions, so "build/*" + "!build/keep.log" still
re-includes (build/* excludes only the contents, not the build directory
itself), as do the !libfoo/!libfoo/** and !*/ idioms.

A small _wrap_backend extension point is added on PathSpec (a no-op by default,
overridden by GitIgnoreSpec) so the wrapper applies uniformly, including through
the internal test backend factory.

Fixes cpburnz#129.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GitIgnoreSpec re-includes files under an excluded directory (violates git's parent-exclusion rule)

1 participant