Case-insensitive filesystems: differently-cased launch paths create duplicate project entries
Summary
On case-insensitive filesystems (macOS/APFS by default, Windows/NTFS by default), launching Claude Code from a differently-cased path to the same physical directory creates duplicate project entries. The project list keys projects by the literal launch-path string and dedupes case-sensitively, so ~/projects/foo and ~/Projects/foo — which are the same directory on these filesystems — show up as two separate projects.
Environment
- macOS, APFS (case-insensitive, the default). Same class of bug applies to default Windows NTFS.
- Both
cd ~/projects/fooandcd ~/Projects/fooresolve to the same physical directory; the shell/OS treats them as equal.
Steps to reproduce
- Have a project directory, e.g.
~/Projects/foo(capitalP). - Launch Claude Code once from it normally.
- On another occasion, launch from
~/projects/foo(lowercasep) — e.g. a tab-completion slip, a script, or a different shell. The OS resolves it to the same folder. - Open the projects list.
Expected
One project entry — both paths point to the same directory (verifiable: they share the same inode via stat -f %i).
Actual
Two separate project entries for the one directory. In ~/.claude.json there are two distinct top-level keys under projects:
/Users/<you>/Projects/foo ← fully populated (session history, cost, metrics)
/Users/<you>/projects/foo ← phantom; only default/onboarding fields
The desktop app renders one sidebar row per key, so the same project appears twice — one with full history, one nearly empty.
Notes
- Session history is not actually split.
~/.claude/projects/-Users-<you>-Projects-fooand the lowercase spelling resolve to the same inode, so both entries share on-disk history. The duplication is purely in the~/.claude.jsonproject registry and the UI derived from it — but it's confusing and clutters the project list. - Worktree sub-projects duplicate the same way (one lowercase phantom per worktree).
Suggested fix
Canonicalize project paths before using them as registry keys — e.g. fs.realpathSync.native() (which returns the on-disk canonical casing), or a case-insensitive comparison when the containing volume is case-insensitive. Migrating existing ~/.claude.json keys by merging case-variant duplicates into the canonical entry would clean up already-affected installs.
3 Comments
Additional detail after tracing where the duplicate is actually rooted (macOS desktop app):
The sidebar groups sessions by an
originCwdfield that the app stores per session and leaves as the raw, un-canonicalized launch-path string — even though it canonicalizescwdandworktreePathto the on-disk casing in the same record:The lowercase path is persisted in three stores, so a partial cleanup doesn't hold:
~/.claude.json->projectskeys~/Library/Application Support/Claude/claude-code-sessions/**/local_*.json->originCwd(alsocwd/worktreePathon some)Editing
~/.claude.jsonwhile the app is running is also futile — the live process re-writes its own project key on the next state flush.Suggested fix: canonicalize
originCwd(e.g.fs.realpathSync.native()) at capture time and dedupe grouping keys case-insensitively on case-insensitive volumes, plus a one-time migration that merges case-variant entries across all three stores.Concrete trigger (more actionable than manual mis-launch): the duplicate is generated by the app's own "suggested task → open in a new session" (spawn-task) feature, not by the user typing a lowercase path.
Reproduced state on a real install:
spawnSeed) were written with a lowercaseoriginCwd(/Users/<you>/projects/<repo>), while theircwd/worktreePathwere canonical capital — so the new session lands under a second, differently-cased project group in the sidebar.spawnSeed: { "worktreeHookBased": true }, i.e. these follow-up sessions launch through a git-worktree path. That launch path appears to capture a non-canonical project root rather thanrealpath-ing it.I could not confirm from the stored records whether the child inherits the parent session's raw path or the spawn/worktree code independently mis-cases it (
spawnSeedstores no parent pointer) — but either way the fix is the same: canonicalize the project root at session-spawn / worktree-launch time (fs.realpathSync.native()), don't propagate a raw path string. Repos that use git worktrees heavily hit this far more than typical users.Same root cause, different trigger — worth folding into whatever normalisation fixes this: on Windows, path separators produce the same duplication as case does.
C:\Users\<u>\…\repo\project-mainandC:/Users/<u>/…/repo/project-mainare one directory, and both spellings are valid and equivalent on Windows, but they are stored and rendered as two separate entries. In my install:| spelling | session records |
|---|---:|
|
C:\Users\…\repo\project-main| 116 ||
C:/Users/…/repo/project-main| 4 |Two rows in the picker, identical labels, distinguished only by a truncated path.
One addition that may matter for the fix: this is not only the
~/.claude.jsonprojectsmap. The Claude Code folder picker in the desktop app duplicates the same way, and its list is built from a different store — theoriginCwdfield of%APPDATA%\Claude\claude-code-sessions\<…>\local_*.json. I verified they are independent: pruning~/.claude.jsonto 9 project entries left the picker showing a different set of 10. So path normalisation likely needs to happen in both places, or in a shared helper.Filed the picker side with full measurements in #86181 (it also covers deleted folders never leaving the list, which is separate from this issue).