diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md new file mode 100644 index 0000000..3282057 --- /dev/null +++ b/UPSTREAM_SYNC.md @@ -0,0 +1,206 @@ +# UPSTREAM_SYNC · 上游同步基线文档 + +> 本文件是 PineAgents(agent-desktop)与上游 **QwenPaw** 的同步基线、裁决规则与变更记录的活文档。 +> 合规署名与全部修改清单见 `NOTICE`;本文件聚焦「如何把上游更新科学、稳健地合进来」。 + +--- + +## 1. 基线信息 + +| 项 | 值 | +|----|-----| +| 上游仓库 | [agentscope-ai/QwenPaw](https://github.com/agentscope-ai/QwenPaw)(Apache 2.0) | +| 基线版本 | 2.1.0b1(tag `v2.1.0-beta.1` = commit `6f8e52a48c32ea06ec726834ada3e4c753e5f964`) | +| 当前目标版本 | 2.2.0(tag `v2.2.0` = commit `ae092fdca62164b467dee998893cbc450b97d59f`) | +| 本仓库 | agent-desktop(PineAgents,闭源商业软件) | +| 基线差异跨度 | `v2.1.0-beta.1..v2.2.0` = 328 commits(70 feat) | + +> ⚠️ 基线必须记录**精确 commit hash**,禁止只写版本字符串(`2.1.0b1` 这种无法唯一定位)。 + +--- + +## 2. 三层代码组织(治本规范) + +任何新代码按以下分层落位,目标是「桥接层 + 自研层」几乎零冲突: + +| 层 | 定位 | 规则 | 目录 | +|----|------|------|------| +| **上游区 Upstream** | 跟上游同步的源码 | 只允许「采用上游 / 三向合并后以上游为主」;禁止写入云超服业务逻辑 | `console/src/api`、`console/src/pages/Chat`、`console/src/pages/Settings`(上游页面)、`src/`(后端) | +| **桥接层 Bridge** | 上游与自研的扩展点 | 需要动上游行为时优先走注册表/适配器/包装组件,不改上游文件 | `console/src/layouts/registry/`、`console/src/plugins/`、`console/src/bridge/`(新建) | +| **自研区 Custom** | 云超服独有能力 | 独立目录,零依赖上游内部实现,经桥接层挂入 | `console/src/pages/portal/`、`pages/Operator/`、`pages/Communication/`、`pages/Agent/Workspace/`、`auth/`、`api/modules/*(自研)` | + +--- + +## 3. 同步流程(每个上游 release 的同步窗口,建议 1-2 周内) + +1. **拉取 tag**:上游发布后锁定 commit hash,更新本文件「基线信息」。 +2. **三向 diff**:运行 `python scripts/sync-diff.py`(见 §5),输出文件级分类清单。 +3. **分类处理**: + - `upstream_only`(仅上游改)→ 直接采用上游版本; + - `local_only`(仅本地改)→ 保留本地; + - `both`(双改)→ 按 §4 裁决规则人工三向合并; + - `only_head`(上游新增)→ 引入;`only_local`(本地新增)→ 保留; + - `deleted_upstream` / `deleted_local` → 决策是否跟随删除/恢复。 +4. **特征核验**:对已声明功能增量做符号级断言(如 `grep card_auto_layout`),确认功能真的落地。 +5. **回归门**:`yarn build` + console 单元测试 + 后端 pytest + 关键路径 smoke(登录/聊天/文件/市场)。 +6. **登记归档**:更新 `NOTICE` 与本文件「变更记录」,同步独立 PR 必须引用 sync-diff 报告。 + +--- + +## 4. 双改文件裁决规则(2026-09-04 拍板) + +**云端逻辑以云超服为准,本地与新增功能跟随上游。** + +- **A 桶 · 云超服为准(云端/平台业务)**:登录认证、角色权限、布局与侧栏、路由注册表、门户入口、插件市场、审批、Agent/技能/环境/MCP/安全等平台能力;吸收上游纯增量,但云超服主体逻辑不被覆盖。 +- **B 桶 · 上游为准(本地/功能演进)**:聊天、模型选择、后台任务、Token/上下文统计、Agent 统计、ReMe、技能池逻辑、插件加载、Coding/Files、Inbox(上游 Mailbox 接入)等;保留云超服必要适配。 +- **C 桶 · 混合(按部位拆分合并)**:语言文件(云超服语言集 + 全量吸收上游 key)、Chat 入口、Inbox、AppCenter、PluginManager、ChannelDrawer、ToolCardShell 等,逐区块并合。 + +### 4.1 本次(v2.1.0b1 → v2.2.0)83 个双改文件裁决清单 + +#### A 桶 · 云超服为准(25 个) +| 文件 | 裁决理由 | +|------|---------| +| `api/modules/auth.ts` | 云超服强制登录/JWT 角色体系 | +| `api/modules/pawapp.ts` | 云超服 pawapp 扩展 | +| `api/modules/pluginMarket.ts` | 云超服自研插件市场 API | +| `api/types/channel.ts` | 云超服渠道类型 + 吸收上游字段 | +| `components/AgentSelector/index.module.less` | 云超服 Agent 选择器样式 | +| `components/AgentSelector/index.tsx` | 云超服 Agent 选择器 | +| `components/ApprovalCard/ApprovalCard.module.less` | 云超服审批卡 | +| `components/LanguageSwitcher/index.tsx` | 云超服语言切换 | +| `components/SkillVisual/index.tsx` | 云超服技能可视化 | +| `layouts/Header.tsx` | 云超服头部 | +| `layouts/MainLayout/index.tsx` | 云超服五大工作页布局,吸收上游新增 | +| `layouts/Sidebar.tsx` | 云超服角色菜单为主 + 吸收上游会话分组渲染 | +| `layouts/index.module.less` | 云超服布局样式 | +| `layouts/registry/builtinMenu.ts` | 云超服菜单注册 + 补上游新增入口 | +| `layouts/registry/builtinRoutes.tsx` | 云超服路由 + 补上游 Files/Hub/OS 路由 | +| `pages/Agent/MCP/index.module.less` | 云超服 MCP 样式 | +| `pages/Agent/Skills/components/SkillCard.tsx` | 云超服技能卡 | +| `pages/Login/index.tsx` | 云超服登录/角色体系,吸收上游登录增强 | +| `pages/Settings/Environments/index.module.less` | 云超服环境配置样式 | +| `pages/Settings/Models/index.module.less` | 云超服模型样式 + 吸收上游布局 | +| `pages/Settings/Models/index.tsx` | 云超服模型页(PineAgentsModelCards) | +| `pages/Settings/Security/components/FileGuardSection.tsx` | 云超服文件守卫 + 吸收上游 | +| `plugins/hostExternals.ts` | 云超服插件宿主外部依赖 | +| `plugins/registry/store.ts` | 云超服插件注册表 + 吸收上游 | +| `styles/layout.css` | 云超服全局布局样式 | + +#### B 桶 · 上游为准(32 个) +| 文件 | 裁决理由 | +|------|---------| +| `api/types/agents.ts` | 上游 Agent 类型+云超服扩展 | +| `api/types/provider.ts` | 上游 Provider 类型+云超服扩展 | +| `components/LoopInput/LoopModeSelector.tsx` | 上游循环模式 | +| `components/MermaidCodeBlock/index.module.less` | 上游 Mermaid 样式 | +| `components/SessionItem/sessionItem.module.less` | 上游未读提示样式 | +| `pages/Agent/Config/components/ReMeLightMemoryCard.tsx` | 上游 ReMe 看板 | +| `pages/Agent/Config/index.module.less` | 上游 Agent 配置样式(ReMe) | +| `pages/Agent/Tools/index.tsx` | 上游工具能力(上游改31行>本地4行) | +| `pages/Chat/ModelSelector/index.module.less` | 上游模型选择器样式 | +| `pages/Chat/ModelSelector/index.tsx` | 上游 Agent 模型路由 | +| `pages/Chat/components/ApprovalLevelToggle.tsx` | 上游审批级别开关 | +| `pages/Chat/components/BackgroundTaskPanel.tsx` | 上游 compact 取代云超服 collapsed | +| `pages/Chat/components/ChatActionGroup/index.tsx` | 上游动作组 | +| `pages/Chat/components/ChatHeaderTitle/index.module.less` | 上游标题样式 | +| `pages/Chat/components/ChatSenderTabsPanel.tsx` | 上游后台任务 tab | +| `pages/Chat/components/ChatSessionDrawer/index.module.less` | 上游会话抽屉样式+云超服适配 | +| `pages/Chat/components/ContextUsageIndicator/ContextUsageIndicator.tsx` | 上游提示词缓存可观测 | +| `pages/Chat/components/HarnessModelSelector.tsx` | 上游模型选择 | +| `pages/Chat/index.module.less` | 上游 Chat 样式 | +| `pages/Chat/index.tsx` | 上游大改(自动折叠/媒体下载等)+保留云超服必要适配 | +| `pages/Coding/FilePreview.module.less` | 上游文件预览样式(随 Files 迁移) | +| `pages/Coding/GitPanel.module.less` | 上游 Git 面板样式 | +| `pages/Coding/TabbedEditor.module.less` | 上游编辑器样式(随 Files 迁移) | +| `pages/Control/CronJobs/index.tsx` | 上游定时任务+云超服小改 | +| `pages/Settings/AgentStats/index.tsx` | 上游当前视角统计 | +| `pages/Settings/Agents/components/AgentModal.tsx` | 上游 Agent 模态 | +| `pages/Settings/Agents/components/AgentTable.tsx` | 上游 Agent 表格 | +| `pages/Settings/Models/components/modals/RemoteModelManageModal.tsx` | 上游统一提供商重构,保留云超服兼容 | +| `pages/Settings/SkillPool/useSkillPool.tsx` | 上游技能自动更新逻辑+保留云超服定制 | +| `pages/Settings/TokenUsage/hooks/useTokenTypeConfig.ts` | 上游 Token 缓存统计 | +| `tauri/BackendLoadingPage.tsx` | 上游桌面启动页 | +| `utils/skill.ts` | 上游技能工具 | + +#### C 桶 · 混合(26 个) +| 文件 | 裁决理由 | +|------|---------| +| `App.tsx` | 上游大改(Hub 路由)+云超服注入 → 上游主体+保留云超服路由 | +| `components/Chat/ToolCards/shared/ToolCardShell.tsx` | 双方同量级 → 并合 | +| `components/Chat/ToolCards/shared/toolCards.module.less` | 双方样式并合 | +| `layouts/SidebarSettingsPanel.tsx` | 云超服设置侧栏+上游 | +| `layouts/sidebarSessionList.module.less` | 上游会话列表样式+云超服 | +| `layouts/sidebarSettingsPanel.module.less` | 双方样式 | +| `locales/en.json` | 同上 | +| `locales/vi.json` | 同上 | +| `locales/zh.json` | 以云超服语言集为准 + 全量吸收上游新增 key | +| `main.tsx` | 云超服入口注入 + 补上游 Files/Hub/OS 路由 | +| `pages/AppCenter/AppMarket.tsx` | 上游应用市场+云超服定制 → 逐区块并合 | +| `pages/AppCenter/index.module.less` | 双方大改样式 | +| `pages/AppCenter/index.tsx` | 上游应用中心+云超服定制 | +| `pages/Control/Channels/components/ChannelDrawer.tsx` | 云超服 IM 渠道扩展 + 吸收上游钉钉宽屏/共享会话 | +| `pages/Inbox/index.module.less` | 上游 Inbox 样式+云超服扩展样式 | +| `pages/Inbox/index.tsx` | 上游 Mailbox 接入为主 + 保留云超服 Harvest 扩展 | +| `pages/Settings/AgentStats/index.module.less` | 双方样式 | +| `pages/Settings/Agents/index.module.less` | 双方样式 | +| `pages/Settings/Market/components/ResultCard.module.less` | 云超服结果卡样式 | +| `pages/Settings/Market/index.module.less` | 上游市场样式+云超服 | +| `pages/Settings/Market/useMarketSearch.ts` | 上游市场搜索+云超服 | +| `pages/Settings/PluginManager/components/MarketPluginList.module.less` | 上游市场列表样式 | +| `pages/Settings/PluginManager/components/OfficialPluginList.module.less` | 双方样式 | +| `pages/Settings/PluginManager/index.tsx` | 云超服自研市场为主体 + 吸收上游统一市场视图 | +| `pages/Settings/SkillPool/index.module.less` | 双方样式 | +| `plugins/usePluginLoader.ts` | 上游大改+云超服 → 上游主体+云超服适配 | + +--- + +## 5. sync-diff 工具 + +```bash +# 前端 console/src 比对(本次口径) +python scripts/sync-diff.py \ + --upstream "<上游仓库路径>" --base v2.1.0-beta.1 --head v2.2.0 \ + --local . --subpath console/src --out <输出目录> + +# 后端(上游 src/qwenpaw -> 本地 src/pineagents 改名) +python scripts/sync-diff.py --upstream "<上游>" \ + --base v2.1.0-beta.1 --head v2.2.0 --local . \ + --subpath src --map src/qwenpaw=src/pineagents --out <输出目录> +``` + +输出:`same / upstream_only / local_only / both / only_head / only_local / removed_upstream / deleted_upstream / deleted_local` 九个分类清单 + `REPORT.md` 汇总 + `meta.json`(含三方 commit 记录)。 + +### 5.1 本次 console/src 实测汇总(v2.1.0b1 → v2.2.0,不含测试文件) + +| 分类 | 说明 | 数量 | +|------|------|-----:| +| `same` | 一致(无需动作) | 273 | +| `upstream_only` | 仅上游改(直接采用) | 138 | +| `local_only` | 仅本地改(保留) | 66 | +| `both` | 双改(人工裁决,见 §4.1) | 83 | +| `only_head` | 上游新增(引入) | 157 | +| `only_local` | 本地新增(保留) | 99 | +| `deleted_upstream` | 上游已删但本地保留 | 17 | +| `deleted_local` | 本地已删但上游保留 | 9 | + +> 口径说明:head 取 tag `v2.2.0`(稳定基线);测试文件独立归类,不混入功能源码统计。 + +--- + +## 6. 验收门(每次同步合并) + +- [ ] `yarn build` 通过 +- [ ] console 单元测试通过 +- [ ] 后端 pytest 通过 +- [ ] 关键路径 smoke:登录 / 聊天 / 文件 / 市场 / 渠道 +- [ ] 高冲突文件回归快照测试(防止合并静默丢失云超服定制) +- [ ] 数据兼容:会话 / 项目目录 / 技能配置升级不丢不坏 +- [ ] `NOTICE` 与本文件「变更记录」已更新 + +--- + +## 7. 变更记录 + +| 日期 | 上游版本 | 本次动作 | 结果 | +|------|---------|---------|------| +| 2026-09-04 | v2.1.0-beta.1 → v2.2.0 | 三向 diff 分析 + 83 双改裁决 + 移植计划(见 `qwenpaw-移植计划与上游同步机制.html`) | 待执行 | diff --git a/scripts/sync-diff.py b/scripts/sync-diff.py new file mode 100644 index 0000000..b33bc2c --- /dev/null +++ b/scripts/sync-diff.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +sync-diff: 上游三向同步差异分析工具 + +功能 +---- +给定「上游 git 仓库 + 基线 ref(base) + 上游新版本 ref(head) + 本地仓库(local)」, +对指定子路径做三向文件级 diff,输出可执行分类清单,供同步合并使用: + + same base == head == local 无需动作 + upstream_only base == local != head 仅上游改 → 直接采用上游 + local_only base == head != local 仅本地改 → 保留本地 + both 三方互异 双改 → 人工裁决 + only_head 仅 head 有 上游新增 → 引入 + only_local 仅 local 有 本地新增 → 保留 + removed_upstream base 有、head/local 都无 双方都删 → 忽略 + deleted_upstream base 有、head 无、local 有 上游删了本地还留 → 决策是否跟随 + deleted_local base 有、head 有、local 无 本地删了上游还在 → 决策是否恢复 + +路径映射 +-------- +本地若对上游目录做过重命名(如 src/qwenpaw -> src/pineagents),通过 --map 指定, +导出后会先把上游树按映射改名再比对,避免把「重命名」误判为「删除+新增」。 + +测试文件 +-------- +默认将 *test*/__tests__/*.test.*/*.spec.* 归入独立的 tests_* 清单输出, +不混入功能源码统计(--include-tests 可合并)。 + +用法示例 +-------- + # 前端 console/src 比对(本次 QwenPaw 2.1.0b1 -> 2.2.0) + python scripts/sync-diff.py \ + --upstream "/path/to/QwenPaw" --base v2.1.0-beta.1 --head v2.2.0 \ + --local . --subpath console/src --out sync_out + + # 后端(上游 src/qwenpaw -> 本地 src/pineagents) + python scripts/sync-diff.py --upstream /path/to/QwenPaw \ + --base v2.1.0-beta.1 --head v2.2.0 --local . \ + --subpath src --map src/qwenpaw=src/pineagents --out sync_out_backend +""" +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile + +IGNORE_DIRS = {"node_modules", "dist", "build", ".venv", "venv", "__pycache__", ".git", "target", "out", "logs"} +TEST_MARKERS = ("test", "__tests__", "e2e", "tests") + + +def log(msg): + print(msg, file=sys.stderr) + + +def run_git(args, cwd): + r = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError("git %s failed: %s" % (" ".join(args), r.stderr.strip())) + return r.stdout + + +def resolve_commit(repo, ref): + """ref 支持 tag / 分支 / commit,返回完整 commit hash。""" + return run_git(["rev-parse", "%s^{commit}" % ref], repo).strip() + + +def export_tree(repo, ref, subpath, dest): + """git archive 导出 ref 的 subpath 到 dest(去掉 subpath 前缀)。""" + os.makedirs(dest, exist_ok=True) + strip = len(subpath.split("/")) if subpath else 0 + with open(os.devnull, "wb") as devnull: + p = subprocess.Popen( + ["git", "archive", ref, subpath], cwd=repo, + stdout=subprocess.PIPE, stderr=devnull, + ) + t = subprocess.Popen( + ["tar", "-x", "-C", dest, "--strip-components", str(strip)], + stdin=p.stdout, stdout=devnull, stderr=devnull, + ) + if p.stdout is not None: + p.stdout.close() + t.wait() + p.wait() + if p.returncode != 0 or t.returncode != 0: + raise RuntimeError("git archive %s:%s failed" % (ref, subpath)) + + +def export_local(repo, subpath, dest, path_map): + """用 git ls-files 取本地 tracked 文件,导出到 dest(应用路径映射)。""" + os.makedirs(dest, exist_ok=True) + files = [f for f in run_git(["ls-files", "-z", subpath], repo).split("\0") if f] + for f in files: + rel = f[len(subpath):].lstrip("/") if subpath else f + if not rel: + continue + # 应用路径映射(把上游名替换为本地名) + out_rel = rel + for old, new in path_map: + if rel == old or rel.startswith(old + "/"): + out_rel = new + rel[len(old):] + break + target = os.path.join(dest, out_rel) + os.makedirs(os.path.dirname(target), exist_ok=True) + blob = run_git(["show", "HEAD:" + f], repo) + with open(target, "w", encoding="utf-8", newline="") as fh: + fh.write(blob) + + +def apply_map(tree_root, path_map): + """把 tree_root 下符合映射的文件/目录改名(处理 export 后未映射的上游树)。""" + for old, new in path_map: + src = os.path.join(tree_root, old) + if os.path.exists(src): + dst = os.path.join(tree_root, new) + os.makedirs(os.path.dirname(dst), exist_ok=True) + if os.path.exists(dst): + shutil.rmtree(dst) + os.rename(src, dst) + + +def md5_file(path): + h = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def walk_files(root, ignore_dirs, include_tests): + """返回 {rel_path: md5hex}。""" + result = {} + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in ignore_dirs] + for fn in filenames: + full = os.path.join(dirpath, fn) + rel = os.path.relpath(full, root) + is_test = is_test_file(rel) + if is_test and not include_tests: + continue + result[rel] = md5_file(full) + return result + + +def is_test_file(rel): + parts = rel.split(os.sep) + if any(m in p for p in parts for m in TEST_MARKERS): + return True + if rel.endswith((".test.ts", ".test.tsx", ".test.js", ".spec.ts", ".spec.tsx", ".spec.js", ".test.py")): + return True + return False + + +def classify(base, head, local): + cats = {k: [] for k in ( + "same", "upstream_only", "local_only", "both", "only_head", "only_local", + "removed_upstream", "deleted_upstream", "deleted_local", + )} + tests = {k: [] for k in cats} + base_set = set(base); head_set = set(head); local_set = set(local) + + for p in sorted(base_set | head_set | local_set): + b, h, l = p in base_set, p in head_set, p in local_set + if b and h and l: + if base[p] == head[p] == local[p]: + key = "same" + elif base[p] == local[p]: + key = "upstream_only" + elif base[p] == head[p]: + key = "local_only" + else: + key = "both" + elif h and not b and not l: + key = "only_head" + elif l and not b and not h: + key = "only_local" + elif b and not h and not l: + key = "removed_upstream" + elif b and not h and l: + key = "deleted_upstream" + else: # b and h and not l + key = "deleted_local" + (tests if is_test_file(p) else cats)[key].append(p) + return cats, tests + + +def write_list(path, items): + with open(path, "w", encoding="utf-8") as f: + for p in items: + f.write(p + "\n") + + +def main(): + ap = argparse.ArgumentParser(description="上游三向同步差异分析") + ap.add_argument("--upstream", required=True, help="上游 git 仓库路径") + ap.add_argument("--base", required=True, help="基线 ref(tag/commit)") + ap.add_argument("--head", required=True, help="上游新版本 ref") + ap.add_argument("--local", default=".", help="本地仓库路径(默认当前目录)") + ap.add_argument("--subpath", default="console/src", help="比对的子路径(默认 console/src)") + ap.add_argument("--map", action="append", default=[], metavar="OLD=NEW", + help="上游→本地路径映射,可多次指定,如 src/qwenpaw=src/pineagents") + ap.add_argument("--out", default="sync_out", help="输出目录") + ap.add_argument("--include-tests", action="store_true", help="将测试文件并入主分类(默认单独输出 tests_*)") + ap.add_argument("--keep-tmp", action="store_true", help="保留临时导出目录(调试用)") + args = ap.parse_args() + + path_map = [] + for m in args.map: + if "=" not in m: + ap.error("--map 需为 OLD=NEW 格式: %s" % m) + old, new = m.split("=", 1) + path_map.append((old.strip("/"), new.strip("/"))) + + local = os.path.abspath(args.local) + upstream = os.path.abspath(args.upstream) + if not os.path.isdir(os.path.join(local, ".git")): + ap.error("本地不是 git 仓库: %s" % local) + if not os.path.isdir(os.path.join(upstream, ".git")): + ap.error("上游不是 git 仓库: %s" % upstream) + + base_commit = resolve_commit(upstream, args.base) + head_commit = resolve_commit(upstream, args.head) + local_commit = resolve_commit(local, "HEAD") + log("base = %s (%s)" % (args.base, base_commit[:12])) + log("head = %s (%s)" % (args.head, head_commit[:12])) + log("local = HEAD (%s)" % local_commit[:12]) + + tmp = tempfile.mkdtemp(prefix="sync-diff-") + try: + d_base = os.path.join(tmp, "base") + d_head = os.path.join(tmp, "head") + d_local = os.path.join(tmp, "local") + export_tree(upstream, args.base, args.subpath, d_base) + export_tree(upstream, args.head, args.subpath, d_head) + apply_map(d_base, path_map) + apply_map(d_head, path_map) + export_local(local, args.subpath, d_local, path_map) + + ignore = IGNORE_DIRS + base_map = walk_files(d_base, ignore, args.include_tests) + head_map = walk_files(d_head, ignore, args.include_tests) + local_map = walk_files(d_local, ignore, args.include_tests) + + cats, tests = classify(base_map, head_map, local_map) + finally: + if not args.keep_tmp: + shutil.rmtree(tmp, ignore_errors=True) + + os.makedirs(args.out, exist_ok=True) + meta = { + "upstream": upstream, + "base_ref": args.base, "base_commit": base_commit, + "head_ref": args.head, "head_commit": head_commit, + "local": local, "local_commit": local_commit, + "subpath": args.subpath, "path_map": args.map, + "include_tests": args.include_tests, + } + with open(os.path.join(args.out, "meta.json"), "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + + order = ["same", "upstream_only", "local_only", "both", + "only_head", "only_local", "removed_upstream", + "deleted_upstream", "deleted_local"] + label = { + "same": "一致(无需动作)", "upstream_only": "仅上游改(直接采用上游)", + "local_only": "仅本地改(保留本地)", "both": "双改(人工裁决)", + "only_head": "上游新增(引入)", "only_local": "本地新增(保留)", + "removed_upstream": "双方删除(忽略)", + "deleted_upstream": "上游已删但本地保留(决策是否跟随删除)", + "deleted_local": "本地已删但上游保留(决策是否恢复)", + } + summary = [] + for k in order: + n = len(cats[k]) + len(tests[k]) + summary.append((k, label[k], n)) + write_list(os.path.join(args.out, k + ".txt"), + cats[k] + tests[k]) + if tests[k]: + write_list(os.path.join(args.out, "tests_" + k + ".txt"), tests[k]) + # 仅测试文件专属清单 + all_test_files = sorted(set(sum(tests.values(), []))) + if all_test_files: + write_list(os.path.join(args.out, "tests_all.txt"), all_test_files) + + with open(os.path.join(args.out, "REPORT.md"), "w", encoding="utf-8") as f: + f.write("# 上游同步差异报告(sync-diff)\n\n") + f.write("- 上游: `%s`\n- base: `%s` (%s)\n- head: `%s` (%s)\n- local: `%s` (%s)\n- 子路径: `%s`\n- 路径映射: %s\n\n" % ( + upstream, args.base, base_commit[:12], args.head, head_commit[:12], + local, local_commit[:12], args.subpath, args.map or "无")) + f.write("| 分类 | 说明 | 数量 |\n|---|---|---|\n") + for k, lab, n in summary: + f.write("| %s | %s | %d |\n" % (k, lab, n)) + f.write("\n> 双改文件清单见 `both.txt`;each 分类清单见同目录 `*.txt`。\n") + + width = max(len(lab) for _, lab, _ in summary) + log("\n=== 同步差异汇总 ===") + for k, lab, n in summary: + log(" %-*s %4d" % (width, lab, n)) + log("\n输出目录: %s" % os.path.abspath(args.out)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())