cang-ying/scripts/memory_sync.py

166 lines
6.0 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
memory_sync.py 耳耳蛋记忆库双向自动合并
背景面板与终端共用同一个 CodeBuddy 项目库但记忆有两份落点
本地记忆库 ~/.codebuddy/projects/home-ls/memory/ 蛋蛋实际读写的
仓库副本 ~/cang-ying/memory/eed/ 推送到仓库跨机器带走的
两边都可能被写终端会话写①面板会话也写①但爸爸手动或旧流程可能只更新②
过去靠手动 cp 同步一不留神就会覆盖掉另一边刚写的经验
本脚本做双向合并
- 只在一边存在的文件 补到另一边
- 两边都有且内容不同 mtime 取新的旧版备份到 BACKUP_memory_conflict/
- 内容相同 跳过
永不静默丢数据任何被覆盖的版本都先备份
用法
python3 memory_sync.py # 执行合并
python3 memory_sync.py --dry-run # 只看会怎么做,不动文件
python3 memory_sync.py --json # 输出 JSON供面板调用
"""
import filecmp
import json
import os
import shutil
import sys
import time
LOCAL_DIR = os.path.expanduser("~/.codebuddy/projects/home-ls/memory")
REPO_DIR = os.path.expanduser("~/cang-ying/memory/eed")
CONFLICT_BACKUP = os.path.expanduser("~/.codebuddy/BACKUP_memory_conflict")
# 仓库侧独有、不该被同步回本地记忆库的文件(仓库自带说明等)
REPO_ONLY = {"README.md"}
def _md_files(d):
if not os.path.isdir(d):
return set()
return {f for f in os.listdir(d) if f.endswith(".md")}
def _backup(path, tag):
"""覆盖前先备份,返回备份路径。"""
os.makedirs(CONFLICT_BACKUP, exist_ok=True)
stamp = time.strftime("%Y%m%d_%H%M%S")
dst = os.path.join(CONFLICT_BACKUP, f"{stamp}_{tag}_{os.path.basename(path)}")
shutil.copy2(path, dst)
return dst
def sync(dry_run=False):
"""执行双向合并,返回结果字典。"""
result = {"ok": True, "copied_to_repo": [], "copied_to_local": [],
"updated_local": [], "updated_repo": [], "backups": [],
"skipped": 0, "error": ""}
if not os.path.isdir(LOCAL_DIR):
result["ok"] = False
result["error"] = f"本地记忆库不存在:{LOCAL_DIR}"
return result
if not os.path.isdir(REPO_DIR):
if dry_run:
result["error"] = f"仓库副本目录不存在(将创建):{REPO_DIR}"
else:
os.makedirs(REPO_DIR, exist_ok=True)
local = _md_files(LOCAL_DIR)
repo = _md_files(REPO_DIR)
# ① 只在本地 → 补到仓库
for name in sorted(local - repo):
if not dry_run:
shutil.copy2(os.path.join(LOCAL_DIR, name), os.path.join(REPO_DIR, name))
result["copied_to_repo"].append(name)
# ② 只在仓库 → 补到本地(跳过仓库自带说明文件)
for name in sorted(repo - local):
if name in REPO_ONLY:
continue
if not dry_run:
shutil.copy2(os.path.join(REPO_DIR, name), os.path.join(LOCAL_DIR, name))
result["copied_to_local"].append(name)
# ③ 两边都有 → 比内容,不同则按 mtime 取新,旧版先备份
for name in sorted(local & repo):
lp = os.path.join(LOCAL_DIR, name)
rp = os.path.join(REPO_DIR, name)
try:
if filecmp.cmp(lp, rp, shallow=False):
result["skipped"] += 1
continue
except OSError as e:
result["error"] += f"[{name} 比对失败: {e}] "
continue
lt, rt = os.path.getmtime(lp), os.path.getmtime(rp)
if lt >= rt:
# 本地更新 → 覆盖仓库,先备份仓库那版
if not dry_run:
result["backups"].append(_backup(rp, "repo"))
shutil.copy2(lp, rp)
result["updated_repo"].append(name)
else:
# 仓库更新 → 覆盖本地,先备份本地那版
if not dry_run:
result["backups"].append(_backup(lp, "local"))
shutil.copy2(rp, lp)
result["updated_local"].append(name)
return result
def summarize(r):
"""把结果压成一句人话,给面板/唤醒卡用。"""
if not r["ok"]:
return "⚠️ 记忆同步失败:" + r["error"]
n = (len(r["copied_to_repo"]) + len(r["copied_to_local"])
+ len(r["updated_local"]) + len(r["updated_repo"]))
if n == 0:
return f"✅ 记忆已同步({r['skipped']} 份一致,无变化)"
parts = []
if r["copied_to_repo"]:
parts.append(f"新增到仓库 {len(r['copied_to_repo'])}")
if r["copied_to_local"]:
parts.append(f"拉回本地 {len(r['copied_to_local'])}")
if r["updated_repo"]:
parts.append(f"本地较新覆盖仓库 {len(r['updated_repo'])}")
if r["updated_local"]:
parts.append(f"仓库较新覆盖本地 {len(r['updated_local'])}")
s = f"✅ 记忆已合并({' · '.join(parts)}"
if r["backups"]:
s += f",旧版已备份 {len(r['backups'])}"
return s
def main():
dry = "--dry-run" in sys.argv
as_json = "--json" in sys.argv
r = sync(dry_run=dry)
if as_json:
print(json.dumps(r, ensure_ascii=False))
return 0 if r["ok"] else 1
print(("【预演,未改动任何文件】" if dry else "【记忆双向合并】"))
print(f" 本地记忆库: {LOCAL_DIR}")
print(f" 仓库副本: {REPO_DIR}")
print()
for key, label in (("copied_to_repo", "→ 补到仓库"),
("copied_to_local", "← 拉回本地"),
("updated_repo", "→ 本地较新,覆盖仓库"),
("updated_local", "← 仓库较新,覆盖本地")):
for name in r[key]:
print(f" {label}: {name}")
if r["backups"]:
print(f"\n 旧版备份 {len(r['backups'])} 份 → {CONFLICT_BACKUP}")
print(f"\n{summarize(r)}")
if r["error"]:
print(f"⚠️ {r['error']}")
return 0 if r["ok"] else 1
if __name__ == "__main__":
sys.exit(main())