perf: 优化 Wiki 镜像增量同步 (#29)
This commit is contained in:
@@ -16,7 +16,8 @@
|
||||
| 检查模板结构 | `python dev_scripts/harness.py check --strict` |
|
||||
| 运行单元测试 | `python -m unittest discover -s tests -v` |
|
||||
| 导出核心 Wiki 镜像 | `python dev_scripts/harness.py sync` |
|
||||
| 检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` |
|
||||
| 快速检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --check` |
|
||||
| 深度检查核心 Wiki 镜像 | `python dev_scripts/harness.py sync --deep-check` |
|
||||
| 创建可选任务快照 | `python dev_scripts/harness.py archive 123 "修复登录超时"` |
|
||||
| 增量导出已有快照 | `python dev_scripts/harness.py export` |
|
||||
| 全量导出已有快照 | `python dev_scripts/harness.py export --all` |
|
||||
@@ -102,7 +103,7 @@ Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明
|
||||
- `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。
|
||||
- `继续工单 #N`:优先核对当前状态、最新评论、Git 和必要 Wiki 证据,从首个未完成步骤继续;关键前提未变化时不重复读取和分析仍有效的内容。
|
||||
- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。
|
||||
- `同步文档`:读取 Wiki、导出核心 `docs/` 并检查一致性,不处理任务归档;不修改 Wiki、不自动提交。
|
||||
- `同步文档`:按 revision 增量读取 Wiki、导出核心 `docs/` 并快速检查一致性,不处理任务归档;不修改 Wiki、不自动提交。只有明确要求深度检查时才逐页下载正文。
|
||||
- `导出原型 #N`:人工触发导出指定工单已确认的原型版本,按工单和版本写入 `prototypes/`;不扩展范围、不自动提交。
|
||||
- `导出全部原型`:人工触发导出当前项目已明确范围内的全部已确认原型;不自动提交。
|
||||
- `导出任务归档`:人工触发 `python dev_scripts/harness.py export`,只导出新增或 revision 已变化的任务归档;不删除本地文件、不自动提交。
|
||||
|
||||
+42
-19
@@ -620,32 +620,47 @@ def run_check(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def run_sync(args: argparse.Namespace) -> int:
|
||||
"""--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。"""
|
||||
"""同步核心镜像;--verify 复用本轮完整读取完成初始化验证。"""
|
||||
|
||||
if args.verify:
|
||||
steps = (
|
||||
("同步", lambda: run_sync(
|
||||
argparse.Namespace(check=False, verify=False, config=args.config))),
|
||||
("结构检查", lambda: run_check(argparse.Namespace(strict=True))),
|
||||
("一致性校验", lambda: run_sync(
|
||||
argparse.Namespace(check=True, verify=False, config=args.config))),
|
||||
)
|
||||
for name, step in steps:
|
||||
code = step()
|
||||
if code != 0:
|
||||
print(f"错误:{name}未通过,已停止")
|
||||
return code
|
||||
try:
|
||||
config = load_config(Path(args.config).resolve())
|
||||
messages = sync_all(
|
||||
config, WikiClient(config), check=False, deep_check=True
|
||||
)
|
||||
except WikiDocsError as exc:
|
||||
print(f"错误:{exc}")
|
||||
return 1
|
||||
for message in messages:
|
||||
print(message)
|
||||
code = run_check(argparse.Namespace(strict=True))
|
||||
if code != 0:
|
||||
print("错误:结构检查未通过,已停止")
|
||||
return code
|
||||
print("Wiki 镜像初始化验证通过")
|
||||
return 0
|
||||
|
||||
try:
|
||||
config = load_config(Path(args.config).resolve())
|
||||
messages = sync_all(config, WikiClient(config), check=args.check)
|
||||
deep_check = getattr(args, "deep_check", False)
|
||||
messages = sync_all(
|
||||
config,
|
||||
WikiClient(config),
|
||||
check=args.check or deep_check,
|
||||
deep_check=deep_check,
|
||||
)
|
||||
except WikiDocsError as exc:
|
||||
print(f"错误:{exc}")
|
||||
return 1
|
||||
for message in messages:
|
||||
print(message)
|
||||
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
|
||||
print(
|
||||
"Wiki 镜像深度检查通过"
|
||||
if deep_check
|
||||
else "Wiki 镜像检查通过"
|
||||
if args.check
|
||||
else "Wiki 镜像同步完成"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -710,13 +725,21 @@ def main() -> int:
|
||||
p_check.set_defaults(func=run_check)
|
||||
|
||||
p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像")
|
||||
p_sync.add_argument(
|
||||
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
|
||||
sync_mode = p_sync.add_mutually_exclusive_group()
|
||||
sync_mode.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="按 revision 快速检查 Wiki 与镜像,不写文件",
|
||||
)
|
||||
p_sync.add_argument(
|
||||
sync_mode.add_argument(
|
||||
"--deep-check",
|
||||
action="store_true",
|
||||
help="下载全部 Wiki 正文并逐页检查镜像,不写文件",
|
||||
)
|
||||
sync_mode.add_argument(
|
||||
"--verify",
|
||||
action="store_true",
|
||||
help="依次执行导出、check --strict 和一致性校验",
|
||||
help="完整读取并导出 Wiki,再执行 check --strict 初始化验证",
|
||||
)
|
||||
p_sync.add_argument(
|
||||
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
|
||||
|
||||
@@ -381,7 +381,72 @@ def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
|
||||
def _metadata_revision(metadata: dict[str, Any]) -> str | None:
|
||||
last_commit = metadata.get("last_commit")
|
||||
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
|
||||
return revision if isinstance(revision, str) and revision else None
|
||||
|
||||
|
||||
def _find_page_metadata(
|
||||
pages: list[dict[str, Any]], page_name: str
|
||||
) -> dict[str, Any]:
|
||||
metadata = next(
|
||||
(
|
||||
item
|
||||
for item in pages
|
||||
if item.get("title") == page_name or item.get("sub_url") == page_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if metadata is None:
|
||||
raise WikiDocsError(
|
||||
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def _metadata_identity(
|
||||
config: Config, metadata: dict[str, Any], fallback_title: str
|
||||
) -> tuple[str, str] | None:
|
||||
title = metadata.get("title")
|
||||
sub_url = metadata.get("sub_url")
|
||||
if not isinstance(title, str) or not title:
|
||||
title = fallback_title
|
||||
if not isinstance(sub_url, str) or not sub_url:
|
||||
return None
|
||||
url = (
|
||||
f"{config.gitea_url}/{quote(config.owner, safe='')}/"
|
||||
f"{quote(config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
|
||||
)
|
||||
return title, url
|
||||
|
||||
|
||||
def _local_revision(
|
||||
path: Path, *, expected_title: str, expected_url: str
|
||||
) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
metadata, _body = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError):
|
||||
return None
|
||||
if (
|
||||
metadata.get("wiki_page") != expected_title
|
||||
or metadata.get("wiki_url") != expected_url
|
||||
or not metadata.get("synchronized_at")
|
||||
):
|
||||
return None
|
||||
revision = metadata.get("wiki_revision")
|
||||
return revision if revision else None
|
||||
|
||||
|
||||
def sync_all(
|
||||
config: Config,
|
||||
client: WikiClient,
|
||||
*,
|
||||
check: bool = False,
|
||||
deep_check: bool = False,
|
||||
) -> list[str]:
|
||||
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
|
||||
|
||||
if not check:
|
||||
@@ -392,10 +457,28 @@ def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list
|
||||
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
|
||||
)
|
||||
|
||||
pages = client.list_pages()
|
||||
messages: list[str] = []
|
||||
for mapping in config.mappings:
|
||||
page = client.get_page(mapping.page)
|
||||
metadata = _find_page_metadata(pages, mapping.page)
|
||||
target = ROOT / PurePosixPath(mapping.path)
|
||||
remote_revision = _metadata_revision(metadata)
|
||||
identity = _metadata_identity(config, metadata, mapping.page)
|
||||
local_revision = (
|
||||
_local_revision(
|
||||
target, expected_title=identity[0], expected_url=identity[1]
|
||||
)
|
||||
if identity is not None
|
||||
else None
|
||||
)
|
||||
if not deep_check and remote_revision and local_revision == remote_revision:
|
||||
action = "一致" if check else "无变化"
|
||||
messages.append(
|
||||
f"{action}:{mapping.path} <- {mapping.page}@{remote_revision[:12]}"
|
||||
)
|
||||
continue
|
||||
|
||||
page = client.get_page_from_metadata(metadata, mapping.page)
|
||||
if check:
|
||||
errors = check_mirror(mapping, page, target)
|
||||
if errors:
|
||||
|
||||
+6
-3
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Development-Workflow
|
||||
wiki_url: https://git.ilapage.cn/OPC/dev_harness/wiki/Development-Workflow.-
|
||||
wiki_revision: 4e30058e244453bf5da5cf2b5a0aa59176649127
|
||||
synchronized_at: 2026-08-26T08:01:22Z
|
||||
wiki_revision: fb52a69363b53f3bce6d5f2bdc016aead0c19f90
|
||||
synchronized_at: 2026-09-02T01:36:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 开发工作流
|
||||
@@ -193,7 +193,10 @@ python dev_scripts/harness.py export --all # 全量导出已有归档
|
||||
- 所有同步和导出只实现 Wiki → `docs/`,不提供反向同步。
|
||||
- 镜像头必须记录页面名、页面地址、revision 和同步时间。
|
||||
- 已跟踪镜像存在未提交改动时,同步必须停止;确认改动来源后再处理。
|
||||
- 核心同步的 `--check` 只检查核心镜像,不要求线上任务归档全部存在于本地。
|
||||
- `sync` 和 `sync --check` 先读取一次页面列表,并用远端 revision 与本地镜像头比较;revision 未变化时不下载正文,只有页面新增、变化、元数据缺失或本地镜像元数据无效时才读取正文。
|
||||
- `sync --check` 是核心镜像的日常快速检查,不要求线上任务归档全部存在于本地;它验证 revision 和镜像元数据,不替代正文审计。
|
||||
- `sync --deep-check` 显式下载全部映射页面正文并逐页比较,用于疑似镜像损坏、同步算法审计或人工要求的完整核对。
|
||||
- `sync --verify` 用于新项目 Wiki 初始化门禁,完整读取并写入核心镜像后执行严格结构检查;同一次运行复用读取结果,不重复下载正文。
|
||||
- 已经导出的任务镜像仍必须具有来源页面、revision 和同步时间,并通过 Harness 格式检查。
|
||||
- 页面删除和重命名不会自动传播,必须先更新工单并人工确认映射变化。
|
||||
- Wiki 更新成功而导出失败时,在工单记录部分完成状态,不得把任务标为完成。
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -17,6 +18,7 @@ from harness import ( # noqa: E402
|
||||
existing_task_mirrors,
|
||||
export_task_archives,
|
||||
main as harness_main,
|
||||
run_sync,
|
||||
safe_title,
|
||||
task_target,
|
||||
)
|
||||
@@ -122,6 +124,165 @@ class MirrorTests(unittest.TestCase):
|
||||
sync_all(config, client)
|
||||
client.get_page.assert_not_called()
|
||||
|
||||
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
|
||||
def test_incremental_sync_lists_once_and_skips_unchanged_body(self, _dirty) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "docs" / "README.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(render_mirror(self.page), encoding="utf-8")
|
||||
config = Config(
|
||||
path=root / "wiki-docs.json",
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
client.list_pages.return_value = [
|
||||
{
|
||||
"title": "Home",
|
||||
"sub_url": "Home",
|
||||
"last_commit": {"sha": self.page.revision},
|
||||
}
|
||||
]
|
||||
with patch("wiki_docs.ROOT", root):
|
||||
messages = sync_all(config, client)
|
||||
self.assertTrue(messages[0].startswith("无变化:"))
|
||||
client.list_pages.assert_called_once_with()
|
||||
client.get_page_from_metadata.assert_not_called()
|
||||
|
||||
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
|
||||
def test_incremental_sync_fetches_changed_page(self, _dirty) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "docs" / "README.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(render_mirror(self.page), encoding="utf-8")
|
||||
changed = WikiPage(
|
||||
title="Home",
|
||||
sub_url="Home",
|
||||
text="# 新首页\n",
|
||||
revision="b" * 40,
|
||||
html_url=self.page.html_url,
|
||||
)
|
||||
metadata = {
|
||||
"title": "Home",
|
||||
"sub_url": "Home",
|
||||
"last_commit": {"sha": changed.revision},
|
||||
}
|
||||
config = Config(
|
||||
path=root / "wiki-docs.json",
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
client.list_pages.return_value = [metadata]
|
||||
client.get_page_from_metadata.return_value = changed
|
||||
with patch("wiki_docs.ROOT", root):
|
||||
messages = sync_all(config, client)
|
||||
_metadata, body = parse_mirror(target.read_text(encoding="utf-8"))
|
||||
self.assertTrue(messages[0].startswith("已更新:"))
|
||||
self.assertEqual(body, changed.text)
|
||||
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
|
||||
|
||||
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
|
||||
def test_sync_fetches_when_remote_revision_is_missing(self, _dirty) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "docs" / "README.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(render_mirror(self.page), encoding="utf-8")
|
||||
metadata = {"title": "Home", "sub_url": "Home"}
|
||||
config = Config(
|
||||
path=root / "wiki-docs.json",
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
client.list_pages.return_value = [metadata]
|
||||
client.get_page_from_metadata.return_value = self.page
|
||||
with patch("wiki_docs.ROOT", root):
|
||||
sync_all(config, client)
|
||||
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
|
||||
|
||||
@patch("wiki_docs.dirty_mirror_paths", return_value=[])
|
||||
def test_sync_fetches_when_local_metadata_is_invalid(self, _dirty) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "docs" / "README.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
invalid = render_mirror(self.page).replace(
|
||||
"wiki_page: Home", "wiki_page: Wrong"
|
||||
)
|
||||
target.write_text(invalid, encoding="utf-8")
|
||||
metadata = {
|
||||
"title": "Home",
|
||||
"sub_url": "Home",
|
||||
"last_commit": {"sha": self.page.revision},
|
||||
}
|
||||
config = Config(
|
||||
path=root / "wiki-docs.json",
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
client.list_pages.return_value = [metadata]
|
||||
client.get_page_from_metadata.return_value = self.page
|
||||
with patch("wiki_docs.ROOT", root):
|
||||
sync_all(config, client)
|
||||
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
|
||||
|
||||
def test_deep_check_fetches_unchanged_page_body(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
target = root / "docs" / "README.md"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text(render_mirror(self.page), encoding="utf-8")
|
||||
metadata = {
|
||||
"title": "Home",
|
||||
"sub_url": "Home",
|
||||
"last_commit": {"sha": self.page.revision},
|
||||
}
|
||||
config = Config(
|
||||
path=root / "wiki-docs.json",
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
client.list_pages.return_value = [metadata]
|
||||
client.get_page_from_metadata.return_value = self.page
|
||||
with patch("wiki_docs.ROOT", root):
|
||||
sync_all(config, client, check=True, deep_check=True)
|
||||
client.list_pages.assert_called_once_with()
|
||||
client.get_page_from_metadata.assert_called_once_with(metadata, "Home")
|
||||
|
||||
@patch("harness.run_check", return_value=0)
|
||||
@patch("harness.sync_all", return_value=["已更新:docs/README.md"])
|
||||
@patch("harness.WikiClient")
|
||||
@patch("harness.load_config")
|
||||
def test_verify_reuses_one_deep_sync(
|
||||
self, load, client_class, sync, check
|
||||
) -> None:
|
||||
config = Mock()
|
||||
load.return_value = config
|
||||
result = run_sync(
|
||||
Namespace(verify=True, check=False, deep_check=False, config="config.json")
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
sync.assert_called_once_with(
|
||||
config, client_class.return_value, check=False, deep_check=True
|
||||
)
|
||||
check.assert_called_once()
|
||||
|
||||
|
||||
class WikiClientTests(unittest.TestCase):
|
||||
def test_encoded_unicode_sub_url_is_not_double_encoded(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user