Skip to content

Instantly share code, notes, and snippets.

@rickvip
Last active June 2, 2026 05:46
Show Gist options
  • Select an option

  • Save rickvip/3d03ff9077d83a2528fddb0d4c637034 to your computer and use it in GitHub Desktop.

Select an option

Save rickvip/3d03ff9077d83a2528fddb0d4c637034 to your computer and use it in GitHub Desktop.
Migrate Claude Code project cache (sessions + memory + ~/.claude.json) on dir rename
#!/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