verify.py builds git pathspecs by interpolating configured paths, so a path carrying *, ? or […] matches a wider set than the one it names. Every affected call is on the over-match side; none of them under-matches.
Sibling of #401, which is the same class one layer over: there the interpolation is into gitignore syntax, here it is into git pathspec syntax. Neither escapes.
Status
Items 1 and 2 shipped in #406 (hotfix/0.9.1-build-auto, commits f0b34b3 + e8979b6), via a _literal_specs helper mirroring _exclude_specs. Items 3, 4 and 5 remain open and are what this issue now tracks.
Two corrections to this issue's own text, both established by measurement while fixing 1 and 2 — kept rather than deleted, because each was load-bearing in the decision to defer:
- The deferral rationale below was wrong. It read "neither a regression it introduced nor reachable on any configuration it ships." Both halves fail for those two sites.
git log -S "def path_tracked" returns e370528 alone and git merge-base --is-ancestor e370528 release/0.9.x is false — path_tracked and its only exposed call site are entirely new on that branch. And commit_paths became unattended in dc4f934: until then its only caller was decisions.py:155, behind the operator-invoked bmad-loop decisions; _carry_harvested_deferrals put it inside _defer, on the run path, with no human present.
- Item 2's "already declares safe" claim named the wrong lines.
verify.py:325-327 declares the GitError degrade safe, not the over-match. The real gap was :310-316, whose enumeration of considered false-positive sources (core.quotePath, tracked-but-deleted, --error-unmatch, check-ignore) omitted pathspec globbing entirely. The fix carries a new paragraph there.
Still live on main
main is not already fixed, and the forward-port is not a clean cherry-pick:
path_tracked does not exist on main at all — item 2 is unique to the hotfix branch.
main's commit_paths still has all three bare operands, plus a fourth unescaped ls-files -z -- *missing pathspec that release/0.9.x does not carry. Its failure direction looks different — the membership test is r in tracked, and an over-match returns the neighbour's name — so it likely drops the rel rather than over-including. Not measured; measure before porting.
main hand-rolls r.replace("\\", "/") where .as_posix() fixes the same Windows-separator problem at the source. The port should replace it, not keep both.
Version floor (was a blocking unknown)
Measured, not read off the docs, as this issue required. :(literal) behaves identically at git 2.20.4 (alpine:3.9 — the floor install._WORKTREE_CONFIG_GIT declares) and at 2.55.0, non-root: metachar operand → empty; ordinary path → lists; directory prefix _bmad/render → lists beneath; tracked-but-deleted → still lists; add of an ignored path → rc 1. This mattered more than it looked: below the floor the prefix would read as a literal filename, match nothing and answer False — which authorizes the unlink, inverting the failure direction from "skip a delete" to "delete a tracked file".
Measured (git 2.55.0)
Fixture: sibling pairs docs[a]/docsa, a*b/axb, q?r/qxr, each holding f.md.
1. commit_paths — FIXED on release/0.9.x, still live on main. verify.py:1838-1866 documents itself as "commit exactly paths (and nothing else) … safe to call out of band", and its add/status/commit all took the rels as bare pathspecs. It was not:
add -- docs[a]/f.md staged=['docs[a]/f.md', 'docsa/f.md']
add -- a*b/f.md staged=['a*b/f.md', 'axb/f.md']
add -- q?r/f.md staged=['q?r/f.md', 'qxr/f.md']
An unrelated user file is staged and committed under a story's name. Note the operand is a superset — it matches its own literal text and the glob's reach — because match_pathspec_item compares literally before falling through to fnmatch. That is also why the under-match direction is impossible.
A second harm this issue did not originally record: with the ledger gitignored (the shape _carry_harvested_deferrals is built to hit), git add refuses an explicitly-named ignored path but skips a globbed one. So the plain form could exit rc 0 having staged nothing, status found no change, and the carry reported success having committed no ledger and journalled no harvest-carry-uncommitted — a silent loss where the explicit form has a record. rc 0 requires a glob-reachable neighbour to exist: with none it is still rc 1; with a clean neighbour it stages nothing; with a dirty one it stages the operator's edit.
2. path_tracked (verify.py:328) over-matched — FIXED on release/0.9.x; does not exist on main. With the metachar path untracked and only the collider tracked:
rel='docs[a]/f.md' tracked? plain=True literal=False
rel='a*b/f.md' tracked? plain=True literal=False
rel='q?r/f.md' tracked? plain=True literal=False
So _ledger_is_gits_to_restore answered True for an untracked ledger and the harvest revert's unlink() was skipped — the ledger revert silently no-opped for such a project, leaving a finding about discarded code open for a later sweep. Found by Codex on #406 (dc4f934); reproduction and ablation table on that thread.
3. _exclude_specs (verify.py:280-282) over-excludes, but only for specific shapes — worth stating precisely, because the general form of this claim is false:
prefix='docs[a]' victim='docsa/f.md' victim-excluded=False <- sibling DIR: NOT swallowed
prefix='docs[a]' victim='docsa' victim-excluded=True <- sibling FILE at root: swallowed
prefix='q?r' victim='qxr/f.md' victim-excluded=False
prefix='q?r' victim='qxr' victim-excluded=True
prefix='a*b' victim='axb/f.md' victim-excluded=False
prefix='doc*' victim='docsa/f.md' victim-excluded=True <- a `*` that can swallow `/`
The pathspec has to match the whole path, so a same-length […]/? collision only reaches a sibling file, never a sibling directory's contents; a * reaches everything because it crosses /. Where it does fire, has_changes_since (:208, the diff --quiet at :237) and attempt_dirty (:247, the same call at :270) under-report the diff — a false "no changes", the same failure #406 fixed at _harvest_gate_exclude by a different route.
4. _exclude_specs (git glob) and _path_under_any (:285-287, a Python startswith) disagree on exactly those rows — the two halves of one has_changes_since call answer differently:
prefix='docs[a]' path='docsa' git-excludes=True python-under=False
prefix='q?r' path='qxr' git-excludes=True python-under=False
prefix='doc*' path='docsa/f.md' git-excludes=True python-under=False
The tracked half is excluded while the untracked half is still counted, so the two branches of the same function disagree about what "under the artifact dir" means.
5. safe_rollback's checkout -- <preserve dir> (verify.py:648) — same shape-dependence:
checkout -- 'docs[a]' target=restored victim='dirty' (unaffected)
checkout -- 'doc*' target=restored victim=restored (an unrelated file reverted)
Reachability
implementation_artifacts is read verbatim out of _bmad/bmm/config.yaml and only {project-root}-substituted and resolved (bmadconfig.py:131-146); nothing sanitizes it. * and ? are illegal in Windows path names, so the realistic carrier is [/] — which narrows the live exposure to the victim-is-a-file rows of 3/4. No fixture in the repo has ever carried one, and no report exists; the remaining items are latent-correctness, not a live bug.
Fix shape (remaining items)
:(exclude,literal) in _exclude_specs (item 3) and :(literal) on safe_rollback's preserve dirs (item 5). The floor is settled — :(literal) is measured safe at 2.20.4, see above — so that blocker no longer applies.
Item 4 is a behaviour decision, not an escaping fix: it is about what "under the artifact dir" means. Fixing only the git half does not merely paper over the disagreement — it makes both halves agree on the literal reading, which is the one _path_under_any already implements, so it is the right direction rather than a coin flip.
verify.pybuilds git pathspecs by interpolating configured paths, so a path carrying*,?or[…]matches a wider set than the one it names. Every affected call is on the over-match side; none of them under-matches.Sibling of #401, which is the same class one layer over: there the interpolation is into gitignore syntax, here it is into git pathspec syntax. Neither escapes.
Status
Items 1 and 2 shipped in #406 (
hotfix/0.9.1-build-auto, commitsf0b34b3+e8979b6), via a_literal_specshelper mirroring_exclude_specs. Items 3, 4 and 5 remain open and are what this issue now tracks.Two corrections to this issue's own text, both established by measurement while fixing 1 and 2 — kept rather than deleted, because each was load-bearing in the decision to defer:
git log -S "def path_tracked"returnse370528alone andgit merge-base --is-ancestor e370528 release/0.9.xis false —path_trackedand its only exposed call site are entirely new on that branch. Andcommit_pathsbecame unattended indc4f934: until then its only caller wasdecisions.py:155, behind the operator-invokedbmad-loop decisions;_carry_harvested_deferralsput it inside_defer, on the run path, with no human present.verify.py:325-327declares the GitError degrade safe, not the over-match. The real gap was:310-316, whose enumeration of considered false-positive sources (core.quotePath, tracked-but-deleted,--error-unmatch,check-ignore) omitted pathspec globbing entirely. The fix carries a new paragraph there.Still live on
mainmainis not already fixed, and the forward-port is not a clean cherry-pick:path_trackeddoes not exist onmainat all — item 2 is unique to the hotfix branch.main'scommit_pathsstill has all three bare operands, plus a fourth unescapedls-files -z -- *missingpathspec thatrelease/0.9.xdoes not carry. Its failure direction looks different — the membership test isr in tracked, and an over-match returns the neighbour's name — so it likely drops the rel rather than over-including. Not measured; measure before porting.mainhand-rollsr.replace("\\", "/")where.as_posix()fixes the same Windows-separator problem at the source. The port should replace it, not keep both.Version floor (was a blocking unknown)
Measured, not read off the docs, as this issue required.
:(literal)behaves identically at git 2.20.4 (alpine:3.9 — the floorinstall._WORKTREE_CONFIG_GITdeclares) and at 2.55.0, non-root: metachar operand → empty; ordinary path → lists; directory prefix_bmad/render→ lists beneath; tracked-but-deleted → still lists;addof an ignored path → rc 1. This mattered more than it looked: below the floor the prefix would read as a literal filename, match nothing and answerFalse— which authorizes the unlink, inverting the failure direction from "skip a delete" to "delete a tracked file".Measured (git 2.55.0)
Fixture: sibling pairs
docs[a]/docsa,a*b/axb,q?r/qxr, each holdingf.md.1.
commit_paths— FIXED onrelease/0.9.x, still live onmain.verify.py:1838-1866documents itself as "commit exactlypaths(and nothing else) … safe to call out of band", and itsadd/status/commitall took the rels as bare pathspecs. It was not:An unrelated user file is staged and committed under a story's name. Note the operand is a superset — it matches its own literal text and the glob's reach — because
match_pathspec_itemcompares literally before falling through to fnmatch. That is also why the under-match direction is impossible.A second harm this issue did not originally record: with the ledger gitignored (the shape
_carry_harvested_deferralsis built to hit),git addrefuses an explicitly-named ignored path but skips a globbed one. So the plain form could exit rc 0 having staged nothing,statusfound no change, and the carry reported success having committed no ledger and journalled noharvest-carry-uncommitted— a silent loss where the explicit form has a record. rc 0 requires a glob-reachable neighbour to exist: with none it is still rc 1; with a clean neighbour it stages nothing; with a dirty one it stages the operator's edit.2.
path_tracked(verify.py:328) over-matched — FIXED onrelease/0.9.x; does not exist onmain. With the metachar path untracked and only the collider tracked:So
_ledger_is_gits_to_restoreansweredTruefor an untracked ledger and the harvest revert'sunlink()was skipped — the ledger revert silently no-opped for such a project, leaving a finding about discarded code open for a later sweep. Found by Codex on #406 (dc4f934); reproduction and ablation table on that thread.3.
_exclude_specs(verify.py:280-282) over-excludes, but only for specific shapes — worth stating precisely, because the general form of this claim is false:The pathspec has to match the whole path, so a same-length
[…]/?collision only reaches a sibling file, never a sibling directory's contents; a*reaches everything because it crosses/. Where it does fire,has_changes_since(:208, thediff --quietat:237) andattempt_dirty(:247, the same call at:270) under-report the diff — a false "no changes", the same failure #406 fixed at_harvest_gate_excludeby a different route.4.
_exclude_specs(git glob) and_path_under_any(:285-287, a Pythonstartswith) disagree on exactly those rows — the two halves of onehas_changes_sincecall answer differently:The tracked half is excluded while the untracked half is still counted, so the two branches of the same function disagree about what "under the artifact dir" means.
5.
safe_rollback'scheckout -- <preserve dir>(verify.py:648) — same shape-dependence:Reachability
implementation_artifactsis read verbatim out of_bmad/bmm/config.yamland only{project-root}-substituted and resolved (bmadconfig.py:131-146); nothing sanitizes it.*and?are illegal in Windows path names, so the realistic carrier is[/]— which narrows the live exposure to thevictim-is-a-file rows of 3/4. No fixture in the repo has ever carried one, and no report exists; the remaining items are latent-correctness, not a live bug.Fix shape (remaining items)
:(exclude,literal)in_exclude_specs(item 3) and:(literal)onsafe_rollback's preserve dirs (item 5). The floor is settled —:(literal)is measured safe at 2.20.4, see above — so that blocker no longer applies.Item 4 is a behaviour decision, not an escaping fix: it is about what "under the artifact dir" means. Fixing only the git half does not merely paper over the disagreement — it makes both halves agree on the literal reading, which is the one
_path_under_anyalready implements, so it is the right direction rather than a coin flip.