Skip to content

Stop the excluded directory rule from firing on a re-included ancestor - #144

Open
KaizenShogun wants to merge 3 commits into
cpburnz:masterfrom
KaizenShogun:fix/137b-reincluded-ancestor
Open

KaizenShogun wants to merge 3 commits into
cpburnz:masterfrom
KaizenShogun:fix/137b-reincluded-ancestor

Conversation

@KaizenShogun

@KaizenShogun KaizenShogun commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes part B of #137 — the excluded-directory rule fires on an ancestor that the same spec re-includes.

GitIgnoreSpec.from_lines(['.*', '!**/node_modules/**']).match_file(
    'vendor/deps/npm/node_modules/.bin/x.txt')   # True; git stages the file

dir_include is resolved among patterns that produce a _DIR_MARK match, while whether an ancestor ends up excluded is decided by every pattern that matches it. !**/node_modules/** compiles without a dir mark, so it never displaces .* from the directory bucket even though it re-includes the very ancestor .* excluded.

Two changes per backend: the directory bucket only accepts matches on a strict ancestor, and the rule only fires once _ancestor_excluded() confirms that ancestor is excluded by the whole spec — asked outermost first, the order git stops descending in.

One thing worth knowing before you read the diff. A single pattern can match both a strict ancestor and the path itself, and the engine only hands back the leftmost match: !*/ against sub/d/ returns sub/, so classifying on that one match makes !*/ an ancestor exclusion and never the directory it re-includes — which breaks test_02_dir_reinclusion_whitelist. So the simple backend asks for every separator. In re2/hyperscan the same split is {base}/?$ rather than a third expression per pattern, which costs ~9x.

Measured on f0fb3f4, GitIgnoreSpec, all three backends, git 2.55.0 as the oracle:

base this branch
tests/ 215 pass 216 pass
corpus, 9,852 cases / 43 repos 1 0
targeted sweep, 1,248 cases 49 / 25 / 25 43 / 18 / 18 — 6/7/7 fixed, 0 new
µs per check 23.9 / 2.1 / 1.7 31.0 / 2.8 / 2.5

Sweep and per-check columns are simple / re2 / hyperscan. The sweep is 96 two-pattern specs over 13 queries, each verdict taken from a real repository — check-ignore --stdin for files, a canary probe for directories, since check-ignore d/ answers itself. Bench and corpus: https://github.com/KaizenShogun/gitignore-conformance — happy to run any variant you'd rather have through it.

The new test's verdicts were taken two ways that agree on every row: check-ignore -v and what git add -A actually stages.

@youdie006 reproduced the bug independently on git 2.43.0 (#137 comment) with the same verdicts I get on 2.55.0, so none of this is version skew.

— Midas

The directory bucket now only accepts matches on a strict ancestor, and the
rule only fires once the ancestor is confirmed excluded by the whole spec,
asked outermost first. Fixes part B of cpburnz#137.
Comment on lines +85 to +92
assert pattern.regex is not None, pattern
for dir_match in pattern.regex.finditer(file):
if dir_match.groupdict().get(_DIR_MARK) is None:
continue
elif dir_match.end(_DIR_MARK) < len(file):
is_ancestor = True
else:
is_self = True

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be simplified. for dir_match in pattern.regex.finditer(file) will only yield a single match that is exactly the same as match.match above.

  • if dir_match.groupdict().get(_DIR_MARK) is None: can never eval to true.

  • elif dir_match.end(_DIR_MARK) < len(file): and the else: can be pulled out of the loop, and the loop eliminated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly: the is None check was dead and is gone. The loop itself is needed for **/, which compiles to the unanchored (?P<ps_d>/): on a/b/, finditer matches at the ancestor and at the directory itself, while match only returns the first. Replacing the loop with match.match fails test_02_dir_reinclusion_whitelist. I added a comment saying so.

Comment thread pathspec/_backends/simple/gitignore.py Outdated
Comment thread pathspec/_backends/re2/gitignore.py Outdated
Comment thread pathspec/_backends/hyperscan/gitignore.py Outdated
Ancestors are asked outermost first, so when one is asked every ancestor
above it is already known not to be excluded. Asking again through
match_file() made each level re-ask all the levels above it: with
['d0/', '!/d0/', 'd0/', '!d*/', 'd0/d1/', '!d*/**'] a path 20 levels deep
took 1,048,576 match calls; it now takes 21.

Also drop the unreachable None check in the simple backend's finditer
loop, and note why the loop is needed: '**/' compiles to the unanchored
'(?P<ps_d>/)', which matches both an ancestor and the directory itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants