Skip to content

fix(hooks): avoid quadratic git branch delete matching - #320

Open
atomicdjt wants to merge 2 commits into
griddynamics:mainfrom
atomicdjt:fix/313-git-branch-delete-scaling
Open

fix(hooks): avoid quadratic git branch delete matching#320
atomicdjt wants to merge 2 commits into
griddynamics:mainfrom
atomicdjt:fix/313-git-branch-delete-scaling

Conversation

@atomicdjt

@atomicdjt atomicdjt commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Remove repeated suffix rescanning from the git-branch-delete PreToolUse detector while preserving the force-delete coverage added by #299.

Root cause

The previous matcher began at every git branch candidate and used two independent lookaheads: one for a delete flag and one for a force flag. Each lookahead scanned forward to the next ;, &, |, CR/LF, or end of input.

On a separator-free command containing many candidates, unsuccessful matches therefore rescanned overlapping suffixes. Doubling the candidate count produced approximately four times the evaluation time.

Structural solution

The matcher now starts local candidate discovery at each separator-bounded segment boundary and stops the prefix at the first line-local git branch candidate.

This is sufficient because every later candidate in that segment has a post-branch lookahead window that is a suffix of the first candidate's window. The existing delete and force predicates are unchanged existential checks, so any qualifying flag pair visible to a later candidate is also visible to the first.

A separate alternative preserves the previous git\s+branch behavior when the whitespace between git and branch crosses CR/LF. Those candidates cross a recognized boundary, so this legacy alternative does not recreate repeated scanning of one separator-free suffix. No arbitrary scan-distance bound or shell parser was introduced.

Security behavior

The dangerous-command behavior introduced by #299 is preserved, including:

  • -d -f and -f -d
  • -D, -fd, and -df
  • long, short, mixed, clustered, and reordered flags
  • flags before, after, and around branch operands
  • widely separated delete and force flags
  • wrapper and whitespace variants
  • ;, &&, |, LF, and CRLF boundaries
  • review-marker behavior

The delete and force lookaheads and separator set are unchanged. Flags from different command segments still cannot combine.

An independently compiled pre-change matcher at 70d401ec6d556005c16182024edfcfad3aa588ca and the optimized matcher at c0d6520ae21e054bd438389432b1f258fde8da40 (unchanged by follow-up commit 55d497009a726c198671106ab54b7d25b8321029) produced zero differences across 12 explicit cases plus 3,000,000 deterministically generated token sequences (seed 0x313299). The harness also asserts that the baseline source equals the recorded old matcher and that the baseline and final regex sources differ.

Separately, 20 ordering and clustering variants were exercised against Git 2.55.0 using an unmerged disposable branch. Git accepted every form and deleted the branch in every case.

Performance impact

Local Linux Node 24 medians after warmup through the production evaluateDangerous path:

Separator-free input Before After
14 KB / 1,000 candidates 27.51 ms 0.20 ms
28 KB / 2,000 candidates 105.46 ms 0.41 ms
56 KB / 4,000 candidates 419.91 ms 0.81 ms
112 KB / 8,000 candidates 1,689.46 ms 1.59 ms

The fixed series grows approximately with input size and no longer exhibits the prior repeated-suffix rescan trend.

The regression test uses a 250 ms threshold for the 112 KB production-path workload. In a dedicated threshold run, the vulnerable minimum was 1,678.81 ms while the fixed maximum was 4.19 ms, leaving substantial headroom for CI variance.

Tests

  • Expanded dangerous flag-order, clustering, mixed-form, and wrapper coverage.
  • Added flags-after-operands and widely separated flag cases.
  • Added safe listing, creation, and unrelated-command cases.
  • Added segment-isolation checks for every supported separator.
  • Added cross-line CR/LF coverage for all four requested legacy whitespace forms.
  • Added the same-segment later-candidate regression case.
  • Added production-path performance regression coverage.
  • Dedicated detector file: 109 tests passed.
  • Full hooks suite: 1,257 tests passed.

