Skip to content

perf(git): normalize tracked discard paths once per operation - #20299

Merged
nwparker merged 2 commits into
mainfrom
nwparker/perf-git-discard-path-normalization
Sep 13, 2026
Merged

nwparker merged 2 commits into
mainfrom
nwparker/perf-git-discard-path-normalization

Conversation

@nwparker

@nwparker nwparker commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​173 0 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​173
Prod 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​44 $\color{#cf222e}{\Huge{\mathbf{−}}}$​38 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​6

What

Bulk discard classified requested paths with two .filter() passes, each calling isTrackedPathSpec, which re-normalized every tracked record inside .some() — worst case ≈ 2R(T+1) normalizations. Main and relay each carried their own copy of the same matcher.

Now a single shared pure module, src/shared/git-tracked-pathspecs.ts, does one classification pass: each request is normalized once, and each visited tracked entry is normalized lazily at most once within that synchronous partition. Main and relay both call it, so the duplicate relay implementation is gone.

The module is in src/shared (not under src/main/git) so relay does not pull main's WSL/command-resolution dependencies. It imports nothing at all — no node:*, no main-only module.

Behavior preserved

  • Early directory-match exit is kept — selecting a directory still stops at its first tracked descendant instead of eagerly normalizing 150,000 of them.
  • Slash conversion, trailing-slash semantics, case sensitivity, descendant boundaries, original path spelling, duplicates, and relative order are unchanged (the arrays select restore vs clean).
  • Git argv, chunking, restore/clean choice, validation order, routing, and cleanup are untouched.
  • No cache outlives one operation or crosses repositories/hosts. The lazy cache is created per partitionTrackedPathSpecs call and dies with it.

ELI5

Discarding a big selection of files means sorting them into two buckets: files Git already knows about (rewind them) and files Git has never seen (delete them). To sort them, the old code tidied up the spelling of every known-file name over and over — once for every single file you selected, and then a second time because it walked the list twice. If you selected 64 files and Git knew about 256, it did that tidying about 33,000 times to answer 64 questions. Now it tidies each name once and remembers the tidy version for the rest of that one sort, which is 320 times instead of 33,000. It's like alphabetising a deck of cards once instead of re-alphabetising it before every card you look up — the answers are identical, you just stop redoing the same chore. Honestly: for normal selections you will not notice any difference; this is a cheap-operation cleanup, not a speedup you can feel.

Measurement

Scenario Before After
64 misses × 256 tracked paths 32,896 320
one directory request, 150,000 descendants 4 2
zero requests, 150,000 descendants 0 0

Normalization counts only — not CPU time, not wall clock, not latency. Matching comparisons remain worst-case O(R×T); only the per-comparison normalization is removed. The lazy cache temporarily retains O(visited tracked records) normalized strings, a real memory tradeoff rather than a free win. No wall-clock benchmark was run, so no timing claim is made.

Tests

src/shared/git-tracked-pathspecs.test.ts diffs against the previous implementation across path edges (Windows/UNC paths, trailing slashes, glob-looking literals, Unicode NFC-vs-NFD, empty strings) and asserts the normalization counts above. New action-order tests in the main and relay suites pin the exact ls-files / restore / clean argv, including duplicates and original spelling.

ORCA_BACKGROUND_LAUNCH=1 pnpm exec vitest run --config config/vitest.config.ts \
  src/shared/git-tracked-pathspecs.test.ts \
  src/main/git/status-discard-and-bulk-staging.test.ts \
  src/relay/git-handler-working-tree-changes.test.ts \
  src/shared/git-discard-path-safety.test.ts \
  src/main/git/status-wsl-pathspecs.test.ts \
  src/main/git/status-pathspec-literals.test.ts \
  src/main/git/status-discard-symlink.test.ts \
  src/main/git/source-control/bulk-pathspec-command-line-budget.test.ts \
  src/main/git/source-control/wsl-tracked-pathspec-banner.test.ts

Re-verified on the current branch head:

  • the command above → 9 files / 84 tests passed, covering real local Git discard, symlink safety, WSL conversion and banner fencing, and command-line budgets.
  • ORCA_BACKGROUND_LAUNCH=1 pnpm run test:perf:contracts → 11 files / 74 tests passed.
  • ORCA_BACKGROUND_LAUNCH=1 pnpm tc → passed (the node project typechecks src/shared, src/main, and src/relay together).
  • ORCA_CODE_QUALITY_BASE=main pnpm run check:code-quality:changed → 0 new findings across 6 changed files (native, type-aware, React Doctor).
  • Scoped oxfmt --write on the changed files → no reformatting needed.

Additionally, an out-of-band randomized differential check ran the old and new partition side by side over 200,000 generated request/tracked list pairs built from adversarial atoms (empty string, /, //, \, \\, ., .., C:, UNC shapes, [a], *, :(glob), spaces, newlines, NUL, NFC/NFD é, ligatures) with mixed separators: 0 divergences, and repeated calls on one matcher instance returned stable results.

Limitations

Not validated on native Windows, a real WSL distro, or a live SSH host; relay coverage is the mocked handler path plus real local Git. Windows/UNC/WSL path shapes are covered as strings through the shared module and through the WSL pathspec suites, not by executing Git on those hosts.

Merge risk

Low.

  • Blast radius. Two call sites, both bulk discard: src/main/git/source-control/discard-changes.ts:121 and src/relay/git-handler-discard-operations.ts:90. Single-file discard, staging, status, and every other Git path are untouched. Nothing persisted, no schema, no wire format, no IPC/RPC payload, no settings — so there is no mixed-version or rollback surface.
  • What breaks if the reasoning is wrong. This is the destructive discard path: the two output arrays decide git restore --worktree versus git clean -ffdx. If the lazy normalization cache ever returned a stale or wrong value, a tracked file could be routed to clean (uncommitted and committed content deleted) or an untracked file routed to restore (which would simply fail, harmless). The first direction is the only one that loses data, and it is the reason this is called out rather than filed as a plain refactor.
  • Riskiest line. src/shared/git-tracked-pathspecs.ts:15 — const normalizedTracked = (normalizedTrackedPaths[index] ??= normalizeGitPathForCompare(trackedPath)). Everything about safety rests on that memo being keyed to the same immutable trackedPaths array for the life of one partition, and on ??= not mistaking a legitimately empty normalized path ('', which / and // normalize to) for "not yet computed". '' is not nullish, so it caches and reuses correctly; both call sites build trackedPathSpecs locally and never mutate it during the partition.
  • How it would be noticed. A misroute is immediately visible: a discarded tracked file would come back empty/absent instead of reverted to HEAD, and git status would show a deletion rather than a clean tree. The pinned-argv tests in both the main and relay suites would fail first in CI.
  • Revert. The branch is two commits (d72d30b the refactor, f7a4a8d a dead re-export cleanup). Reverting the squashed merge commit restores the two previous private copies of the matcher; there is no data or state to migrate back.

User-facing trade-offs

One, and it is invisible in practice: during a single bulk discard, the partition now holds an extra array of up to one entry per visited tracked record — bounded by the ls-files output for the selection, freed as soon as the partition returns. For a 150,000-descendant directory selection that is ~150k array slots; the normalized strings themselves are mostly not new allocations, because String.prototype.replace with a non-matching pattern returns the original string, and git ls-files emits forward slashes. Before, those strings were created and discarded one comparison at a time. This is a peak-memory-for-work trade, not a leak: nothing survives the call.

Beyond that, none identified. What was actually checked to justify that:

  • Classification output is byte-for-byte identical to the previous implementation over 200,000 randomized adversarial path pairs plus the enumerated edge table (Windows backslashes, UNC \\host\share, drive letters, trailing/repeated slashes, ./.. segments, bracket and glob-looking literals, :(glob) prefixes, spaces, newlines, NFC vs NFD) — so no file changes which of restore / clean it receives.
  • Original spelling, duplicates, and relative order are preserved in both arrays, so the emitted argv is unchanged; the main and relay suites now assert the exact ls-files / restore / clean argv rather than just the bucket contents.
  • Case sensitivity is unchanged (DOCS still does not match docs/readme.md) — this is pre-existing behavior, deliberately pinned by test, not introduced or altered here.
  • Symlink containment (git-discard-path-safety), worktree-escape validation, WSL pathspec conversion and login-shell banner fencing, and command-line budget chunking are all untouched and still green.
  • The shared module imports nothing, so relay gains no main-only dependency and the relay bundle surface does not change.
  • No user-visible string, setting, IPC/RPC contract, or persisted artifact is touched, so there is no paired-web/iOS/Android/older-client compatibility angle.

Unproven, disclosed rather than claimed: no execution on native Windows, in a real WSL distro, or against a live SSH/relay host; no wall-clock or CPU benchmark, so the "faster" framing is deliberately limited to operation counts; relay coverage is the mocked handler path.

@coderabbitai

coderabbitai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: cf529754-783d-4dd3-8185-77a9a57b5734

📥 Commits

Reviewing files that changed from the base of the PR and between d72d30b and f7a4a8d.

📒 Files selected for processing (2)
  • src/main/git/source-control/discard-changes.ts
  • src/main/git/source-control/git-pathspec.ts
💤 Files with no reviewable changes (1)
  • src/main/git/source-control/git-pathspec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/git/source-control/discard-changes.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 6 remain after this review.


📝 Walkthrough

Walkthrough

The change adds shared Git path-spec matching and partitioning utilities. The main and relay bulk-discard flows now use the shared partitioner. The local matcher and re-export are removed. Tests cover path normalization, ordering, duplicates, literal patterns, early exits, and discard command routing.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to f7a4a

No merge-blocking risk remains from the reviewed change.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 7 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description gives detailed technical context, behavior guarantees, test results, limitations, and merge-risk analysis. However, it omits required template information, including the mandatory Link… Add the required template sections. Provide a valid issue reference after Fixes #, add ## Visual Proof with N/A and a reason because this is not a visual change, and complete the Testing, AI Disclosure, Review, Agent skill upstream bo…
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: normalizing tracked discard paths once per operation. It is concise and specific.
Full details: Description check

Explanation

The description gives detailed technical context, behavior guarantees, test results, limitations, and merge-risk analysis. However, it omits required template information, including the mandatory Linked Issue, the Visual Proof section with an N/A explanation, and several checklist and review sections.

Resolution

Add the required template sections. Provide a valid issue reference after Fixes #, add ## Visual Proof with N/A and a reason because this is not a visual change, and complete the Testing, AI Disclosure, Review, Agent skill upstream boundary, Notes, and Checklist sections.

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot 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.

ℹ️ No critical issues — one minor cleanup suggestion inline.

Reviewed changes

  • New shared matcher — src/shared/git-tracked-pathspecs.ts adds createTrackedPathSpecMatcher, isTrackedPathSpec, and partitionTrackedPathSpecs; one matcher per operation normalizes each request once and each visited tracked entry at most once through a lazy index-keyed cache, while keeping the directory early-exit.
  • Main discard — bulkDiscardChanges replaces the two .filter() passes with a single partitionTrackedPathSpecs call; the old normalizeGitPathForCompare is removed from git-pathspec.ts.
  • Relay discard — the duplicated private matcher in GitHandlerDiscardOperations is deleted in favour of the shared module, which is dependency-free and safe for the relay bundle.
  • Tests — a differential suite against the previous implementation, normalization-count contracts, cross-operation non-reuse, plus exact-argv action-order tests in both the main and relay suites.

I traced the new lazy cache against the old implementation: normalization is deterministic and pure, ??= caches the empty string correctly (non-nullish), short-circuit order is unchanged, and duplicate/order/spelling semantics are preserved. I also ran git-tracked-pathspecs.test.ts, status-discard-and-bulk-staging.test.ts, and git-handler-working-tree-changes.test.ts — all green.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using openrouter/deepseek/deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker force-pushed the nwparker/perf-git-discard-path-normalization branch from f7a4a8d to 145f717 Compare September 13, 2026 02:41
@nwparker

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #20274 merged.

Both PRs touched the tracked/untracked split in bulkDiscardChanges. #20274 replaced the two filter passes with a single forEach; this PR replaces the same code with partitionTrackedPathSpecs from the new src/shared/git-tracked-pathspecs.ts. I resolved in favour of this PR because the helper is a strict superset: same single pass, same preservation of original spelling, duplicates and order, plus it memoizes normalizeGitPathForCompare across the whole partition via one shared matcher closure — which #20274's version did not do.

Tests on the rebased tree: git-tracked-pathspecs.test.ts, status-discard-and-bulk-staging.test.ts, git-handler-working-tree-changes.test.ts — 56/56 pass.

@nwparker
nwparker merged commit 471463f into main Sep 13, 2026
34 checks passed
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.

1 participant