Skip to content

fix: compare git-emitted paths correctly on Windows - #388

Open
oudi wants to merge 1 commit into
agegr:mainfrom
oudi:fix/windows-git-path-separators
Open

fix: compare git-emitted paths correctly on Windows#388
oudi wants to merge 1 commit into
agegr:mainfrom
oudi:fix/windows-git-path-separators

Conversation

@oudi

@oudi oudi commented Aug 5, 2026

Copy link
Copy Markdown

Problem

git rev-parse --path-format=absolute prints POSIX-style absolute paths even on Windows (D:/repo/sub), while realpathSync() and the session cwds recorded by pi are native (D:\repo\sub). resolveProject() compared the two with ===:

const isTopLevel = toplevel === realCwd;

On Windows that comparison can never be true, so isTopLevel was permanently false. Three user-visible consequences followed:

  1. The worktree switcher never appeared. showWorktreeSwitcher requires isTopLevel, so Windows users only ever saw the disabled "Open repo root" hint — even standing in the root of a git repository.
  2. Linked worktrees were never grouped. isWorktreeTopLevel is gated on isTopLevel, so it was also always false and projectRoot never collapsed to the main repo. Every worktree surfaced as a separate phantom project row.
  3. The current-worktree highlight was broken. listWorktrees() returned slash-style paths that never matched selectedCwd, so w.path === selectedCwd in SessionSidebar.tsx always 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 through samePath() instead of ===. samePath() also folds case, since Windows paths — including the drive letter — are case-insensitive.

Branch names deliberately bypass normalization: rev-parse returns the ref on the same output as the paths, and normalizing it would turn feature/x into feature\x.

Consolidation

The two helpers landed in a new lib/paths.ts, which also absorbed duplication found nearby:

  • isWindowsAbsolutePath() existed twice, in lib/file-access.ts and lib/path-security.ts.
  • isFilePathAllowed() was a byte-for-byte copy of isPathWithinRoots(). 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.ts documents 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, where D:/... would be wrong for Windows users.

Verification

Checked on Windows against real repositories:

Case isTopLevel isWorktree
Repository root true false was false before this change
Subdirectory of a repo false false unchanged — no over-correction
Linked worktree true true projectRoot now collapses to the main repo
Worktree subdirectory false false branch feature/x keeps its slash
Non-git directory false false unchanged

Lowercase 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 --noEmit and npx 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.mjs needed one change. It loaded path-security.ts with a bare dynamic import(), which only worked while that file had no imports of its own; adding one broke resolution under node --test. It now loads through jiti so 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:

node --test lib/paths.test.mjs lib/file-access.test.mjs
# tests 5
# pass 5
# fail 0

Note

npx eslint . reports 6 pre-existing react-hooks/preserve-manual-memoization errors in components/ChatInput.tsx. They reproduce on a clean checkout of main and are unrelated to this change, so they are left untouched.

🤖 Generated with Claude Code

`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>
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