Validation

  • npm ci — passed.
  • npm run check — TypeScript validation passed.
  • npm run test — passed; 45 hook bundles built, 34/34 test files passed, and 1,257/1,257 tests passed.
  • Repository Python type validation — mypy passed across 55 source files.
  • Repository Python suites — 339 MCP tests and 56 CLI tests passed.
  • Repository pre-commit entrypoint — passed in an isolated LF WSL worktree, including both plugin generations, hooks type validation, 45-bundle build, and the full hooks suite.
  • Performance threshold — 20/20 production-path runs on the 112 KB workload passed at 1.631–1.869 ms against the unchanged 250 ms limit.
  • Mutation sanity checks — disabling the cross-line alternative dropped all four requested cases; bounding the lookaheads before the next local candidate dropped the same-segment case while a single dangerous candidate still matched.
  • npm audit --omit=dev — 0 vulnerabilities.
  • git diff --check — passed.
  • DCO trailers — verified on both commits.

Risk / blast radius

This modifies a PreToolUse dangerous-action security detector. A false negative could permit an unreviewed destructive branch deletion, while a pathological matcher can delay every Bash tool call.

Risk was controlled by retaining the existing flag predicates and separators, expanding the #299 regression matrix, comparing independently compiled pre-change and final matchers, validating supported forms against real Git, and exercising adversarial production-path inputs. The production change is confined to the git-branch-delete pattern.

Compatibility

No dangerous-action behavior change is intended. Existing lexical separator handling, cross-line whitespace behavior, review-marker policy, evaluation interfaces, and module responsibilities are unchanged. Because this is a local matcher implementation change rather than an architecture change, no architecture documentation update is required.

Limitations

  • Native Windows cannot run the hooks package's Unix shell steps, so the repository pre-commit entrypoint was run in an isolated LF WSL worktree; it passed. Python validation ran separately because that worktree lacked a provisioned root venv.
  • CodeQL is unavailable locally and remains a remote PR check.
  • Performance values are local medians, not guarantees for every CI runner.

Fixes #313

Signed-off-by: David Turner <davidelsey9513@gmail.com>
@atomicdjt
atomicdjt marked this pull request as ready for review August 21, 2026 13:57
@github-actions github-actions Bot added the bug Something isn't working label Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Rosetta Triage Review

