claude-code - 💡(How to fix) Fix [BUG] WSL DrvFs case-fold produces silent ~/.claude/projects/ fragmentation; canonicalization fix needs migration step or existing users lose /resume continuity

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

~/.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.

Error Message

#!/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 }'

Root Cause

  • 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.

Fix Action

Fix / Workaround

Local mitigation in place (incomplete)

Code Example

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.

---

$ ~/.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).

---

#!/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
}'
RAW_BUFFERClick to expand / collapse

Preflight

  • 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

  • /resume picker 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 different cwd form 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:

  1. Canonicalize cwd before encodingfh(realpath(cwd)) instead of fh(cwd). Collapses DrvFs case-fold, symlinks, junctions, and the MSYS2 path-form cases in #54865 in one step.

  2. 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.
  3. Opt-out flag for users who depend on the current behavior — CLAUDE_PROJECTS_KEY_STRATEGY=literal-cwd or 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

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING