restore_p1.py 6.14 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
从 Claude Code 会话记录里恢复被误删的 P1 源码。

## 背景

`tools/p2_import_legacy.py` 在拷贝老代码前会 `shutil.rmtree(目标目录)` 以保证
可重复执行。但 `parking-module-system` 与 `parking-module-api` 这两个模块
**既有 P1 手写的新代码、又要接收老代码**,于是 P1 写的 84 个类被连带删掉了
(只剩 target/classes 里的 .class)。

## 恢复来源

P1 的代码是在同一个 Claude Code 会话里用 Write/Edit 工具写的,
其完整内容按顺序记录在会话 jsonl 里。本脚本按记录顺序重放:
`Write` 置内容、`Edit` 做替换,得到每个文件在被删前的最终内容。

另有 4 个 `parking-module-api` 的文件是当时用 `sed` 从老 `zteits-api`
复制+改包名生成的,内容可确定性重算,不在 jsonl 里,由
`regen_api_apibase.sh` 负责。

输出先落到 `tools/recover/out/`(不直接写源码树),核对无误后再拷回。

用法:python3 -I tools/recover/restore_p1.py <会话.jsonl> [--apply]
"""

import argparse
import collections
import json
import os
import sys

CLOUD = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
STAGE = os.path.join(CLOUD, "tools/recover/out")

# 只恢复这两个模块下 P1 手写的新代码(老代码的包名是 com.zteits.{park,order,...}
# 与 com.clouds.common,不会命中这里)
PREFIXES = (
    "parking-module-system/src/main/java/com/zteits/parking",
    "parking-module-api/src/main/java/com/zteits/parking",
)


def collect(jsonl):
    """按出现顺序收集目标文件的操作。

    ⚠️ 必须剔除**失败**的操作。会话里 Edit 报错(old_string 对不上)后助手会重试,
    若把失败的也重放一遍,重放内容就会和原始历史分叉。失败与否只能看
    `tool_result.is_error`,所以要先扫一遍拿到失败集合,再过滤。
    """
    ops = collections.OrderedDict()
    lines = []
    with open(jsonl, encoding="utf-8", errors="replace") as f:
        for line in f:
            if '"tool_use"' in line or '"tool_result"' in line:
                lines.append(line)

    failed = set()
    for line in lines:
        if '"tool_result"' not in line:
            continue
        try:
            rec = json.loads(line)
        except Exception:
            continue
        content = (rec.get("message") or {}).get("content")
        if not isinstance(content, list):
            continue
        for c in content:
            if isinstance(c, dict) and c.get("type") == "tool_result" and c.get("is_error"):
                if c.get("tool_use_id"):
                    failed.add(c["tool_use_id"])

    skipped_reads = skipped_failed = 0
    for line in lines:
        if '"tool_use"' not in line:
            continue
        try:
            rec = json.loads(line)
        except Exception:
            continue
        content = (rec.get("message") or {}).get("content")
        if not isinstance(content, list):
            continue
        for c in content:
            if not isinstance(c, dict) or c.get("type") != "tool_use":
                continue
            name = c.get("name")
            inp = c.get("input") or {}
            p = inp.get("file_path")
            if not isinstance(p, str):
                continue
            if not any(pp in p for pp in PREFIXES):
                continue
            if name == "Read":
                skipped_reads += 1
                continue
            if c.get("id") in failed:
                skipped_failed += 1
                continue
            if name not in ("Write", "Edit"):
                print(f"  !! 未处理的操作类型 {name} on {p}")
                continue
            ops.setdefault(p, []).append((name, inp))
    return ops, skipped_reads, skipped_failed


def replay(path, actions):
    txt = None
    for i, (name, inp) in enumerate(actions):
        if name == "Write":
            txt = inp.get("content", "")
        else:  # Edit
            old, new = inp.get("old_string"), inp.get("new_string")
            if txt is None:
                raise SystemExit(f"!! {path}: 第 {i} 个操作是 Edit 但没有前置 Write")
            if old is None or new is None:
                raise SystemExit(f"!! {path}: Edit 缺少 old_string/new_string")
            cnt = txt.count(old)
            if cnt == 0:
                raise SystemExit(f"!! {path}: Edit #{i} 的 old_string 在重放内容里找不到")
            if inp.get("replace_all"):
                txt = txt.replace(old, new)
            else:
                if cnt > 1:
                    print(f"  ! {path}: Edit #{i} 匹配 {cnt} 处,按语义只替换第一处")
                txt = txt.replace(old, new, 1)
    return txt


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("jsonl")
    ap.add_argument("--apply", action="store_true",
                    help="恢复后直接写回源码树(默认只落 out/ 暂存区)")
    args = ap.parse_args()

    ops, skipped_reads, skipped_failed = collect(args.jsonl)
    print(
        f"命中文件 {len(ops)} 个"
        f"(忽略 Read {skipped_reads} 次 / 失败操作 {skipped_failed} 次)"
    )

    failures = []
    written = 0
    for p, actions in ops.items():
        rel = p.split("parking-cloud/", 1)[-1]
        try:
            txt = replay(rel, actions)
        except SystemExit as e:
            failures.append(str(e))
            continue
        if txt is None:
            failures.append(f"!! {rel}: 没有 Write 内容")
            continue
        dst = os.path.join(STAGE, rel)
        os.makedirs(os.path.dirname(dst), exist_ok=True)
        with open(dst, "w", encoding="utf-8") as f:
            f.write(txt)
        written += 1
        if args.apply:
            real = os.path.join(CLOUD, rel)
            os.makedirs(os.path.dirname(real), exist_ok=True)
            with open(real, "w", encoding="utf-8") as f:
                f.write(txt)

    print(f"恢复 {written} 个文件 -> {os.path.relpath(STAGE, CLOUD)}/")
    if args.apply:
        print("(--apply:已同时写回源码树)")
    for f in failures:
        print(f)


if __name__ == "__main__":
    main()