Summary: Fixes O(n^2) rescanning in the git-branch-delete PreToolUse detector (issue #313) by anchoring git branch candidate discovery to separator-bounded segments and matching only the first local candidate per segment, while adding a dedicated cross-line alternative to preserve prior CR/LF-straddling behavior.

Findings:

  • Traced the regex by hand: within one separator-bounded segment, a later git branch candidate's post-branch scan window is always a suffix of the first candidate's, so stopping at the first candidate cannot drop a true positive — the delete/force lookaheads still scan to the segment's end regardless of anchor point.
  • Cross-segment isolation is preserved: the segment prefix (?:^|[;&|\r\n])(?:(?!...)[^;&|\r\n])* cannot cross ;, &, |, CR, or LF, so flags from different commands still can't combine — matches the existing git-force-push isolation pattern already in this file.
  • Test coverage is genuinely expanded, not just re-padded: new cases cover flag clustering/reordering, wide separation, all five separators in both positions, and a dedicated 112 KB latency-budget regression test tied to the production evaluateDangerous path.
  • Change is scoped to one pattern; no architecture doc update needed per the repo's own hooks contract (docs/hooks/*.md covers I/O contracts, not internal matcher implementation).

Caveats:

  • The PR body's specific benchmark numbers and "3,000,000 generated sequences" differential-testing claim were not independently re-run in this triage — accepted on the strength of the included regression test and the hand-verified regex logic, not the reported figures themselves.

Automated triage by Rosetta agent

@isolomatov-gd isolomatov-gd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you, @atomicdjt. Two things to add before this goes in.

1. The cross-line branch has no test. GIT_BRANCH_CROSS_LINE (patterns.ts:111) is the alternative that preserves the old \s+ behavior, and nothing pins it. Deleting it from GIT_BRANCH leaves all 344 tests green across git-branch-delete.test.ts, dangerous-actions.test.ts and adapter.cursor.test.ts, while these stop being caught:

git\nbranch -D x
git\r\nbranch -D x
git\rbranch -D x
git \n branch -d -f x

The only whitespace variant covered today is the tab at line 41, which exercises GIT_BRANCH_LOCAL. Please add the four above.

2. Add the same-segment case git branch -a git branch -D x. It is the exact shape the anchor-skip optimization risks. It matches correctly today; it should be pinned.

Worth a code comment too: the optimization is sound only because both lookaheads run unbounded to the segment end, which makes every later candidate's window a suffix of the first's. Bounding a lookahead window later would silently break it. Same for \r\n staying in the separator class -- the pattern has no m flag, so narrowing that class to [;&|] would stop ^ from reaching post-newline segments.

On the fix itself: verified equivalent to the previous matcher across ~5.5M inputs (306,911 seeded fuzz, 5,229,042 exhaustive length-1..6 sequences, 56 crafted adversarial), zero divergences in both directions -- no new false negatives, no new false positives. The harness was mutation-checked first, so the zero is meaningful. O(n^2) to O(n) reproduced: 8.8 / 35.4 / 138.9 / 552.2 ms before against 0.08 / 0.16 / 0.33 / 0.66 ms after, at 1k/2k/4k/8k repetitions. The 250 ms assertion is not flaky (15/15 runs, 0.77-1.87 ms) and a revert fails it by roughly 4x.

The approach is also better than both directions suggested in #313. Bounding the window would break the test added here at line 84, which needs -f matched 75,000 characters after branch. Splitting on separators in JS would mean changing the DangerPattern contract or special-casing one pattern inside evaluate.ts.

One finding in the same file, outside this PR. git-force-push has the same unbounded-rescan shape with a nested quantifier, and it is larger: on 'git push a '.repeat(k) we measure 16.6 / 68.2 / 264.6 / 1082.6 ms at k = 1000/2000/4000/8000. It also runs earlier -- index 9 versus index 12 -- and matchPatterns is first-match-wins, so a Bash call still pays over a second on adversarial input after this merges. rm-rf-recursive, rm-rf-root, aws-s3-rm-recursive and curl-pipe-shell scale the same way. Taking it in this PR would close the class; if you would rather not, say so and we will track it separately.

Signed-off-by: David Turner <davidelsey9513@gmail.com>
@atomicdjt

Copy link
Copy Markdown
Contributor Author

@isolomatov-gd Thank you for the unusually thorough review and for independently validating both equivalence and scaling.

Addressed in 55d49700:

  • Added exact-matcher and evaluateDangerous coverage for all four requested cross-line forms (LF, CRLF, CR, and spaced LF). Each case necessarily exercises GIT_BRANCH_CROSS_LINE because the line-local alternative excludes CR/LF.
  • Added the same-segment regression for git branch -a git branch -D x through both the exact matcher and production evaluation path.
  • Documented the unbounded-lookahead/suffix-window invariant, the false-negative risk of future bounds, and the CR/LF plus no-m anchoring invariant.

Validation is green: focused suites 349/349, full hooks 1,257/1,257 with 45 bundles built, repository pre-commit passed in an LF WSL worktree, mypy passed across 55 files, the Python suites passed 339 MCP plus 56 CLI tests, and 20/20 production-path performance runs were 1.631–1.869 ms against the unchanged 250 ms limit. Mutation checks confirmed that removing the cross-line alternative drops all four new cases and that bounding before the next local candidate drops the same-segment case. Rosetta Hooks CI and all current CodeQL checks are also green on the new head.

I would prefer to keep #320 scoped to #313 and track the git-force-push, rm-rf-*, aws-s3-rm-recursive, and curl-pipe-shell findings separately. I did not find an existing tracking issue for that matcher family, and I am happy to take the follow-up work unless you would prefer this PR to absorb it.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ROSETTA] git-branch-delete pattern scales O(n^2) on separator-free commands

2 participants