166 lines
6.0 KiB
Python
166 lines
6.0 KiB
Python
|
|
#!/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())
|