Following up on your request in #132.
Everything below is measured on master at 5f14318, against the same corpus I used there — 9,852 (.gitignore, path) cases from 43 real repositories, git check-ignore as the oracle, KaizenShogun/gitignore-conformance — with GitIgnoreSpec on all three backends. The six remaining divergences are two bugs, not six.
A. X/** reports the directory X itself as ignored — 5 of the 6, pre-existing
from pathspec.gitignore import GitIgnoreSpec
GitIgnoreSpec.from_lines(['d/**']).match_file('d/') # True
GitIgnoreSpec.from_lines(['d/*']).match_file('d/') # False
git ignores d in neither case. d/** excludes what is under d, not d itself, and that difference is exactly what makes a re-inclusion work under d/** and fail under d/:
.gitignore |
git stages d/keep.txt |
match_file('d/') |
match_file('d/keep.txt') |
d/** + !d/keep.txt |
yes |
True |
False |
d/ + !d/keep.txt |
no |
True |
True |
On the first row pathspec calls the directory ignored and the file inside it not ignored. A caller that prunes a walk with match_file(append_dir_sep(p)) — the idiom append_dir_sep() was added for in #65 — never reaches the file git would stage.
The mechanism is in the compiled regex. A trailing ** emits a bare '/' (spec.py:324), so d/** becomes ^d/, which the string d/ matches with nothing left over — and with no ps_d group it lands in the file accumulator, not the directory one:
'd/**' -> ^d/
'd/*' -> ^d/[^/]+(?:(?P<ps_d>/)|$)
'd/' -> ^(?:.+/)?d(?P<ps_d>/)
d/* is already right on the same question, which is what makes this look like an oversight rather than a decision. GitIgnoreBasicPattern has the same shape (basic.py:280).
Appending one character there — out_parts.append('/.') — takes the bench from 6 to 1 on all three backends. Of the 212 tests in tests/, three then fail, and all three are assertEqual on the literal regex text: test_03_child_double_asterisk, test_03_duplicate_leading_double_asterisk_edge_case, test_14_issue_81_a. The match_file assertions inside those same tests still hold, and **/api/** still compiles equal to **/**/api/**/**. I'm not putting that forward as the fix — it's a one-character probe that says where the five live. Happy to send it as a PR with those three assertions updated if you want it in that shape.
The five corpus rows, all the same shape: .settings/ under .settings/** (kubernetes), .superset/ under **/.superset/** (supabase), and tensorflow/lite/gen/, tensorflow/lite/tools/make/downloads/, tensorflow/lite/tools/make/gen/ under their /…/** (tensorflow).
B. The excluded-ancestor rule outlives the negation that re-includes the ancestor — 1 of the 6, new in #132
GitIgnoreSpec.from_lines(['.*', '!**/node_modules/**']).match_file(
'vendor/deps/npm/node_modules/.bin/x.txt') # True; git: not ignored
93e0179 (master before the merge) answered False here on all three backends; from f404ca47 on, True. Reduced from nodejs/node's real .gitignore with git check-ignore arbitrating each step. Swap .bin for bin and the answer is right again, so it is the hidden ancestor doing it.
.* is the only pattern that produces a _DIR_MARK match, so it sets dir_include = True for the ancestor .bin. !**/node_modules/** compiles to ^(?:.+/)?node_modules/ — no dir mark — so it only ever reaches file_include, and if dir_include: wins. But that same negation also re-includes .bin itself: git names it as the deciding pattern for the bare ancestor path, and a canary dropped inside .bin stages. pathspec agrees, when you ask it directly:
s = GitIgnoreSpec.from_lines(['.*', '!**/node_modules/**'])
s.match_file('vendor/deps/npm/node_modules/.bin') # False — ancestor not excluded
s.match_file('vendor/deps/npm/node_modules/.bin/x.txt') # True — because that ancestor is excluded
So the ancestor rule fires on an ancestor the same spec says is not excluded. The directory accumulator is resolved over _DIR_MARK matches only, while whether an ancestor ends up excluded is decided by every pattern that matches it. I don't have a measured fix for this one — it looks like an ordering question inside the accumulator rather than a design problem, but I haven't got a patch I'd stand behind yet.
How git was asked, since half of this is about directories
The obvious ways to ask git about a directory are both traps, and they cost me a probe script each. check-ignore on d/ with the trailing slash matches d/*, because * happily matches the empty string — that is the question answering itself, not git ignoring anything. And !! d/ in status --ignored fires just as readily when a directory merely has nothing left to stage.
So directory verdicts here come from a real tree: check-ignore -v on the bare path, plus a canary probe — append !d/canary, create it, git add -A -n, and staged means the parent was never excluded, per the "not possible to re-include a file if a parent directory is excluded" rule in gitignore(5). That is the only thing I found that separates d/* from d/. Corpus cases were kept only where the independent ways agreed; disagreements were dropped and counted, never guessed at. git 2.55.0 throughout.
— Midas
Following up on your request in #132.
Everything below is measured on
masterat5f14318, against the same corpus I used there — 9,852 (.gitignore, path) cases from 43 real repositories,git check-ignoreas the oracle, KaizenShogun/gitignore-conformance — withGitIgnoreSpecon all three backends. The six remaining divergences are two bugs, not six.A.
X/**reports the directoryXitself as ignored — 5 of the 6, pre-existinggit ignores
din neither case.d/**excludes what is underd, notditself, and that difference is exactly what makes a re-inclusion work underd/**and fail underd/:.gitignored/keep.txtmatch_file('d/')match_file('d/keep.txt')d/**+!d/keep.txtd/+!d/keep.txtOn the first row pathspec calls the directory ignored and the file inside it not ignored. A caller that prunes a walk with
match_file(append_dir_sep(p))— the idiomappend_dir_sep()was added for in #65 — never reaches the file git would stage.The mechanism is in the compiled regex. A trailing
**emits a bare'/'(spec.py:324), sod/**becomes^d/, which the stringd/matches with nothing left over — and with nops_dgroup it lands in the file accumulator, not the directory one:d/*is already right on the same question, which is what makes this look like an oversight rather than a decision.GitIgnoreBasicPatternhas the same shape (basic.py:280).Appending one character there —
out_parts.append('/.')— takes the bench from 6 to 1 on all three backends. Of the 212 tests intests/, three then fail, and all three areassertEqualon the literal regex text:test_03_child_double_asterisk,test_03_duplicate_leading_double_asterisk_edge_case,test_14_issue_81_a. Thematch_fileassertions inside those same tests still hold, and**/api/**still compiles equal to**/**/api/**/**. I'm not putting that forward as the fix — it's a one-character probe that says where the five live. Happy to send it as a PR with those three assertions updated if you want it in that shape.The five corpus rows, all the same shape:
.settings/under.settings/**(kubernetes),.superset/under**/.superset/**(supabase), andtensorflow/lite/gen/,tensorflow/lite/tools/make/downloads/,tensorflow/lite/tools/make/gen/under their/…/**(tensorflow).B. The excluded-ancestor rule outlives the negation that re-includes the ancestor — 1 of the 6, new in #132
93e0179(master before the merge) answeredFalsehere on all three backends; fromf404ca47on,True. Reduced fromnodejs/node's real.gitignorewithgit check-ignorearbitrating each step. Swap.binforbinand the answer is right again, so it is the hidden ancestor doing it..*is the only pattern that produces a_DIR_MARKmatch, so it setsdir_include = Truefor the ancestor.bin.!**/node_modules/**compiles to^(?:.+/)?node_modules/— no dir mark — so it only ever reachesfile_include, andif dir_include:wins. But that same negation also re-includes.binitself: git names it as the deciding pattern for the bare ancestor path, and a canary dropped inside.binstages. pathspec agrees, when you ask it directly:So the ancestor rule fires on an ancestor the same spec says is not excluded. The directory accumulator is resolved over
_DIR_MARKmatches only, while whether an ancestor ends up excluded is decided by every pattern that matches it. I don't have a measured fix for this one — it looks like an ordering question inside the accumulator rather than a design problem, but I haven't got a patch I'd stand behind yet.How git was asked, since half of this is about directories
The obvious ways to ask git about a directory are both traps, and they cost me a probe script each.
check-ignoreond/with the trailing slash matchesd/*, because*happily matches the empty string — that is the question answering itself, not git ignoring anything. And!! d/instatus --ignoredfires just as readily when a directory merely has nothing left to stage.So directory verdicts here come from a real tree:
check-ignore -von the bare path, plus a canary probe — append!d/canary, create it,git add -A -n, and staged means the parent was never excluded, per the "not possible to re-include a file if a parent directory is excluded" rule in gitignore(5). That is the only thing I found that separatesd/*fromd/. Corpus cases were kept only where the independent ways agreed; disagreements were dropped and counted, never guessed at. git 2.55.0 throughout.— Midas