Case-insensitive filesystems: differently-cased launch paths create duplicate project entries

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jul 22, 2026

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/foo and cd ~/Projects/foo resolve to the same physical directory; the shell/OS treats them as equal.

Steps to reproduce

  1. Have a project directory, e.g. ~/Projects/foo (capital P).
  2. Launch Claude Code once from it normally.
  3. On another occasion, launch from ~/projects/foo (lowercase p) — e.g. a tab-completion slip, a script, or a different shell. The OS resolves it to the same folder.
  4. 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-foo and the lowercase spelling resolve to the same inode, so both entries share on-disk history. The duplication is purely in the ~/.claude.json project 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.

View original on GitHub ↗

3 Comments

dstoll7 · 20 days ago

Additional detail after tracing where the duplicate is actually rooted (macOS desktop app):

The sidebar groups sessions by an originCwd field that the app stores per session and leaves as the raw, un-canonicalized launch-path string — even though it canonicalizes cwd and worktreePath to the on-disk casing in the same record:

.cwd          = /Users/<you>/Projects/foo/...   <- canonicalized
.originCwd     = /Users/<you>/projects/foo        <- raw launch casing; drives sidebar grouping

The lowercase path is persisted in three stores, so a partial cleanup doesn't hold:

  1. ~/.claude.json -> projects keys
  2. ~/Library/Application Support/Claude/claude-code-sessions/**/local_*.json -> originCwd (also cwd/worktreePath on some)
  3. Electron Local Storage (leveldb)

Editing ~/.claude.json while 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.

dstoll7 · 19 days ago

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:

  • 12 spawned sessions (records carrying spawnSeed) were written with a lowercase originCwd (/Users/<you>/projects/<repo>), while their cwd/worktreePath were canonical capital — so the new session lands under a second, differently-cased project group in the sidebar.
  • The spawn records show 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 than realpath-ing it.
  • Effect compounds: once one session's project root is recorded lowercase, every follow-up task spawned while working there piles into the phantom group, so it looks like a persistent, self-regenerating duplicate.

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 (spawnSeed stores 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.

mbbi-rep · 18 days ago

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-main and C:/Users/<u>/…/repo/project-main are 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.json projects map. The Claude Code folder picker in the desktop app duplicates the same way, and its list is built from a different store — the originCwd field of %APPDATA%\Claude\claude-code-sessions\<…>\local_*.json. I verified they are independent: pruning ~/.claude.json to 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).