cursor Init 完善多人协同changelog,以及godot相关基础skill和代码规范
This commit is contained in:
336
.cursor/changelog/tools/changelog_build.py
Normal file
336
.cursor/changelog/tools/changelog_build.py
Normal 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())
|
||||
23
.cursor/changelog/tools/git-hooks/post-merge
Normal file
23
.cursor/changelog/tools/git-hooks/post-merge
Normal 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
|
||||
25
.cursor/changelog/tools/git-hooks/pre-commit
Normal file
25
.cursor/changelog/tools/git-hooks/pre-commit
Normal 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
|
||||
18
.cursor/changelog/tools/install-git-hooks.ps1
Normal file
18
.cursor/changelog/tools/install-git-hooks.ps1
Normal 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)."
|
||||
16
.cursor/changelog/tools/install-git-hooks.sh
Normal file
16
.cursor/changelog/tools/install-git-hooks.sh
Normal 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 合并后重建)。"
|
||||
96
.cursor/changelog/tools/merge-import.ps1
Normal file
96
.cursor/changelog/tools/merge-import.ps1
Normal 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"
|
||||
82
.cursor/changelog/tools/merge-import.sh
Normal file
82
.cursor/changelog/tools/merge-import.sh
Normal 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"
|
||||
252
.cursor/changelog/tools/migrate_changelog.py
Normal file
252
.cursor/changelog/tools/migrate_changelog.py
Normal 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())
|
||||
Reference in New Issue
Block a user