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

Resolved 💬 1 comment Opened May 25, 2026 by Rhammang Closed Jun 25, 2026

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

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

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