Last active
June 2, 2026 05:46
-
-
Save rickvip/3d03ff9077d83a2528fddb0d4c637034 to your computer and use it in GitHub Desktop.
Migrate Claude Code project cache (sessions + memory + ~/.claude.json) on dir rename
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # 迁移某项目目录的 Claude Code 缓存(会话历史 + memory + ~/.claude.json 项目配置)到新目录名。 | |
| # 用法: bash migrate-claude-project.sh <old_dir> <new_dir> | |
| # | |
| # 重要:请在【退出所有该项目的 claude 会话后】、用普通终端运行本脚本。 | |
| # 否则被改名目录的 cwd 会悬空,且 ~/.claude.json 可能被退出时回写覆盖。 | |
| # | |
| # 相比早期版本修复的两个关键坑: | |
| # 1) 路径编码是【有损】的:Claude 把路径里每个非字母数字字符都替换成 '-', | |
| # 所以"结构相同、字符数相同"的不同路径(尤其中文路径,每个汉字 = 一个 '-') | |
| # 会编码成同一个缓存目录而发生【碰撞】——一个缓存目录里可能混着好几个项目 | |
| # 的会话。本脚本因此【按会话内部记录的 cwd 过滤】,只迁移真正属于 OLD 的会话, | |
| # 不会把碰撞邻居(别的项目)的历史也搬过去。 | |
| # 2) Claude 列会话时按 jsonl 内部记录的 cwd 匹配当前目录,纯文件拷贝(不改 cwd) | |
| # 会导致在新目录里 claude 看不到历史。所以拷贝后必须把会话内部的 cwd | |
| # (含 worktree / 子目录前缀)从 OLD 改写为 NEW。 | |
| # 3) OLD 与 NEW 可能编码相同(如中文改成"同字数"的另一中文名、或空格↔连字符), | |
| # 此时源缓存目录 == 目标缓存目录。脚本改用"原子写(临时文件 + os.replace)", | |
| # 在同一目录下【就地改写 cwd】而非复制,避免把源文件截断清空导致数据丢失。 | |
| set -euo pipefail | |
| [ $# -eq 2 ] || { echo "用法: bash $0 <old_dir> <new_dir>"; exit 2; } | |
| # 解析绝对路径(不要求目录已存在:NEW 通常还不存在 → 用 父目录 realpath + basename 推算) | |
| abspath() { | |
| local p="$1"; [ "${p#/}" = "$p" ] && p="$PWD/$p" | |
| local dir; dir="$(cd "$(dirname "$p")" 2>/dev/null && pwd)" \ | |
| || { echo "✗ 父目录不存在: $(dirname "$p")" >&2; exit 1; } | |
| printf '%s/%s\n' "${dir%/}" "$(basename "$p")" | |
| } | |
| OLD="$(abspath "$1")"; NEW="$(abspath "$2")" | |
| [ "$OLD" != "$NEW" ] || { echo "✗ 新旧路径相同"; exit 1; } | |
| cd "$HOME" # 确保后续 cwd 不在待改名目录内 | |
| enc() { python3 -c "import re,sys;print(re.sub(r'[^a-zA-Z0-9]','-',sys.argv[1]))" "$1"; } | |
| PROJ="$HOME/.claude/projects"; CJSON="$HOME/.claude.json" | |
| E_OLD="$(enc "$OLD")"; E_NEW="$(enc "$NEW")" | |
| echo "OLD=$OLD"; echo "NEW=$NEW"; echo "编码: $E_OLD → $E_NEW"; echo | |
| # 1) 重命名项目目录(仅当还没改名) | |
| if [ -d "$OLD" ] && [ ! -e "$NEW" ]; then | |
| mkdir -p "$(dirname "$NEW")"; mv "$OLD" "$NEW"; echo "✓ 已重命名项目目录" | |
| elif [ ! -d "$OLD" ] && [ -d "$NEW" ]; then | |
| echo "· 项目目录已是新名(跳过 mv)" | |
| else | |
| echo "✗ 目录状态异常(期望 OLD 在且 NEW 不在,或 OLD 不在且 NEW 在)"; exit 1 | |
| fi | |
| # 2) 迁移会话历史 + memory(copy 非 move,旧的留作备份) | |
| # · 只迁移内部 cwd 属于 OLD(== OLD 或在 OLD/ 之下)的会话 → 规避编码碰撞 | |
| # · 拷贝时把会话内部 cwd 从 OLD 改写为 NEW(含 worktree / 子目录前缀) | |
| if [ -d "$PROJ/$E_OLD" ]; then | |
| mkdir -p "$PROJ/$E_NEW" | |
| python3 - "$OLD" "$NEW" "$PROJ/$E_OLD" "$PROJ/$E_NEW" <<'PY' | |
| import json, os, shutil, sys | |
| OLD, NEW, SRC, DST = sys.argv[1:5] | |
| SAME = os.path.realpath(SRC) == os.path.realpath(DST) # OLD/NEW 编码相同 → 同一缓存目录 | |
| def remap(v): | |
| """cwd 改写:== OLD → NEW;在 OLD/ 之下 → 换前缀;其它(含未移动的兄弟目录)保持不变。""" | |
| if not isinstance(v, str): return None | |
| if v == OLD: return NEW | |
| if v.startswith(OLD + "/"): return NEW + v[len(OLD):] | |
| return None | |
| def primary_cwd(path): | |
| """会话归属 = 文件里第一条带 cwd 的记录。""" | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: continue | |
| try: o = json.loads(line) | |
| except Exception: continue | |
| if isinstance(o, dict) and o.get("cwd"): return o["cwd"] | |
| except Exception: pass | |
| return None | |
| def belongs(cwd): | |
| return cwd is not None and (cwd == OLD or cwd.startswith(OLD + "/")) | |
| def rewrite_jsonl(src, dst): | |
| # 原子写:先写 dst.tmp 再 os.replace。src==dst(编码相同)时即"就地改写",绝不截断源文件。 | |
| os.makedirs(os.path.dirname(dst), exist_ok=True) | |
| tmp = dst + ".tmp" | |
| with open(src, encoding="utf-8") as fin, open(tmp, "w", encoding="utf-8") as fout: | |
| for line in fin: | |
| s = line.rstrip("\n") | |
| if not s: fout.write(line); continue | |
| try: o = json.loads(s) | |
| except Exception: | |
| fout.write(line if line.endswith("\n") else line + "\n"); continue | |
| nv = remap(o.get("cwd")) if isinstance(o, dict) else None | |
| if nv is not None: | |
| o["cwd"] = nv | |
| fout.write(json.dumps(o, ensure_ascii=False) + "\n") | |
| else: | |
| fout.write(line if line.endswith("\n") else line + "\n") | |
| os.replace(tmp, dst) | |
| def rewrite_meta(src, dst): | |
| os.makedirs(os.path.dirname(dst), exist_ok=True) | |
| try: o = json.load(open(src, encoding="utf-8")) | |
| except Exception: | |
| if os.path.abspath(src) != os.path.abspath(dst): shutil.copy2(src, dst) | |
| return | |
| nv = remap(o.get("cwd")) if isinstance(o, dict) else None | |
| if nv is not None: o["cwd"] = nv | |
| tmp = dst + ".tmp" | |
| with open(tmp, "w", encoding="utf-8") as f: | |
| json.dump(o, f, ensure_ascii=False, indent=2) | |
| os.replace(tmp, dst) | |
| def copy_tree_rewrite(srcdir, dstdir): | |
| for root, _, files in os.walk(srcdir): | |
| rel = os.path.relpath(root, srcdir) | |
| for fn in files: | |
| sp = os.path.join(root, fn) | |
| dp = os.path.join(dstdir, fn) if rel == "." else os.path.join(dstdir, rel, fn) | |
| if fn.endswith(".jsonl"): rewrite_jsonl(sp, dp) | |
| elif fn.endswith(".meta.json"): rewrite_meta(sp, dp) | |
| elif os.path.abspath(sp) != os.path.abspath(dp): | |
| os.makedirs(os.path.dirname(dp), exist_ok=True); shutil.copy2(sp, dp) | |
| # 扫描顶层会话,按归属分类 | |
| matched = []; skipped = {} | |
| for name in sorted(os.listdir(SRC)): | |
| if not name.endswith(".jsonl"): continue | |
| cwd = primary_cwd(os.path.join(SRC, name)) | |
| if belongs(cwd): matched.append(name[:-6]) | |
| else: skipped[name[:-6]] = cwd | |
| # 碰撞提示 | |
| others = sorted({c for c in skipped.values() if c}) | |
| if others: | |
| print("⚠ 检测到编码碰撞:该缓存目录还混有其它项目的会话,已自动跳过(不会被搬到新目录):") | |
| for c in others: print(f" · {c}") | |
| if SAME: | |
| print("· OLD/NEW 编码相同(同一缓存目录),改为【就地改写 cwd】,不复制") | |
| # 迁移匹配的会话(jsonl + 同名子目录),并改写内部 cwd | |
| for uid in matched: | |
| rewrite_jsonl(os.path.join(SRC, uid + ".jsonl"), os.path.join(DST, uid + ".jsonl")) | |
| sd = os.path.join(SRC, uid) | |
| if os.path.isdir(sd): copy_tree_rewrite(sd, os.path.join(DST, uid)) | |
| print(f"✓ 已迁移 {len(matched)} 个会话(跳过碰撞邻居 {len(skipped)} 个),并改写内部 cwd → NEW") | |
| # memory/:按【目录】共享存储,整体拷贝(碰撞时它本就是多项目共用,副本可能含邻居记忆) | |
| msrc = os.path.join(SRC, "memory"); mdst = os.path.join(DST, "memory") | |
| if os.path.isdir(msrc) and os.path.realpath(msrc) != os.path.realpath(mdst): | |
| shutil.copytree(msrc, mdst, dirs_exist_ok=True) | |
| note = "(注意:原目录存在碰撞,memory 为多项目共享,副本可能含邻居记忆)" if skipped else "" | |
| print("✓ 已拷贝 memory/ " + note) | |
| elif os.path.isdir(msrc): | |
| print("· memory/ 已在原位(同一缓存目录),无需拷贝") | |
| PY | |
| echo "✓ 会话历史 + memory 已迁移 → $E_NEW" | |
| else | |
| echo "· 旧项目无 Claude 会话缓存($PROJ/$E_OLD 不存在),跳过" | |
| fi | |
| # 3) 迁移 ~/.claude.json projects 键(先备份;只新增新键,不删旧键) | |
| if [ -f "$CJSON" ]; then | |
| cp "$CJSON" "$CJSON.bak.$(date +%s)" | |
| python3 - "$OLD" "$NEW" "$CJSON" <<'PY' | |
| import json,os,sys | |
| old,new,p=sys.argv[1:4] | |
| d=json.load(open(p)); projs=d.get("projects",{}) | |
| if old in projs and new not in projs: | |
| projs[new]=projs[old] | |
| tmp=p+".tmp" | |
| with open(tmp,"w") as f: | |
| json.dump(d,f,indent=2,ensure_ascii=False) | |
| os.replace(tmp,p) # 原子替换:要么旧文件、要么新文件,绝不留半截损坏文件 | |
| print("✓ ~/.claude.json projects 键已迁移") | |
| else: | |
| print("· 跳过 projects 键迁移(旧键缺失或新键已存在)") | |
| PY | |
| else echo "· 无 ~/.claude.json,跳过"; fi | |
| # 4) 自动验证(按【归属】计数:碰撞场景下新数会小于旧目录总数,属正常) | |
| echo; echo "=== 验证 ==="; ok=1 | |
| if [ -d "$PROJ/$E_OLD" ]; then | |
| counts="$(python3 - "$OLD" "$NEW" "$PROJ/$E_OLD" "$PROJ/$E_NEW" <<'PY' | |
| import json,os,sys | |
| OLD,NEW,SRC,DST=sys.argv[1:5] | |
| def primary_cwd(p): | |
| try: | |
| for line in open(p,encoding="utf-8"): | |
| line=line.strip() | |
| if not line: continue | |
| try: o=json.loads(line) | |
| except Exception: continue | |
| if isinstance(o,dict) and o.get("cwd"): return o["cwd"] | |
| except Exception: pass | |
| return None | |
| def bel(root,c): return c is not None and (c==root or c.startswith(root+"/")) | |
| nn=sum(1 for n in os.listdir(DST) if n.endswith(".jsonl") and bel(NEW,primary_cwd(os.path.join(DST,n)))) if os.path.isdir(DST) else 0 | |
| # 同一缓存目录(编码相同)时已就地改写,源即目标,直接以 NEW 计数为准 | |
| if os.path.realpath(SRC)==os.path.realpath(DST): | |
| no=nn | |
| else: | |
| no=sum(1 for n in os.listdir(SRC) if n.endswith(".jsonl") and bel(OLD,primary_cwd(os.path.join(SRC,n)))) | |
| print(no, nn) | |
| PY | |
| )" | |
| no="${counts% *}"; nn="${counts#* }" | |
| echo "属于本项目的 transcript 数 旧=$no 新=$nn $([ "$no" = "$nn" ] && echo ✓ || { echo ✗; ok=0; })" | |
| [ -d "$PROJ/$E_NEW/memory" ] && echo "memory/ 已迁移 ✓" || echo "· 无 memory/(旧项目本就可能没有)" | |
| fi | |
| [ -d "$NEW" ] && echo "新项目目录存在 ✓" || { echo "新项目目录缺失 ✗"; ok=0; } | |
| [ -f "$CJSON" ] && python3 -c "import json,sys;d=json.load(open(sys.argv[1]));print('projects 新键存在 ✓' if sys.argv[2] in d.get('projects',{}) else '· projects 无新键(旧项目无配置)')" "$CJSON" "$NEW" | |
| echo | |
| [ "$ok" = 1 ] && echo "✅ 完成。cd '$NEW' && claude --continue 验证。" \ | |
| || echo "⚠ 有校验未通过,看上面 ✗;旧数据/备份均未删,可回滚。" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment