[BUG] WSL DrvFs case-fold produces silent ~/.claude/projects/ fragmentation; canonicalization fix needs migration step or existing users lose /resume continuity
Preflight
- [x] Searched existing issues — this is a third distinct surface of the root cause already documented in #54865, #56173, #46342, #46522. Filing separately because the WSL DrvFs case-fold reproducer and the migration requirement aren't covered in any of those threads.
Summary
~/.claude/projects/<encoded-cwd>/ is keyed on the literal cwd string at launch — fh(cwd), the plain [^a-zA-Z0-9] → '-' replacement identified in #54865 — without running realpath() first. On filesystems that case-fold at the FS layer but preserve case at the string layer (WSL DrvFs over NTFS; macOS HFS+/APFS in default config), the same on-disk directory accumulates multiple ~/.claude/projects/ entries silently.
This is the same root cause as:
- #54865 — Windows MSYS2
/u/↔U:\↔U:/↔ UNC encoding nondeterminism - #56173 — Windows junction/symlink aliasing (stale)
- #46342 — symlinked entries under
~/.claude/projects/ - #46522 — sessions hidden after project rename/move
This issue adds (a) a WSL DrvFs reproducer that isn't in any of those threads, and (b) an argument that a canonicalization fix alone is insufficient — without migration, existing users lose /resume continuity at the moment the fix lands.
Reproducer (WSL + DrvFs)
cd /mnt/c/Users/$USER/02_Areas/AI && claude # writes ~/.claude/projects/-mnt-c-Users-...-02-Areas-AI/
cd /mnt/c/Users/$USER/02_Areas/ai && claude # writes ~/.claude/projects/-mnt-c-Users-...-02-Areas-ai/
# Same on-disk directory. Two project dirs. /resume only sees one.
NTFS case-folds at the FS layer, so both cds land in the same inode. DrvFs preserves the case the user typed in $PWD. fh($PWD) is case-sensitive at the string layer, so the two launches diverge into separate history trees.
Concrete artifact
Running a read-only audit on my install:
$ ~/.claude/scripts/claude-projects-audit.sh
Duplicate-history audit for /home/robert/.claude/projects
Resolver: walk filesystem (dash = '/' or '_'), then realpath()
Today: 2026-05-25 10:10:12
=== Resolves to: PARTIAL:/mnt/c/users/rhamm/02_areas/ai (2 members) ===
[PARTIAL ] -mnt-c-Users-rhamm-02-Areas-AI sessions=1 latest=2026-05-01 17:42:12
[PARTIAL ] -mnt-c-Users-rhamm-02-Areas-ai sessions=2 latest=2026-05-01 17:42:09
Summary: 9 project dir(s) scanned, 1 duplicate group(s) covering 2 dir(s).
The audit walks the filesystem instead of trying to invert fh() directly because the encoding is lossy — both / and _ map to -, so a naive '-' → '/' decoder mishandles any path containing underscores. The script tries longest-underscore-join first at each step, backtracking on miss.
<details>
<summary>Full audit script (bash + embedded python, ~170 lines)</summary>
#!/usr/bin/env bash
# claude-projects-audit.sh
#
# Read-only audit of ~/.claude/projects/ for fragmented per-cwd history dirs.
#
# Claude Code keys each project's history directory on the literal cwd string
# at launch (not realpath(cwd)). On WSL+DrvFs, where NTFS is case-insensitive
# at the FS layer but case-sensitive at the string layer, the same on-disk
# directory can be addressed by multiple cwd strings, silently splitting
# history. Symlinks and other normalizations cause the same class of bug.
#
# Encoding scheme used by Claude Code (best understood from observed data,
# *not* a clean bijection):
# - Leading '/' -> leading '-'
# - Each '/' -> '-'
# - Each '_' -> '-' (!)
# - Case preserved
# Because both '/' and '_' become '-', the encoded form is ambiguous and
# the only reliable decoder consults the filesystem.
set -euo pipefail
PROJECTS_DIR="${HOME}/.claude/projects"
if [[ ! -d "$PROJECTS_DIR" ]]; then
echo "No projects dir at $PROJECTS_DIR" >&2
exit 1
fi
encoded_list=()
for d in "$PROJECTS_DIR"/*/; do
[[ -d "$d" ]] || continue
encoded_list+=("$(basename "$d")")
done
if (( ${#encoded_list[@]} == 0 )); then
echo "No project dirs found under $PROJECTS_DIR" >&2
exit 0
fi
resolve_tsv=$(python3 - "${encoded_list[@]}" <<'PY'
import os, sys
def walk(prefix, parts, i):
"""Resolve parts[i:] as far as the filesystem allows.
Returns (best_prefix, parts_unconsumed). When parts_unconsumed is
empty, the path resolved fully. Otherwise best_prefix is the
deepest existing directory we could reach and parts_unconsumed are
the tokens we couldn't place (typically because they sit beyond a
broken or unreadable symlink — common on WSL DrvFs when the link
points across the WSL/Windows boundary).
"""
if i >= len(parts):
return prefix, []
best = (prefix, parts[i:])
best_consumed = 0
for n in range(len(parts) - i, 0, -1):
seg = '_'.join(parts[i:i+n])
cand = os.path.join(prefix or '/', seg)
try:
is_dir = os.path.isdir(cand)
except OSError:
is_dir = False
if not is_dir:
continue
sub_prefix, sub_remaining = walk(cand, parts, i + n)
consumed = (len(parts) - i) - len(sub_remaining)
if consumed > best_consumed:
best = (sub_prefix, sub_remaining)
best_consumed = consumed
if not sub_remaining:
return best
return best
for enc in sys.argv[1:]:
enc = enc.strip()
if not enc:
continue
parts = enc.lstrip('-').split('-')
resolved_prefix, remaining = walk('', parts, 0)
if not remaining:
try:
key = os.path.realpath(resolved_prefix)
except OSError:
key = resolved_prefix
status = 'RESOLVED'
else:
partial = os.path.join(resolved_prefix or '/', '/'.join(remaining))
key = 'PARTIAL:' + partial.casefold()
status = 'PARTIAL'
print(f"{enc}\t{key}\t{status}")
PY
)
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
while IFS=$'\t' read -r enc key status; do
[[ -z "$enc" ]] && continue
dir="$PROJECTS_DIR/$enc"
sessions=$(find "$dir" -maxdepth 1 -name '*.jsonl' -type f 2>/dev/null | wc -l)
latest_epoch=$(find "$dir" -type f -printf '%T@\n' 2>/dev/null | sort -rn | head -n1)
if [[ -n "$latest_epoch" ]]; then
latest=$(date -d "@${latest_epoch%.*}" '+%Y-%m-%d %H:%M:%S')
else
latest="(empty)"
fi
printf '%s\t%s\t%d\t%s\t%s\n' "$key" "$enc" "$sessions" "$latest" "$status" >> "$TMP"
done <<< "$resolve_tsv"
echo "Duplicate-history audit for $PROJECTS_DIR"
echo "Resolver: walk filesystem (dash = '/' or '_'), then realpath()"
echo "Today: $(date '+%Y-%m-%d %H:%M:%S')"
echo
dup_groups=0
dup_dirs=0
while IFS= read -r key; do
count=$(awk -F'\t' -v k="$key" '$1==k' "$TMP" | wc -l)
if (( count > 1 )); then
dup_groups=$((dup_groups + 1))
dup_dirs=$((dup_dirs + count))
echo "=== Resolves to: $key ($count members) ==="
awk -F'\t' -v k="$key" '$1==k {
printf " [%-8s] %-50s sessions=%-4s latest=%s\n", $5, $2, $3, $4
}' "$TMP" | sort
echo
fi
done < <(cut -f1 "$TMP" | sort -u)
total=$(wc -l < "$TMP")
echo "Summary: $total project dir(s) scanned, $dup_groups duplicate group(s) covering $dup_dirs dir(s)."
if (( dup_groups == 0 )); then
echo "No fragmentation detected."
fi
echo
echo "All entries (key -> encoded, sessions, latest, status):"
sort "$TMP" | awk -F'\t' '{
printf " %-60s %-46s sessions=%-4s latest=%-19s [%s]\n", $1, $2, $3, $4, $5
}'
</details>
Consequences
/resumepicker only shows the active tree's sessions — the user can't see what they wrote yesterday.- Per-project memory (
~/.claude/projects/<encoded>/memory/) lives in exactly one tree; entering the project via a differentcwdform gets a memory-less Claude that silently drops the user's durable context. - Fragmentation is silent. There is no warning, no symptom until the user asks "where is yesterday's conversation?" — and by then the history has been re-keyed.
Local mitigation in place (incomplete)
A SessionStart hook compares $PWD to realpath($PWD) and warns to stderr on mismatch. This catches the symlink class but is purely advisory — it can't redirect to the canonical dir, and it doesn't catch the DrvFs case-fold (because $PWD and realpath agree at the string level even when the FS doesn't). Any pre-launch wrapper would also miss IDE / ssh -t / bash -c / sudo launches. The fix has to be inside the binary.
Suggested fix
A one-line realpath() in fh() is necessary but not sufficient. The shipping fix must include both:
- Canonicalize cwd before encoding —
fh(realpath(cwd))instead offh(cwd). Collapses DrvFs case-fold, symlinks, junctions, and the MSYS2 path-form cases in #54865 in one step.
- Migrate existing non-canonical project dirs on first launch after the fix — without this, every existing user's history becomes invisible the moment the fix ships, because their old non-canonical encoded names won't match
fh(realpath(cwd))anymore. Suggested migration shape:
- On first launch with the fixed binary, scan
~/.claude/projects/*, resolve each encoded name to its canonical filesystem identity, and group by canonical key. - For each duplicate group, prompt the user to merge (concatenate session files into the canonical dir) or symlink the non-canonical entries to the canonical one.
- For single-member non-canonical entries, rename in place.
- Print a summary of what moved and what's preserved.
- Opt-out flag for users who depend on the current behavior —
CLAUDE_PROJECTS_KEY_STRATEGY=literal-cwdor similar. (Probably no one needs this, but including it preempts pushback in review.)
Cross-references
- #54865 — same root cause, Windows MSYS2 surface, identifies
fh()and suggests canonicalization but no migration plan - #56173 — same root cause, Windows junction/symlink surface (currently labeled stale)
- #46342 — symlinked
~/.claude/projects/entries - #46522 — sessions hidden after dir rename/move (downstream symptom of same encoding lock-in)
- #40946 — non-ASCII encoding collisions (related fh() weakness)
- #61049 — Japanese-named directory collisions (related fh() weakness)
If the maintainers are already planning to consolidate these, please treat this as a vote for "the fix must include a migration pass, not just realpath()."
Environment
- Claude Code via WSL2 (Ubuntu, Windows 11, NTFS via DrvFs)
- Audit script lives at
~/.claude/scripts/claude-projects-audit.sh
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