#!/usr/bin/env python3 """conv — синхронизация конвенций между каноном и репозиторием. Канон — эта директория. Репозиторий держит закоммиченные копии нужных конвенций в docs/conventions/, повторяя структуру канона. Копия — источник правды для репозитория; канон — лавка, из которой берут. Служебная разметка копии: --- origin: arch/time.md # откуда взято origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации synced: 2026-07-25 local: нет # или текст: чем и почему разошлись --- Прочие ключи шапки (status, extends) — часть документа: они сравниваются наравне с телом и приезжают из канона. Локальные регионы — куски, которые по определению принадлежат репозиторию (механизация, отступления, «здесь решили так»). Из сравнения исключаются: ... Имя региона обязательно: перенос при 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 (по умолчанию — текущая директория) и --dir (по умолчанию 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 CANON_TREES = ("common", "arch", "lang", "stack") SERVICE_KEYS = ("origin", "origin_hash", "synced", "local") DEFAULT_DIR = "docs/conventions" ENC = "utf-8" # Маркеры распознаются только в начале строки: так пример разметки внутри # текста конвенции не превращается в настоящий регион. REGION_RE = re.compile( r"^(.*?)^", re.DOTALL | re.MULTILINE, ) OPEN_RE = re.compile(r"^" if raw else "" return f"{head}\n" 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"" if raw else "" if raw in values: used.add(raw) return f"{head}{values[raw]}" 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())