cursor Init 完善多人协同changelog,以及godot相关基础skill和代码规范

This commit is contained in:
Nostars Developer
2026-07-15 14:38:08 +08:00
parent fc8557f2a9
commit 6446261c69
56 changed files with 3523 additions and 1584 deletions

View File

@@ -0,0 +1,4 @@
# Dev Changelog — By Author
按作者聚合的贡献概览,便于多人协作时区分提交来源。
> 本文件由 `tools/changelog_build.py` 自动生成,请勿手改。

View File

@@ -1,5 +1,6 @@
# Dev Changelog — Full
完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改;新增/修改请编辑 `entries/<author>/<id>.md` 后重跑生成器。
## 记录

View File

@@ -1,3 +1,4 @@
# Dev Changelog — Headlines
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。

View File

@@ -1,4 +1,5 @@
# Dev Changelog — Recent
# Dev Changelog — Recent
最近 ~10 次改动的摘要记录,按时间倒序排列。
最近 ~20 次改动的摘要记录,按时间倒序排列。
当 Agent 检测到当前任务与近期改动相关时自动读取。
> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""changelog_build.py — 从 fragment 源生成三层 changelog 视图。
设计目标(解决多人协作 + git merge 冲突):
- 唯一数据源是 ``.cursor/changelog/entries/<author>/<id>.md`` 一条一文件的 fragment。
- 三层视图full / recent / headlines全部由本脚本**确定性生成**,不再手写。
- 因为排序只依赖 fragment 内的 CL 时间戳,与 git merge 顺序无关,所有人重建后逐字节收敛。
只用标准库,兼容 Python 3.10+,可在任意平台运行。
"""
from __future__ import annotations
import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
# ---- 路径约定 --------------------------------------------------------------
CHANGELOG_DIR_NAME = ".cursor/changelog"
ENTRIES_SUBDIR = "entries"
FULL_NAME = "changelog-full.md"
RECENT_NAME = "changelog-recent.md"
HEADLINES_NAME = "changelog-headlines.md"
BY_AUTHOR_NAME = "changelog-by-author.md"
RECENT_WINDOW = 20
HEADLINES_WINDOW = 50
# ---- fragment 解析 ---------------------------------------------------------
ID_RE = re.compile(r"^CL-(\d{8})-(\d{3,4})([a-z]*)(?:-(.+))?$")
FM_FENCE = "---"
SECTION_RE = re.compile(r"^<!--\s*(L1|L2|L3)\s*-->\s*$", re.M)
@dataclass
class Fragment:
fid: str
date: str = ""
author: str = "legacy"
author_name: str = ""
type: str = ""
merge_source: str = ""
status: str = "active"
superseded_by: str = ""
title: str = ""
source_chat: str = ""
tags: list[str] = field(default_factory=list)
affected_files: list[str] = field(default_factory=list)
l3: str = ""
l2: str = ""
l1: str = ""
path: Path | None = None
def sort_key(self) -> tuple[str, str, str, str]:
m = ID_RE.match(self.fid)
if not m:
return (self.fid, "", "", "")
date8, time4, suffix, _author = m.groups()
return (date8, time4.zfill(4), suffix, self.fid)
def date_only(self) -> str:
return self.date.split(" ")[0] if self.date else ""
def _parse_frontmatter(text: str) -> dict[str, object]:
data: dict[str, object] = {}
lines = text.splitlines()
i = 0
while i < len(lines):
line = lines[i]
if not line.strip():
i += 1
continue
m = re.match(r"^([A-Za-z_]+):\s*(.*)$", line)
if not m:
i += 1
continue
key, val = m.group(1), m.group(2).strip()
if val == "":
items: list[str] = []
j = i + 1
while j < len(lines) and re.match(r"^\s+-\s+", lines[j]):
items.append(lines[j].strip()[1:].strip())
j += 1
if items:
data[key] = items
i = j
else:
data[key] = ""
i += 1
elif val.startswith("[") and val.endswith("]"):
inner = val[1:-1].strip()
data[key] = [x.strip() for x in inner.split(",") if x.strip()] if inner else []
i += 1
else:
data[key] = val
i += 1
return data
def parse_fragment(path: Path) -> Fragment:
raw = path.read_text(encoding="utf-8")
if not raw.startswith(FM_FENCE):
raise ValueError(f"fragment 缺少 frontmatter: {path}")
rest = raw[len(FM_FENCE):]
end = rest.find("\n" + FM_FENCE)
if end == -1:
raise ValueError(f"fragment frontmatter 未闭合: {path}")
fm_text = rest[:end]
body = rest[end + len("\n" + FM_FENCE):].lstrip("\n")
fm = _parse_frontmatter(fm_text)
sections: dict[str, str] = {}
parts = SECTION_RE.split(body)
it = iter(parts[1:])
for marker, content in zip(it, it):
sections[marker] = content.strip("\n")
def _s(key: str) -> str:
v = fm.get(key, "")
return v if isinstance(v, str) else ""
def _l(key: str) -> list[str]:
v = fm.get(key, [])
return [str(x) for x in v] if isinstance(v, list) else []
frag = Fragment(
fid=_s("id"),
date=_s("date"),
author=_s("author") or "legacy",
author_name=_s("author_name"),
type=_s("type"),
merge_source=_s("merge_source"),
status=_s("status") or "active",
superseded_by=_s("superseded_by"),
title=_s("title"),
source_chat=_s("source_chat"),
tags=_l("tags"),
affected_files=_l("affected_files"),
l3=sections.get("L3", "").strip(),
l2=sections.get("L2", "").strip(),
l1=sections.get("L1", "").strip(),
path=path,
)
if not frag.fid:
raise ValueError(f"fragment 缺少 id: {path}")
return frag
def load_fragments(entries_dir: Path) -> list[Fragment]:
frags: list[Fragment] = []
seen: dict[str, Path] = {}
for path in sorted(entries_dir.rglob("*.md")):
frag = parse_fragment(path)
if frag.fid in seen:
raise ValueError(
f"重复的 CL id {frag.fid}: {path}{seen[frag.fid]}"
)
seen[frag.fid] = path
frags.append(frag)
frags.sort(key=lambda f: f.sort_key(), reverse=True)
return frags
# ---- 渲染 ------------------------------------------------------------------
def _author_tag(frag: Fragment) -> str:
return "" if frag.author in ("", "legacy") else f"@{frag.author} "
def render_headlines(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — Headlines\n\n"
"最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。\n"
"> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。"
)
lines = []
for frag in frags[:HEADLINES_WINDOW]:
merge = f" [merge:{frag.merge_source}]" if frag.merge_source else ""
text = frag.l3 or frag.title
lines.append(f"- [{frag.fid}] {_author_tag(frag)}{text}{merge}")
body = "\n\n".join(lines)
return head + (f"\n\n{body}" if body else "") + "\n"
def render_recent(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — Recent\n\n"
"最近 ~20 次改动的摘要记录,按时间倒序排列。\n"
"当 Agent 检测到当前任务与近期改动相关时自动读取。\n"
"> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改。"
)
blocks = []
for frag in frags[:RECENT_WINDOW]:
b = [f"### [{frag.fid}] {frag.date_only()}{frag.title}"]
if frag.merge_source:
b.append(f"- **merge_source**: {frag.merge_source}")
if frag.author and frag.author != "legacy":
b.append(f"- **author**: {frag.author}")
if frag.tags:
b.append(f"- **tags**: {', '.join(frag.tags)}")
if frag.affected_files:
b.append(f"- **affected_files**: {''.join(frag.affected_files)}")
if frag.l2:
b.append(f"- **summary**: {frag.l2}")
if frag.source_chat:
b.append(f"- **source_chat**: {frag.source_chat}")
blocks.append("\n".join(b))
body = "\n\n".join(blocks)
return head + (f"\n\n{body}" if body else "") + "\n"
def render_full(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — Full\n\n"
"完整的开发改动记录,按时间倒序排列。作为主动 RAG 的数据源,用户手动唤醒时读取。\n"
"> 本文件由 `tools/changelog_build.py` 从 `entries/` 自动生成,请勿手改;"
"新增/修改请编辑 `entries/<author>/<id>.md` 后重跑生成器。\n\n"
"## 记录"
)
blocks = []
for frag in frags:
b = [f"### [{frag.fid}] {frag.date}{frag.title}"]
if frag.merge_source:
b.append(f"- **merge_source**: {frag.merge_source}")
author_line = frag.author
if frag.author_name:
author_line = f"{frag.author} ({frag.author_name})"
if frag.author and frag.author != "legacy":
b.append(f"- **author**: {author_line}")
if frag.type:
b.append(f"- **type**: {frag.type}")
if frag.status and frag.status != "active":
sup = f"{frag.superseded_by}" if frag.superseded_by else ""
b.append(f"- **status**: {frag.status}{sup}")
if frag.tags:
b.append(f"- **tags**: {', '.join(frag.tags)}")
if frag.affected_files:
af = "\n".join(f" - {x}" for x in frag.affected_files)
b.append(f"- **affected_files**:\n{af}")
if frag.l1:
b.append(frag.l1)
if frag.source_chat:
b.append(f"- **source_chat**: {frag.source_chat}")
blocks.append("\n".join(b))
body = "\n\n".join(blocks)
return head + (f"\n\n{body}" if body else "") + "\n"
def render_by_author(frags: list[Fragment]) -> str:
head = (
"# Dev Changelog — By Author\n\n"
"按作者聚合的贡献概览,便于多人协作时区分提交来源。\n"
"> 本文件由 `tools/changelog_build.py` 自动生成,请勿手改。"
)
by: dict[str, list[Fragment]] = {}
for frag in frags:
by.setdefault(frag.author or "legacy", []).append(frag)
blocks = []
for author in sorted(by):
items = by[author]
name = next((f.author_name for f in items if f.author_name), "")
title = f"## {author}" + (f" ({name})" if name else "") + f"{len(items)}"
rows = [
f"- [{f.fid}] {f.date_only()}{f.title}" for f in items[:HEADLINES_WINDOW]
]
blocks.append(title + "\n" + "\n".join(rows))
body = "\n\n".join(blocks)
return head + (f"\n\n{body}" if body else "") + "\n"
# ---- 入口 ------------------------------------------------------------------
def find_changelog_dir(start: Path) -> Path:
cur = start.resolve()
for cand in [cur, *cur.parents]:
d = cand / CHANGELOG_DIR_NAME
if d.is_dir():
return d
# 脚本自身位于 .cursor/changelog/tools/ 下时回退
here = Path(__file__).resolve().parent.parent
if here.name == "changelog":
return here
raise SystemExit("找不到 .cursor/changelog 目录")
def build(changelog_dir: Path, check: bool = False) -> int:
entries_dir = changelog_dir / ENTRIES_SUBDIR
if not entries_dir.is_dir():
raise SystemExit(f"entries 目录不存在: {entries_dir}")
frags = load_fragments(entries_dir)
targets = {
HEADLINES_NAME: render_headlines(frags),
RECENT_NAME: render_recent(frags),
FULL_NAME: render_full(frags),
BY_AUTHOR_NAME: render_by_author(frags),
}
drift = False
for name, content in targets.items():
path = changelog_dir / name
old = path.read_text(encoding="utf-8") if path.exists() else None
if old != content:
drift = True
if check:
print(f"[drift] {name} 与 fragment 源不一致")
else:
path.write_text(content, encoding="utf-8", newline="\n")
print(f"[write] {name}")
if check:
if drift:
print("视图与 fragment 源不一致,请运行 changelog_build.py 重建。")
return 1
print("视图已与 fragment 源同步。")
return 0
print(f"完成:{len(frags)} 条 fragment → 4 个视图。")
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="从 fragment 源生成三层 changelog 视图")
ap.add_argument("changelog_dir", nargs="?", default=None, help=".cursor/changelog 路径")
ap.add_argument("--check", action="store_true", help="只校验是否漂移不写文件CI 用)")
args = ap.parse_args(argv)
changelog_dir = (
Path(args.changelog_dir) if args.changelog_dir else find_changelog_dir(Path.cwd())
)
return build(changelog_dir, check=args.check)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,23 @@
#!/bin/sh
# post-merge — git merge / pull 完成后,从 fragment 源确定性重建 changelog 视图,
# 覆盖掉 merge=union 临时产生的乱序/重复内容。
# 若视图被重建(说明 union 留下了脏内容),提示用户提交一次。
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$ROOT" || exit 0
BUILD=".cursor/changelog/tools/changelog_build.py"
[ -f "$BUILD" ] || exit 0
if command -v python >/dev/null 2>&1; then PY=python
elif command -v python3 >/dev/null 2>&1; then PY=python3
else exit 0
fi
PYTHONIOENCODING=utf-8 "$PY" "$BUILD" >/dev/null 2>&1 || exit 0
if ! git diff --quiet -- .cursor/changelog/changelog-*.md 2>/dev/null; then
echo "[changelog] 已从 fragment 源重建视图merge 后清理)。请 git add + commit 这些视图文件。"
fi
exit 0

View File

@@ -0,0 +1,25 @@
#!/bin/sh
# pre-commit — 从 fragment 源重建 changelog 视图并纳入本次提交。
# 保证每个提交里的 full/recent/headlines/by-author 与 entries/ 严格一致。
# 找不到 python 时静默跳过,不阻塞提交。
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
cd "$ROOT" || exit 0
BUILD=".cursor/changelog/tools/changelog_build.py"
[ -f "$BUILD" ] || exit 0
if command -v python >/dev/null 2>&1; then PY=python
elif command -v python3 >/dev/null 2>&1; then PY=python3
else exit 0
fi
PYTHONIOENCODING=utf-8 "$PY" "$BUILD" >/dev/null 2>&1 || exit 0
git add \
.cursor/changelog/changelog-full.md \
.cursor/changelog/changelog-recent.md \
.cursor/changelog/changelog-headlines.md \
.cursor/changelog/changelog-by-author.md 2>/dev/null || true
exit 0

View File

@@ -0,0 +1,18 @@
# install-git-hooks.ps1 - install changelog pre-commit / post-merge git hooks into this repo.
# Git hooks are not distributed with the repo; run this once per clone (idempotent).
# Usage: powershell -ExecutionPolicy Bypass -File .cursor/changelog/tools/install-git-hooks.ps1
$ErrorActionPreference = "Stop"
$hooksDir = (git rev-parse --git-path hooks 2>$null)
if (-not $hooksDir) { Write-Error "not inside a git repository"; exit 1 }
if (-not (Test-Path $hooksDir)) { New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null }
$src = Join-Path $PSScriptRoot "git-hooks"
foreach ($name in @("pre-commit", "post-merge")) {
$from = Join-Path $src $name
$to = Join-Path $hooksDir $name
Copy-Item -Force $from $to
Write-Output "installed: $to"
}
Write-Output "done. git hooks installed (pre-commit rebuilds + stages views, post-merge rebuilds after merge)."

View File

@@ -0,0 +1,16 @@
#!/bin/sh
# install-git-hooks.sh — 把 changelog 的 pre-commit / post-merge git hook 装进本仓库。
# git hook 不随仓库分发,每个克隆都要跑一次本脚本(幂等)。
# 用法sh .cursor/changelog/tools/install-git-hooks.sh
set -e
HOOKS_DIR=$(git rev-parse --git-path hooks 2>/dev/null) || { echo "不在 git 仓库内"; exit 1; }
mkdir -p "$HOOKS_DIR"
SRC=$(CDPATH= cd -- "$(dirname -- "$0")/git-hooks" && pwd)
for name in pre-commit post-merge; do
cp -f "$SRC/$name" "$HOOKS_DIR/$name"
chmod +x "$HOOKS_DIR/$name"
echo "installed: $HOOKS_DIR/$name"
done
echo "完成。git hook 已安装pre-commit 重建并纳入视图post-merge 合并后重建)。"

View File

@@ -0,0 +1,96 @@
# merge-import.ps1 - harvest a not-yet-fragmented branch's changelog into fragments, then rebuild views.
#
# Use after merging an old-format branch (e.g. master) into your fragment-based branch.
# It captures the other side's old-format changelog entries as fragments (only the IDs
# that don't already exist), rebuilds the four views, and reports any lost IDs.
#
# Usage:
# powershell -ExecutionPolicy Bypass -File .cursor/changelog/tools/merge-import.ps1 # harvest from working-tree (post union-merge)
# powershell -ExecutionPolicy Bypass -File .cursor/changelog/tools/merge-import.ps1 -Ref master # harvest directly from a ref
param([string]$Ref = "")
$ErrorActionPreference = "Stop"
$env:PYTHONIOENCODING = "utf-8"
$root = (git rev-parse --show-toplevel 2>$null)
if (-not $root) { Write-Error "not inside a git repository"; exit 1 }
Set-Location $root
$cl = ".cursor/changelog"
$tools = "$cl/tools"
$pyCmd = (Get-Command python -ErrorAction SilentlyContinue)
if (-not $pyCmd) { $pyCmd = (Get-Command python3 -ErrorAction SilentlyContinue) }
if (-not $pyCmd) { Write-Error "python not found"; exit 1 }
$py = $pyCmd.Source
function Get-Ids($path) {
if (-not (Test-Path $path)) { return @() }
Select-String -Path $path -Pattern "^### \[(CL-[^\]]+)\]" |
ForEach-Object { $_.Matches[0].Groups[1].Value } | Sort-Object -Unique
}
$views = @("changelog-full.md", "changelog-recent.md", "changelog-headlines.md")
$tmp = Join-Path $env:TEMP ("cl-import-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmp | Out-Null
$enc = New-Object System.Text.UTF8Encoding($false)
try {
if ($Ref) {
Write-Output "harvest source: ref '$Ref'"
foreach ($f in $views) {
$content = (git show "${Ref}:$cl/$f" 2>$null)
if ($content) {
[System.IO.File]::WriteAllText((Join-Path $tmp $f), (($content -join "`n") + "`n"), $enc)
}
}
} else {
Write-Output "harvest source: working-tree (post union-merge)"
foreach ($f in $views) {
if (Test-Path "$cl/$f") { Copy-Item -Force "$cl/$f" (Join-Path $tmp $f) }
}
}
$srcFull = Join-Path $tmp "changelog-full.md"
$srcIds = Get-Ids $srcFull
Write-Output "source entries: $($srcIds.Count)"
# parse source into staging fragments (temp/entries/legacy)
& $py "$tools/migrate_changelog.py" $tmp | Out-Null
# existing fragment ids in the real repo (filename stem == id)
$existing = @()
if (Test-Path "$cl/entries") {
$existing = Get-ChildItem -Recurse "$cl/entries" -Filter *.md | ForEach-Object { $_.BaseName }
}
# copy only NEW ids into real entries/legacy (never overwrite existing fragments)
$stage = Join-Path $tmp "entries/legacy"
$added = 0
if (Test-Path $stage) {
New-Item -ItemType Directory -Force -Path "$cl/entries/legacy" | Out-Null
Get-ChildItem "$stage/*.md" | Where-Object { $existing -notcontains $_.BaseName } | ForEach-Object {
Copy-Item -Force $_.FullName "$cl/entries/legacy/"
$added++
}
}
Write-Output "new fragments imported: $added"
}
finally {
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
}
# rebuild views from the now-complete entries/
& $py "$tools/changelog_build.py"
# verify: every source id must survive in the rebuilt full view
$nowIds = Get-Ids "$cl/changelog-full.md"
$missing = $srcIds | Where-Object { $nowIds -notcontains $_ }
if ($missing) {
Write-Output "WARNING: source ids missing after rebuild:"
$missing | ForEach-Object { Write-Output " $_" }
exit 2
}
Write-Output "OK: all $($srcIds.Count) source entries present. total now: $($nowIds.Count)."
Write-Output "next: git add -A; git commit"

View File

@@ -0,0 +1,82 @@
#!/bin/sh
# merge-import.sh - harvest a not-yet-fragmented branch's changelog into fragments, then rebuild views.
#
# 合并旧格式分支(如 master后用它把对方旧格式 changelog 条目收割成 fragment
# (只补 entries/ 里还没有的 ID绝不覆盖已有 fragment重建四个视图并报告丢失的 ID。
#
# Usage:
# sh .cursor/changelog/tools/merge-import.sh # harvest from working-tree (post union-merge)
# sh .cursor/changelog/tools/merge-import.sh master # harvest directly from a ref
set -e
REF="${1:-}"
export PYTHONIOENCODING=utf-8
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || { echo "not inside a git repository"; exit 1; }
cd "$ROOT"
CL=".cursor/changelog"
TOOLS="$CL/tools"
if command -v python >/dev/null 2>&1; then PY=python
elif command -v python3 >/dev/null 2>&1; then PY=python3
else echo "python not found"; exit 1; fi
ids_of() {
[ -f "$1" ] || return 0
grep -oE "^### \[(CL-[^]]+)\]" "$1" | sed -E 's/^### \[(CL-[^]]+)\]/\1/' | sort -u
}
VIEWS="changelog-full.md changelog-recent.md changelog-headlines.md"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
if [ -n "$REF" ]; then
echo "harvest source: ref '$REF'"
for f in $VIEWS; do
git show "$REF:$CL/$f" > "$TMP/$f" 2>/dev/null || true
done
else
echo "harvest source: working-tree (post union-merge)"
for f in $VIEWS; do
[ -f "$CL/$f" ] && cp -f "$CL/$f" "$TMP/$f"
done
fi
SRC_IDS=$(ids_of "$TMP/changelog-full.md")
SRC_COUNT=$(printf '%s\n' "$SRC_IDS" | grep -c . || true)
echo "source entries: $SRC_COUNT"
# parse source into staging fragments (temp/entries/legacy)
"$PY" "$TOOLS/migrate_changelog.py" "$TMP" >/dev/null
# existing fragment ids in the real repo (filename stem == id)
mkdir -p "$CL/entries/legacy"
added=0
if [ -d "$TMP/entries/legacy" ]; then
for frag in "$TMP/entries/legacy"/*.md; do
[ -e "$frag" ] || continue
base=$(basename "$frag")
id="${base%.md}"
if ! find "$CL/entries" -name "$base" | grep -q .; then
cp -f "$frag" "$CL/entries/legacy/$base"
added=$((added+1))
fi
done
fi
echo "new fragments imported: $added"
# rebuild views
"$PY" "$TOOLS/changelog_build.py"
# verify
NOW_IDS=$(ids_of "$CL/changelog-full.md")
missing=$(comm -23 <(printf '%s\n' "$SRC_IDS") <(printf '%s\n' "$NOW_IDS") || true)
if [ -n "$missing" ]; then
echo "WARNING: source ids missing after rebuild:"
printf ' %s\n' $missing
exit 2
fi
NOW_COUNT=$(printf '%s\n' "$NOW_IDS" | grep -c . || true)
echo "OK: all $SRC_COUNT source entries present. total now: $NOW_COUNT."
echo "next: git add -A; git commit"

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""migrate_changelog.py — 一次性迁移:把旧的三层 changelog 文件拆成 fragment。
把现有 ``changelog-full.md`` 按 ``### [CL-...]`` 头切成一条一文件的 fragment
写到 ``entries/legacy/<id>.md``L3/L2 内容尽量从旧的 headlines/recent 中回收,
回收不到时用标题兜底。迁移后由 ``changelog_build.py`` 重新生成视图。
幂等:重复运行会覆盖 entries/legacy/ 下的同名文件。只用标准库。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
ENTRY_HEADER_RE = re.compile(r"^### \[(CL-[^\]]+)\]\s*(.*)$")
DATE_TITLE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}[^—]*?)\s*—\s*(.*)$")
ID_DIGITS_RE = re.compile(r"^CL-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})")
HEADLINE_LINE_RE = re.compile(r"^- \[(CL-[^\]]+)\]\s*(.*)$")
MERGE_TAG_RE = re.compile(r"\s*\[merge:[^\]]+\]\s*$")
TYPE_BY_TAG = {
"bugfix": "fix",
"fix": "fix",
"feature": "feat",
"feat": "feat",
"docs": "docs",
"refactor": "refactor",
"chore": "chore",
}
def split_entries(full_text: str) -> list[tuple[str, list[str]]]:
"""返回 [(id, [lines...]), ...]lines 含 header 行。"""
lines = full_text.splitlines()
entries: list[tuple[str, list[str]]] = []
cur_id: str | None = None
cur: list[str] = []
for line in lines:
m = ENTRY_HEADER_RE.match(line)
if m:
if cur_id is not None:
entries.append((cur_id, cur))
cur_id = m.group(1)
cur = [line]
elif cur_id is not None:
cur.append(line)
if cur_id is not None:
entries.append((cur_id, cur))
return entries
def parse_headlines(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for line in text.splitlines():
m = HEADLINE_LINE_RE.match(line)
if m:
body = MERGE_TAG_RE.sub("", m.group(2)).strip()
out[m.group(1)] = body
return out
def parse_recent_summaries(text: str) -> dict[str, str]:
out: dict[str, str] = {}
cur_id: str | None = None
for line in text.splitlines():
m = ENTRY_HEADER_RE.match(line)
if m:
cur_id = m.group(1)
continue
if cur_id and line.startswith("- **summary**:"):
out[cur_id] = line[len("- **summary**:"):].strip()
return out
def strip_wrap_backticks(s: str) -> str:
return re.sub(r"^`([^`]+)`$", r"\1", s.strip())
def derive_date(entry_id: str, header_rest: str) -> tuple[str, str]:
"""返回 (date, title)。"""
m = DATE_TITLE_RE.match(header_rest)
if m:
return m.group(1).strip(), m.group(2).strip()
# 无 date —— 标题就是整段date 从 id 推
dm = ID_DIGITS_RE.match(entry_id)
if dm:
y, mo, d, hh, mm = dm.groups()
return f"{y}-{mo}-{d} {hh}:{mm}", header_rest.strip()
return "", header_rest.strip()
def parse_body(lines: list[str]) -> dict[str, object]:
"""解析一个 L1 条目的 body不含 header 行)。"""
tags: list[str] = []
affected: list[str] = []
merge_source = ""
source_chat = ""
l1_lines: list[str] = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
stripped = line.strip()
if stripped.startswith("- **merge_source**:"):
merge_source = stripped[len("- **merge_source**:"):].strip()
i += 1
continue
if stripped.startswith("- **tags**:"):
raw = stripped[len("- **tags**:"):].strip()
tags = [strip_wrap_backticks(t) for t in raw.split(",") if t.strip()]
i += 1
continue
if stripped.startswith("- **affected_files**:"):
inline = stripped[len("- **affected_files**:"):].strip()
if inline:
affected = [strip_wrap_backticks(x) for x in re.split(r"[、,]", inline) if x.strip()]
i += 1
else:
i += 1
while i < n and re.match(r"^\s+-\s+", lines[i]):
item = lines[i].strip()[1:].strip()
affected.append(strip_wrap_backticks(item))
i += 1
continue
if stripped.startswith("- **source_chat**:"):
source_chat = stripped[len("- **source_chat**:"):].strip()
i += 1
continue
l1_lines.append(line)
i += 1
# 去掉首尾空行
while l1_lines and not l1_lines[0].strip():
l1_lines.pop(0)
while l1_lines and not l1_lines[-1].strip():
l1_lines.pop()
return {
"tags": tags,
"affected_files": affected,
"merge_source": merge_source,
"source_chat": source_chat,
"l1": "\n".join(l1_lines),
}
def infer_type(tags: list[str]) -> str:
for t in tags:
if t.lower() in TYPE_BY_TAG:
return TYPE_BY_TAG[t.lower()]
return ""
def fm_scalar(key: str, value: str) -> str:
return f"{key}: {value}".rstrip()
def fm_list(key: str, items: list[str]) -> str:
if not items:
return f"{key}:"
body = "\n".join(f" - {x}" for x in items)
return f"{key}:\n{body}"
def build_fragment_text(
*, fid: str, date: str, author: str, type_: str, merge_source: str,
title: str, source_chat: str, tags: list[str], affected: list[str],
l3: str, l2: str, l1: str,
) -> str:
fm = [
"---",
fm_scalar("id", fid),
fm_scalar("date", date),
fm_scalar("author", author),
fm_scalar("author_name", ""),
fm_scalar("type", type_),
fm_scalar("merge_source", merge_source),
fm_scalar("status", "active"),
fm_scalar("superseded_by", ""),
fm_scalar("title", title),
fm_scalar("source_chat", source_chat),
fm_list("tags", tags),
fm_list("affected_files", affected),
"---",
]
parts = [
"\n".join(fm),
"",
"<!-- L3 -->",
l3,
"",
"<!-- L2 -->",
l2,
"",
"<!-- L1 -->",
l1,
"",
]
return "\n".join(parts)
def main(argv: list[str] | None = None) -> int:
argv = argv if argv is not None else sys.argv[1:]
changelog_dir = Path(argv[0]) if argv else Path(__file__).resolve().parent.parent
author = argv[1] if len(argv) > 1 else "legacy"
full_path = changelog_dir / "changelog-full.md"
recent_path = changelog_dir / "changelog-recent.md"
headlines_path = changelog_dir / "changelog-headlines.md"
if not full_path.exists():
raise SystemExit(f"找不到 {full_path}")
full_text = full_path.read_text(encoding="utf-8")
headlines = parse_headlines(headlines_path.read_text(encoding="utf-8")) if headlines_path.exists() else {}
recent = parse_recent_summaries(recent_path.read_text(encoding="utf-8")) if recent_path.exists() else {}
entries = split_entries(full_text)
out_dir = changelog_dir / "entries" / author
out_dir.mkdir(parents=True, exist_ok=True)
count = 0
for fid, lines in entries:
header_rest = ENTRY_HEADER_RE.match(lines[0]).group(2)
date, title = derive_date(fid, header_rest)
parsed = parse_body(lines[1:])
l3 = headlines.get(fid, "").strip() or title
l2 = recent.get(fid, "").strip() or l3
text = build_fragment_text(
fid=fid,
date=date,
author=author,
type_=infer_type(parsed["tags"]),
merge_source=str(parsed["merge_source"]),
title=title,
source_chat=str(parsed["source_chat"]),
tags=list(parsed["tags"]),
affected=list(parsed["affected_files"]),
l3=l3,
l2=l2,
l1=str(parsed["l1"]),
)
(out_dir / f"{fid}.md").write_text(text, encoding="utf-8", newline="\n")
count += 1
print(f"迁移完成:{count} 条 → {out_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())