fix: compare git-emitted paths correctly on Windows - #388
Open
oudi wants to merge 1 commit into
Open
Conversation
`git rev-parse --path-format=absolute` prints POSIX-style absolute paths even on Windows (`D:/repo`), while `realpathSync()` and the session cwds recorded by pi are native (`D:\repo`). `resolveProject()` compared the two with `===`, so on Windows: - `isTopLevel` was permanently false, which hid the worktree switcher entirely and left only the disabled "Open repo root" hint; - `isWorktreeTopLevel` was therefore also always false, so linked worktrees never collapsed into the main repo's `projectRoot` and each showed up as a separate phantom project instead of being grouped; - `listWorktrees()` returned slash-style paths that never matched `selectedCwd`, breaking the current-worktree highlight in the sidebar. Route every path read out of git through `toNativePath()` and compare with `samePath()`, which also tolerates drive-letter case since Windows paths are case-insensitive. Branch names deliberately skip normalization so `feature/x` does not become `feature\x`. Both helpers live in a new `lib/paths.ts` alongside `isWindowsAbsolutePath()`, which had been duplicated in `lib/file-access.ts` and `lib/path-security.ts`. `isFilePathAllowed()` was a byte-for-byte copy of `isPathWithinRoots()` and now delegates to it, leaving one implementation of the access-control check. The allowed-roots set keeps its slash-normalized form: it is an internal Set key that is never displayed, and containment checks re-normalize both sides anyway, so both path forms authorize identically. Verified on Windows against real repositories: repo roots now resolve as top-level, subdirectories still do not, linked worktrees collapse to the main repo, and `feature/x` keeps its slash. Adds `lib/paths.test.mjs` covering separator style, drive-letter case and UNC paths. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
git rev-parse --path-format=absoluteprints POSIX-style absolute paths even on Windows (D:/repo/sub), whilerealpathSync()and the session cwds recorded by pi are native (D:\repo\sub).resolveProject()compared the two with===:On Windows that comparison can never be true, so
isTopLevelwas permanentlyfalse. Three user-visible consequences followed:showWorktreeSwitcherrequiresisTopLevel, so Windows users only ever saw the disabled "Open repo root" hint — even standing in the root of a git repository.isWorktreeTopLevelis gated onisTopLevel, so it was also always false andprojectRootnever collapsed to the main repo. Every worktree surfaced as a separate phantom project row.listWorktrees()returned slash-style paths that never matchedselectedCwd, sow.path === selectedCwdinSessionSidebar.tsxalways failed.None of this reproduces on macOS or Linux, where git's output already matches the native form.
Fix
Every path read out of git now goes through
toNativePath(), and path equality goes throughsamePath()instead of===.samePath()also folds case, since Windows paths — including the drive letter — are case-insensitive.Branch names deliberately bypass normalization:
rev-parsereturns the ref on the same output as the paths, and normalizing it would turnfeature/xintofeature\x.Consolidation
The two helpers landed in a new
lib/paths.ts, which also absorbed duplication found nearby:isWindowsAbsolutePath()existed twice, inlib/file-access.tsandlib/path-security.ts.isFilePathAllowed()was a byte-for-byte copy ofisPathWithinRoots(). It now delegates, leaving a single implementation of the access-control check rather than two copies to keep in sync.Two path conventions are kept on purpose, and
lib/paths.tsdocuments which to use where:toNativePath()— for anything reaching fs/path APIs, compared against a session cwd, or shown to the user.toSlashPath()— for internal, never-displayed bookkeeping (the allowed-roots Set keys, separator-insensitive text matching).Unifying on forward slashes was considered and rejected. The allowed-roots slash form is not load-bearing —
isPathWithinRoots()re-resolves and case-folds both sides, so either form authorizes identically — whereas worktree paths are compared against pi-recorded cwds and rendered in the sidebar, whereD:/...would be wrong for Windows users.Verification
Checked on Windows against real repositories:
isTopLevelisWorktreetruefalsefalsebefore this changefalsefalsetruetrueprojectRootnow collapses to the main repofalsefalsefeature/xkeeps its slashfalsefalseLowercase drive letters, trailing separators and forward-slash input all resolve correctly now as well.
Access control was re-checked after the consolidation: a native-form target, a slash-form target, and an out-of-root target all return the same verdicts as before.
npx tsc --noEmitandnpx eslint lib/are clean.Tests
Adds
lib/paths.test.mjs(4 tests) covering separator style, drive-letter case, UNC paths and the empty-string edge, with the platform-specific expectations guarded so the suite is meaningful on POSIX too.lib/file-access.test.mjsneeded one change. It loadedpath-security.tswith a bare dynamicimport(), which only worked while that file had no imports of its own; adding one broke resolution undernode --test. It now loads throughjitiso the module's extensionless imports resolve the way the app resolves them (moduleResolution: "bundler"). The test itself is unchanged and still passes.All 5 tests pass:
Note
npx eslint .reports 6 pre-existingreact-hooks/preserve-manual-memoizationerrors incomponents/ChatInput.tsx. They reproduce on a clean checkout ofmainand are unrelated to this change, so they are left untouched.🤖 Generated with Claude Code