- сами конвенции переехали в conventions/, описательное — в корень: LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции) - conv синхронизирует только conventions/, пути в origin даются относительно неё — раскладка копий в репозиториях не меняется
517 lines
20 KiB
Python
Executable File
517 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""conv — синхронизация конвенций между каноном и репозиторием.
|
|
|
|
Канон — директория conventions/ рядом с этим скриптом. Репозиторий держит
|
|
закоммиченные копии нужных конвенций в docs/conventions/, повторяя её
|
|
структуру. Копия — источник правды для репозитория; канон — лавка, из
|
|
которой берут. Пути в origin даются относительно conventions/.
|
|
|
|
Служебная разметка копии:
|
|
|
|
---
|
|
origin: arch/time.md # откуда взято
|
|
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
|
|
synced: 2026-07-25
|
|
local: нет # или текст: чем и почему разошлись
|
|
---
|
|
|
|
Прочие ключи шапки (status, extends) — часть документа: они сравниваются
|
|
наравне с телом и приезжают из канона.
|
|
|
|
Локальные регионы — куски, которые по определению принадлежат репозиторию
|
|
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:
|
|
|
|
<!-- local:механизировано -->
|
|
...
|
|
<!-- /local -->
|
|
|
|
Имя региона обязательно: перенос при pull идёт по именам.
|
|
|
|
Команды:
|
|
conv list что есть в каноне
|
|
conv add arch/time.md [...] взять конвенцию в репозиторий
|
|
conv status состояние копий репозитория
|
|
conv diff [arch/time.md] чем копия отличается от канона
|
|
conv pull arch/time.md забрать обновление канона
|
|
conv push arch/time.md вернуть локальное улучшение в канон
|
|
conv push --new lang/go/x.md завести в каноне новую конвенцию
|
|
|
|
Везде можно указать --repo <path> (по умолчанию — текущая директория)
|
|
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime
|
|
import difflib
|
|
import hashlib
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import NoReturn
|
|
|
|
CANON = Path(__file__).resolve().parent / "conventions"
|
|
CANON_TREES = ("arch", "lang", "stack")
|
|
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
|
|
DEFAULT_DIR = "docs/conventions"
|
|
ENC = "utf-8"
|
|
|
|
# Маркеры распознаются только в начале строки: так пример разметки внутри
|
|
# текста конвенции не превращается в настоящий регион.
|
|
REGION_RE = re.compile(
|
|
r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
|
|
re.DOTALL | re.MULTILINE,
|
|
)
|
|
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
|
|
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)
|
|
|
|
|
|
def die(message: str) -> NoReturn:
|
|
print(f"conv: {message}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
# --- разметка --------------------------------------------------------------
|
|
|
|
|
|
def read(path: Path) -> str:
|
|
return path.read_text(encoding=ENC)
|
|
|
|
|
|
def write(path: Path, text: str) -> None:
|
|
path.write_text(text, encoding=ENC)
|
|
|
|
|
|
def split_front(text: str) -> tuple[dict[str, str], str]:
|
|
"""Отделяет YAML-шапку (плоский key: value) от тела."""
|
|
if not text.startswith("---\n"):
|
|
return {}, text
|
|
end = text.find("\n---\n", 4)
|
|
if end == -1:
|
|
return {}, text
|
|
meta: dict[str, str] = {}
|
|
for line in text[4:end].splitlines():
|
|
if ":" in line:
|
|
key, value = line.split(":", 1)
|
|
meta[key.strip()] = value.strip()
|
|
return meta, text[end + 5 :]
|
|
|
|
|
|
def join_front(meta: dict[str, str], body: str) -> str:
|
|
if not meta:
|
|
return body
|
|
lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
|
|
return f"---\n{lines}\n---\n{body}"
|
|
|
|
|
|
def doc_keys(meta: dict[str, str]) -> dict[str, str]:
|
|
return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}
|
|
|
|
|
|
def regions(body: str) -> dict[str, str]:
|
|
"""Содержимое локальных регионов по имени.
|
|
|
|
Поднимает ValueError на разметке, из-за которой регион молча превратился
|
|
бы в обычный текст и потерялся при pull.
|
|
"""
|
|
matched = len(REGION_RE.findall(body))
|
|
if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
|
|
raise ValueError("непарный или нераспознанный маркер локального региона")
|
|
found: dict[str, str] = {}
|
|
for match in REGION_RE.finditer(body):
|
|
name = (match.group(1) or "").strip()
|
|
content = match.group(2)
|
|
if not name:
|
|
if content.strip():
|
|
raise ValueError(
|
|
"безымянный локальный регион с содержимым — дай ему имя"
|
|
)
|
|
continue
|
|
if name in found:
|
|
raise ValueError(f"локальный регион '{name}' встречается дважды")
|
|
found[name] = content
|
|
return found
|
|
|
|
|
|
def checked_regions(body: str, where: str) -> dict[str, str]:
|
|
try:
|
|
return regions(body)
|
|
except ValueError as exc:
|
|
die(f"{where}: {exc}")
|
|
|
|
|
|
def blank_regions(body: str) -> str:
|
|
"""Тело с опустошёнными локальными регионами — то, что сравнивается."""
|
|
|
|
def repl(match: re.Match[str]) -> str:
|
|
raw = (match.group(1) or "").strip()
|
|
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
|
|
return f"{head}\n<!-- /local -->"
|
|
|
|
return REGION_RE.sub(repl, body)
|
|
|
|
|
|
def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
|
|
"""Вставляет содержимое регионов по имени. Возвращает тело и имена,
|
|
которым не нашлось места."""
|
|
used: set[str] = set()
|
|
|
|
def repl(match: re.Match[str]) -> str:
|
|
raw = (match.group(1) or "").strip()
|
|
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
|
|
if raw in values:
|
|
used.add(raw)
|
|
return f"{head}{values[raw]}<!-- /local -->"
|
|
return match.group(0)
|
|
|
|
filled = REGION_RE.sub(repl, body)
|
|
lost = [n for n, v in values.items() if n not in used and v.strip()]
|
|
return filled, lost
|
|
|
|
|
|
def fingerprint(meta: dict[str, str], body: str) -> str:
|
|
"""Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
|
|
head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
|
|
return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
|
|
:8
|
|
]
|
|
|
|
|
|
def today() -> str:
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
# --- канон и репозиторий ---------------------------------------------------
|
|
|
|
|
|
def canon_list() -> list[str]:
|
|
out: list[str] = []
|
|
for tree in CANON_TREES:
|
|
root = CANON / tree
|
|
if root.is_dir():
|
|
out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
|
|
return out
|
|
|
|
|
|
def canon_read(origin: str) -> tuple[dict[str, str], str]:
|
|
path = CANON / origin
|
|
if not path.is_file():
|
|
die(f"в каноне нет {origin}")
|
|
return split_front(read(path))
|
|
|
|
|
|
def normalize_origin(name: str, *, must_exist: bool = True) -> str:
|
|
"""Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
|
|
name = name.strip("/")
|
|
if not name.endswith(".md"):
|
|
name += ".md"
|
|
candidate = (CANON / name).resolve()
|
|
if candidate.is_relative_to(CANON):
|
|
rel = str(candidate.relative_to(CANON))
|
|
if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
|
|
return rel
|
|
matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if not matches:
|
|
die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
|
|
die(f"неоднозначно: {name} → {', '.join(matches)}")
|
|
|
|
|
|
def repo_dir(args: argparse.Namespace) -> Path:
|
|
return (Path(str(args.repo)) / str(args.dir)).resolve()
|
|
|
|
|
|
def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
|
|
"""origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
|
|
found: dict[str, Path] = {}
|
|
untracked: list[Path] = []
|
|
problems: list[str] = []
|
|
if not base.is_dir():
|
|
return found, untracked, problems
|
|
for path in sorted(base.rglob("*.md")):
|
|
try:
|
|
meta, _ = split_front(read(path))
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
problems.append(f"{path.name}: не читается ({type(exc).__name__})")
|
|
continue
|
|
origin = meta.get("origin")
|
|
if not origin:
|
|
if path.name != "README.md":
|
|
untracked.append(path)
|
|
continue
|
|
if origin in found:
|
|
problems.append(
|
|
f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
|
|
)
|
|
continue
|
|
found[origin] = path
|
|
return found, untracked, problems
|
|
|
|
|
|
def locate(args: argparse.Namespace, origin: str) -> Path:
|
|
"""Путь копии: по шапке, если она лежит не по канонному пути."""
|
|
base = repo_dir(args)
|
|
copies, _, _ = repo_copies(base)
|
|
return copies.get(origin, base / origin)
|
|
|
|
|
|
# --- состояние -------------------------------------------------------------
|
|
|
|
|
|
def state(
|
|
meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
|
|
) -> str:
|
|
copy_fp = fingerprint(meta, body)
|
|
canon_fp = fingerprint(canon_meta, canon_body)
|
|
if copy_fp == canon_fp:
|
|
return "ok"
|
|
base = meta.get("origin_hash")
|
|
if not base:
|
|
return "нет origin_hash в шапке"
|
|
if base == canon_fp:
|
|
return "изменено локально"
|
|
if base == copy_fp:
|
|
return "канон обновился"
|
|
return "разошлись"
|
|
|
|
|
|
# --- команды ---------------------------------------------------------------
|
|
|
|
|
|
def cmd_list(args: argparse.Namespace) -> int:
|
|
for origin in canon_list():
|
|
meta, _ = canon_read(origin)
|
|
marks = []
|
|
if "extends" in meta:
|
|
marks.append(f"расширяет {meta['extends']}")
|
|
if "status" in meta:
|
|
marks.append(meta["status"])
|
|
tail = f" ({'; '.join(marks)})" if marks else ""
|
|
print(f"{origin}{tail}")
|
|
return 0
|
|
|
|
|
|
def cmd_add(args: argparse.Namespace) -> int:
|
|
base = repo_dir(args)
|
|
added = False
|
|
for raw in args.names:
|
|
origin = normalize_origin(raw)
|
|
target = base / origin
|
|
if target.exists():
|
|
print(f"{origin}: уже есть ({target}), пропускаю")
|
|
continue
|
|
canon_meta, canon_body = canon_read(origin)
|
|
checked_regions(canon_body, f"канон/{origin}")
|
|
meta: dict[str, str] = {
|
|
"origin": origin,
|
|
"origin_hash": fingerprint(canon_meta, canon_body),
|
|
"synced": today(),
|
|
"local": "нет",
|
|
}
|
|
meta.update(doc_keys(canon_meta))
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
write(target, join_front(meta, canon_body))
|
|
added = True
|
|
print(f"{origin} → {target}")
|
|
if "extends" in canon_meta:
|
|
print(f" расширяет {canon_meta['extends']} — возможно, нужна и она")
|
|
if added:
|
|
print("не забудь строку в docs/conventions/README.md")
|
|
return 0
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace) -> int:
|
|
base = repo_dir(args)
|
|
copies, untracked, problems = repo_copies(base)
|
|
if not copies and not untracked and not problems:
|
|
print(f"в {base} нет копий конвенций")
|
|
return 0
|
|
width = max((len(o) for o in copies), default=0)
|
|
for origin, path in copies.items():
|
|
try:
|
|
meta, body = split_front(read(path))
|
|
except (OSError, UnicodeDecodeError) as exc:
|
|
print(f"{origin:<{width}} не читается ({type(exc).__name__})")
|
|
continue
|
|
if not (CANON / origin).is_file():
|
|
print(f"{origin:<{width}} нет в каноне")
|
|
continue
|
|
canon_meta, canon_body = canon_read(origin)
|
|
try:
|
|
regions(body)
|
|
except ValueError as exc:
|
|
print(f"{origin:<{width}} разметка: {exc}")
|
|
continue
|
|
local = meta.get("local", "нет")
|
|
note = "" if local == "нет" else f" [{local}]"
|
|
print(f"{origin:<{width}} {state(meta, body, canon_meta, canon_body)}{note}")
|
|
for path in untracked:
|
|
print(f"{path.name}: без шапки origin — не отслеживается")
|
|
for problem in problems:
|
|
print(problem)
|
|
return 0
|
|
|
|
|
|
def cmd_diff(args: argparse.Namespace) -> int:
|
|
base = repo_dir(args)
|
|
copies, _, _ = repo_copies(base)
|
|
targets = [normalize_origin(args.name)] if args.name else list(copies)
|
|
for origin in targets:
|
|
path = copies.get(origin)
|
|
if path is None:
|
|
print(f"{origin}: нет копии в репозитории")
|
|
continue
|
|
if not (CANON / origin).is_file():
|
|
print(f"{origin}: нет в каноне")
|
|
continue
|
|
meta, body = split_front(read(path))
|
|
canon_meta, canon_body = canon_read(origin)
|
|
if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
|
|
continue
|
|
sys.stdout.writelines(
|
|
difflib.unified_diff(
|
|
join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
|
|
keepends=True
|
|
),
|
|
join_front(doc_keys(meta), blank_regions(body)).splitlines(
|
|
keepends=True
|
|
),
|
|
fromfile=f"канон/{origin}",
|
|
tofile=f"репо/{origin}",
|
|
)
|
|
)
|
|
return 0
|
|
|
|
|
|
def cmd_pull(args: argparse.Namespace) -> int:
|
|
origin = normalize_origin(args.name)
|
|
path = locate(args, origin)
|
|
if not path.is_file():
|
|
die(f"нет копии {origin} — сначала conv add {origin}")
|
|
meta, body = split_front(read(path))
|
|
canon_meta, canon_body = canon_read(origin)
|
|
checked_regions(canon_body, f"канон/{origin}")
|
|
local = checked_regions(body, f"репо/{origin}")
|
|
st = state(meta, body, canon_meta, canon_body)
|
|
if st == "ok":
|
|
fresh = fingerprint(canon_meta, canon_body)
|
|
if meta.get("origin_hash") != fresh:
|
|
meta["origin_hash"] = fresh
|
|
meta["synced"] = today()
|
|
write(path, join_front(meta, body))
|
|
print(f"{origin}: тексты совпадают, отпечаток освежён")
|
|
else:
|
|
print(f"{origin}: уже совпадает")
|
|
return 0
|
|
if st in ("изменено локально", "разошлись") and not args.force:
|
|
die(
|
|
f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
|
|
f" посмотри conv diff {origin}, затем conv pull --force "
|
|
f"или conv push {origin}"
|
|
)
|
|
merged, lost = fill_regions(canon_body, local)
|
|
if lost and not args.force:
|
|
die(
|
|
f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
|
|
f"пропадёт.\n перенеси вручную или conv pull --force"
|
|
)
|
|
for name in lost:
|
|
print(f" потерян локальный регион {name}")
|
|
new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
|
|
new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
|
|
new_meta["synced"] = today()
|
|
new_meta.update(doc_keys(canon_meta))
|
|
write(path, join_front(new_meta, merged))
|
|
print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
|
|
return 0
|
|
|
|
|
|
def cmd_push(args: argparse.Namespace) -> int:
|
|
origin = normalize_origin(args.name, must_exist=not args.new)
|
|
path = locate(args, origin)
|
|
if not path.is_file():
|
|
die(f"нет копии {origin}")
|
|
meta, body = split_front(read(path))
|
|
checked_regions(body, f"репо/{origin}")
|
|
target = CANON / origin
|
|
if not target.is_file():
|
|
if not args.new:
|
|
die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
write(target, join_front(doc_keys(meta), blank_regions(body)))
|
|
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
|
|
meta["synced"] = today()
|
|
write(path, join_front(meta, body))
|
|
print(f"{origin}: заведена в каноне")
|
|
return 0
|
|
canon_meta, canon_body = canon_read(origin)
|
|
st = state(meta, body, canon_meta, canon_body)
|
|
if st == "ok":
|
|
print(f"{origin}: канон уже такой")
|
|
return 0
|
|
if st == "канон обновился":
|
|
die(
|
|
f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
|
|
)
|
|
if st == "разошлись" and not args.force:
|
|
die(
|
|
f"{origin}: разошлись — канон менялся после синхронизации, "
|
|
f"его правки затрутся.\n посмотри conv diff {origin}, "
|
|
f"затем conv push --force"
|
|
)
|
|
write(target, join_front(doc_keys(meta), blank_regions(body)))
|
|
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
|
|
meta["synced"] = today()
|
|
write(path, join_front(meta, body))
|
|
print(f"{origin}: канон обновлён из репозитория")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
common.add_argument("--repo", default=".", help="корень репозитория")
|
|
common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")
|
|
|
|
parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
|
|
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
|
|
sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
|
|
fn=cmd_list
|
|
)
|
|
|
|
p_add = sub.add_parser(
|
|
"add", parents=[common], help="взять конвенцию в репозиторий"
|
|
)
|
|
p_add.add_argument("names", nargs="+")
|
|
p_add.set_defaults(fn=cmd_add)
|
|
|
|
sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
|
|
fn=cmd_status
|
|
)
|
|
|
|
p_diff = sub.add_parser(
|
|
"diff", parents=[common], help="чем копия отличается от канона"
|
|
)
|
|
p_diff.add_argument("name", nargs="?")
|
|
p_diff.set_defaults(fn=cmd_diff)
|
|
|
|
p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
|
|
p_pull.add_argument("name")
|
|
p_pull.add_argument("--force", action="store_true")
|
|
p_pull.set_defaults(fn=cmd_pull)
|
|
|
|
p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
|
|
p_push.add_argument("name")
|
|
p_push.add_argument("--force", action="store_true")
|
|
p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
|
|
p_push.set_defaults(fn=cmd_push)
|
|
|
|
args = parser.parse_args()
|
|
return int(args.fn(args))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|