From 6737a27fa0d86ba0b9952612cf8023665929422e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Tue, 18 Aug 2026 13:26:19 +0200 Subject: [PATCH] Put the language in design document names and add a sync mode --- .gitignore | 1 + CHANGELOG.md | 26 ++ Claude/AGENTS.md | 21 +- Claude/CLAUDE.md | 4 +- ..._DOCUMENT.md => DESIGN_DOCUMENT_PYTHON.md} | 4 +- ...LE.md => DESIGN_DOCUMENT_PYTHON_MODULE.md} | 4 +- Python/TEMPLATE.md | 2 +- README.md | 91 ++++--- ...GN_DOCUMENT.md => DESIGN_DOCUMENT_RUST.md} | 4 +- ...ENT_LIB.md => DESIGN_DOCUMENT_RUST_LIB.md} | 4 +- check_versions.py | 234 +++++++++++++++--- 11 files changed, 306 insertions(+), 89 deletions(-) create mode 100644 .gitignore rename Python/{DESIGN_DOCUMENT.md => DESIGN_DOCUMENT_PYTHON.md} (98%) rename Python/{DESIGN_DOCUMENT_MODULE.md => DESIGN_DOCUMENT_PYTHON_MODULE.md} (98%) rename Rust/{DESIGN_DOCUMENT.md => DESIGN_DOCUMENT_RUST.md} (98%) rename Rust/{DESIGN_DOCUMENT_LIB.md => DESIGN_DOCUMENT_RUST_LIB.md} (98%) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba0430d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b195b1..60f0bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ ships documents, so it uses the same `v#` scheme as the documents themselves. Individual guideline documents carry their own `Document Version` header, and the skills carry `metadata.version`; both are independent of the revisions below. +## v6 — 2026-08-18 + +### Added +- `--sync` in `check_versions.py` — offers to overwrite the outdated copies with the master, after + listing them and asking for confirmation (`--yes` skips the prompt, `--dry-run` writes nothing). + Each file keeps the line endings it already used, so a version bump stays a one-line diff on + Windows and Linux alike, and the write goes through a backup that is restored if it fails. + A `MODIFIED` copy is never overwritten — it holds a local edit that copying would discard +- `.gitignore` at the repository root for `__pycache__/` + +### Changed +- **The design documents are renamed so the language is part of the file name**: + `DESIGN_DOCUMENT.md` → `DESIGN_DOCUMENT_PYTHON.md` / `DESIGN_DOCUMENT_RUST.md`, + `DESIGN_DOCUMENT_MODULE.md` → `DESIGN_DOCUMENT_PYTHON_MODULE.md`, + `DESIGN_DOCUMENT_LIB.md` → `DESIGN_DOCUMENT_RUST_LIB.md`. Python and Rust applications used to + share one file name, which only worked because the folder here disambiguated them — and the folder + does not travel with the copy that lands in a project root. A project can now carry one design + document per language, and `check_versions.py` no longer has to guess the language from + `Cargo.toml`. Bumped: `DESIGN_DOCUMENT_PYTHON.md` v10 → v11, `DESIGN_DOCUMENT_PYTHON_MODULE.md` + v3 → v4, `DESIGN_DOCUMENT_RUST.md` v3 → v4, `DESIGN_DOCUMENT_RUST_LIB.md` v3 → v4, + `AGENTS.md` v7 → v8, `CLAUDE.md` v2 → v3 +- `check_versions.py` recognises the pre-rename file names, reports them as `OLD NAME`, and renames + them during `--sync`. The `Cargo.toml` check survives only to disambiguate legacy + `DESIGN_DOCUMENT.md` copies and can be deleted once every project is migrated. A project holding + both the old and the new name is reported as `DUPLICATE` and never touched + ## v5 — 2026-08-18 ### Added diff --git a/Claude/AGENTS.md b/Claude/AGENTS.md index 193f0a6..933fa33 100644 --- a/Claude/AGENTS.md +++ b/Claude/AGENTS.md @@ -1,19 +1,22 @@ # AI Agents - Project Rules -**Document Version:** v7 (independent, incremented on structural changes) +**Document Version:** v8 (independent, incremented on structural changes) Language-agnostic rules for AI assistants (Claude Code, Cursor, Copilot, etc.). Everything language-specific — package manager, formatter, linter, test framework, logging library, line length, project layout — lives in the matching design document: -| Project type | Design document | -|--------------------|------------------------------| -| Python application | `DESIGN_DOCUMENT.md` | -| Python library | `DESIGN_DOCUMENT_MODULE.md` | -| Rust application | `DESIGN_DOCUMENT.md` | -| Rust library | `DESIGN_DOCUMENT_LIB.md` | -| Godot | `DESIGN_DOCUMENT_GODOT.md` | +| Project type | Design document | +|--------------------|------------------------------------| +| Python application | `DESIGN_DOCUMENT_PYTHON.md` | +| Python library | `DESIGN_DOCUMENT_PYTHON_MODULE.md` | +| Rust application | `DESIGN_DOCUMENT_RUST.md` | +| Rust library | `DESIGN_DOCUMENT_RUST_LIB.md` | +| Godot | `DESIGN_DOCUMENT_GODOT.md` | + +The language is part of the file name, so a project that mixes languages carries one design document +per language and nothing collides. Where this file and a design document disagree, **the design document wins**. @@ -67,7 +70,7 @@ Where this file and a design document disagree, **the design document wins**. - **Commit the lock file for applications, do not commit it for libraries** - **Every core document is committed**, including the shared ones (`CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT*.md`). A checkout then carries the rules that applied to that code, and a fresh clone works standalone. - **Never edit a shared document inside a project** — it is a copy. Changes belong in the documentation repository and are copied outward; project-specific deviations go in `PROJECT.md`. -- Synchronising the copies is its own commit (`docs: sync guidelines to AGENTS v7 / DESIGN_DOCUMENT v10`) and is **not** recorded in the project `CHANGELOG.md` — it is not a change to the product +- Synchronising the copies is its own commit (`docs: sync guidelines to AGENTS v8 / DESIGN_DOCUMENT_PYTHON v11`) and is **not** recorded in the project `CHANGELOG.md` — it is not a change to the product ### Commit messages diff --git a/Claude/CLAUDE.md b/Claude/CLAUDE.md index c4aa103..303fe6a 100644 --- a/Claude/CLAUDE.md +++ b/Claude/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -**Document Version:** v2 (independent, incremented on structural changes) +**Document Version:** v3 (independent, incremented on structural changes) ## First-time setup @@ -11,4 +11,4 @@ - `AGENTS.md` - `PROJECT.md` - `CHANGELOG.md` -- `DESIGN_DOCUMENT*.md` (`DESIGN_DOCUMENT.md`, `DESIGN_DOCUMENT_MODULE.md`, `DESIGN_DOCUMENT_LIB.md` or `DESIGN_DOCUMENT_GODOT.md`) +- `DESIGN_DOCUMENT*.md` — one per language used in the project (`DESIGN_DOCUMENT_PYTHON.md`, `DESIGN_DOCUMENT_PYTHON_MODULE.md`, `DESIGN_DOCUMENT_RUST.md`, `DESIGN_DOCUMENT_RUST_LIB.md`, `DESIGN_DOCUMENT_GODOT.md`) diff --git a/Python/DESIGN_DOCUMENT.md b/Python/DESIGN_DOCUMENT_PYTHON.md similarity index 98% rename from Python/DESIGN_DOCUMENT.md rename to Python/DESIGN_DOCUMENT_PYTHON.md index 9a6739e..cee3199 100644 --- a/Python/DESIGN_DOCUMENT.md +++ b/Python/DESIGN_DOCUMENT_PYTHON.md @@ -1,6 +1,6 @@ # Python Development Guidelines -**Document Version:** v10 +**Document Version:** v11 > **Note on Versioning:** > - This document version is independent — reused across projects @@ -19,7 +19,7 @@ All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root. -The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT.md`, `PROJECT.md`, `CHANGELOG.md`. +The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`. --- diff --git a/Python/DESIGN_DOCUMENT_MODULE.md b/Python/DESIGN_DOCUMENT_PYTHON_MODULE.md similarity index 98% rename from Python/DESIGN_DOCUMENT_MODULE.md rename to Python/DESIGN_DOCUMENT_PYTHON_MODULE.md index a89f79d..590a799 100644 --- a/Python/DESIGN_DOCUMENT_MODULE.md +++ b/Python/DESIGN_DOCUMENT_PYTHON_MODULE.md @@ -1,6 +1,6 @@ # Python Library Development Guidelines -**Document Version:** v3 +**Document Version:** v4 > **Note on Versioning:** > - This document version is independent — reused across projects @@ -18,7 +18,7 @@ All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root. -The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_MODULE.md`, `PROJECT.md`, `CHANGELOG.md`. +The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON_MODULE.md`, `PROJECT.md`, `CHANGELOG.md`. --- diff --git a/Python/TEMPLATE.md b/Python/TEMPLATE.md index 28104d7..b999a2c 100644 --- a/Python/TEMPLATE.md +++ b/Python/TEMPLATE.md @@ -26,7 +26,7 @@ template/ └── test_constants.py # Basic test ``` -`CLAUDE.md`, `AGENTS.md` and `DESIGN_DOCUMENT.md` are **not** part of the template — they are copied +`CLAUDE.md`, `AGENTS.md` and `DESIGN_DOCUMENT_PYTHON.md` are **not** part of the template — they are copied into the project root from the documentation repository, which stays their source of truth. The copies themselves **are committed** with the project and are never edited in place. diff --git a/README.md b/README.md index 25a86a3..79bd684 100644 --- a/README.md +++ b/README.md @@ -10,45 +10,45 @@ This repository is the single source of truth for how I develop software. AI ass ``` Dokumentace/ -├── Claude/ # AI assistant configuration -│ ├── CLAUDE.md # Claude Code session instructions -│ ├── AGENTS.md # Language-agnostic rules for all AI assistants -│ └── skills/ # Custom Claude Code skills -│ ├── changelog/ # /changelog — update CHANGELOG.md -│ ├── check/ # /check — run tests, investigate failures -│ ├── commit/ # /commit — changelog + commit message proposal -│ ├── documentation/ # /documentation — write docs/ -│ └── documentation_context/ # /documentation_context — compress docs/ into CONTEXT.md +├── Claude/ # AI assistant configuration +│ ├── CLAUDE.md # Claude Code session instructions +│ ├── AGENTS.md # Language-agnostic rules for all AI assistants +│ └── skills/ # Custom Claude Code skills +│ ├── changelog/ # /changelog — update CHANGELOG.md +│ ├── check/ # /check — run tests, investigate failures +│ ├── commit/ # /commit — changelog + commit message proposal +│ ├── documentation/ # /documentation — write docs/ +│ └── documentation_context/ # /documentation_context — compress docs/ into CONTEXT.md │ -├── Python/ # Python development guidelines -│ ├── DESIGN_DOCUMENT.md # Guidelines for Python applications -│ ├── DESIGN_DOCUMENT_MODULE.md # Guidelines for Python libraries -│ ├── TEMPLATE.md # New project template specification -│ ├── .gitignore # Python-specific ignore rules -│ ├── prebuild.py # Pre-build script (PyInstaller) -│ ├── src/ # Reference constants module + version fallback -│ └── tests/ # Tests for the reference module +├── Python/ # Python development guidelines +│ ├── DESIGN_DOCUMENT_PYTHON.md # Guidelines for Python applications +│ ├── DESIGN_DOCUMENT_PYTHON_MODULE.md # Guidelines for Python libraries +│ ├── TEMPLATE.md # New project template specification +│ ├── .gitignore # Python-specific ignore rules +│ ├── prebuild.py # Pre-build script (PyInstaller) +│ ├── src/ # Reference constants module + version fallback +│ └── tests/ # Tests for the reference module │ -├── Rust/ # Rust development guidelines -│ ├── DESIGN_DOCUMENT.md # Guidelines for Rust applications -│ ├── DESIGN_DOCUMENT_LIB.md # Guidelines for Rust libraries -│ └── .gitignore # Rust-specific ignore rules +├── Rust/ # Rust development guidelines +│ ├── DESIGN_DOCUMENT_RUST.md # Guidelines for Rust applications +│ ├── DESIGN_DOCUMENT_RUST_LIB.md # Guidelines for Rust libraries +│ └── .gitignore # Rust-specific ignore rules │ -├── Godot/ # Godot development guidelines -│ ├── DESIGN_DOCUMENT_GODOT.md # Guidelines for Godot projects -│ └── .gitignore # Godot-specific ignore rules +├── Godot/ # Godot development guidelines +│ ├── DESIGN_DOCUMENT_GODOT.md # Guidelines for Godot projects +│ └── .gitignore # Godot-specific ignore rules │ -├── Project template/ # Reusable files for new projects -│ ├── CHANGELOG.md # Changelog template -│ ├── PROJECT.md # Project documentation template -│ └── .gitignore # Shared, language-agnostic ignore rules +├── Project template/ # Reusable files for new projects +│ ├── CHANGELOG.md # Changelog template +│ ├── PROJECT.md # Project documentation template +│ └── .gitignore # Shared, language-agnostic ignore rules │ -├── Zscaler/ # Corporate network setup -│ ├── ZSCALER_CERTIFICATE.md # Cert setup for Node, Python, git, cargo +├── Zscaler/ # Corporate network setup +│ ├── ZSCALER_CERTIFICATE.md # Cert setup for Node, Python, git, cargo │ └── ZscalerRootCertificate-2048-SHA256.crt │ -├── check_versions.py # Reports projects whose guideline copies are behind -└── CHANGELOG.md # History of this repository +├── check_versions.py # Reports projects whose guideline copies are behind +└── CHANGELOG.md # History of this repository ``` ## AI Coding Workflow @@ -62,10 +62,10 @@ Where the two disagree, **the design document wins**. | Project type | Design document | |--------------|-----------------| -| Python (application) | `Python/DESIGN_DOCUMENT.md` | -| Python (library) | `Python/DESIGN_DOCUMENT_MODULE.md` | -| Rust (application) | `Rust/DESIGN_DOCUMENT.md` | -| Rust (library) | `Rust/DESIGN_DOCUMENT_LIB.md` | +| Python (application) | `Python/DESIGN_DOCUMENT_PYTHON.md` | +| Python (library) | `Python/DESIGN_DOCUMENT_PYTHON_MODULE.md` | +| Rust (application) | `Rust/DESIGN_DOCUMENT_RUST.md` | +| Rust (library) | `Rust/DESIGN_DOCUMENT_RUST_LIB.md` | | Godot | `Godot/DESIGN_DOCUMENT_GODOT.md` | ### Starting a new project @@ -84,7 +84,7 @@ are **committed there**. They are duplicated on purpose: guidelines are always "latest" and the pairing is lost. - A fresh clone — another machine, CI, Claude Code on the web or a remote agent — has to work standalone. `CLAUDE.md` and `AGENTS.md` are only picked up from the repository root. -- Drift becomes visible: `git log DESIGN_DOCUMENT.md` shows when a project was last synchronised. +- Drift becomes visible: `git log DESIGN_DOCUMENT_PYTHON.md` shows when a project was last synchronised. Three rules keep the copies from rotting: @@ -93,10 +93,25 @@ Three rules keep the copies from rotting: session read list. That is also where extra files to read (for example a generated `CONTEXT.md`) are named. - **Sync commits stand alone**, in the form - `docs: sync guidelines to AGENTS v6 / DESIGN_DOCUMENT v9`. They do not go into the project + `docs: sync guidelines to AGENTS v8 / DESIGN_DOCUMENT_PYTHON v11`. They do not go into the project `CHANGELOG.md` — they are not a change to the product. Run `check_versions.py` from this repository to list every sibling project whose copies are behind. +Add `--sync` and it offers to overwrite the outdated copies with the master version, keeping each +file's existing line endings. Only copies that are purely behind or still carry a pre-rename name are +offered: one edited in place is reported as `MODIFIED` and left alone, because copying would silently +discard the local change. + +A project still holding `DESIGN_DOCUMENT.md`, `DESIGN_DOCUMENT_MODULE.md` or `DESIGN_DOCUMENT_LIB.md` +from before the rename is reported as `OLD NAME`, and `--sync` renames it on the way. If both the old +and the new name exist the copy is flagged `DUPLICATE` and left for you to resolve. + +```bash +python check_versions.py # report only +python check_versions.py --sync # report, then ask before copying +python check_versions.py --sync --dry-run # show what would be copied, change nothing +python check_versions.py --sync --yes # copy without asking, for scripts +``` ### Skills diff --git a/Rust/DESIGN_DOCUMENT.md b/Rust/DESIGN_DOCUMENT_RUST.md similarity index 98% rename from Rust/DESIGN_DOCUMENT.md rename to Rust/DESIGN_DOCUMENT_RUST.md index 6194101..bc5955c 100644 --- a/Rust/DESIGN_DOCUMENT.md +++ b/Rust/DESIGN_DOCUMENT_RUST.md @@ -1,6 +1,6 @@ # Rust Application Development Guidelines -**Document Version:** v3 +**Document Version:** v4 > **Note on Versioning:** > - This document version is independent — reused across projects @@ -18,7 +18,7 @@ All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root. -The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT.md`, `PROJECT.md`, `CHANGELOG.md`. +The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_RUST.md`, `PROJECT.md`, `CHANGELOG.md`. --- diff --git a/Rust/DESIGN_DOCUMENT_LIB.md b/Rust/DESIGN_DOCUMENT_RUST_LIB.md similarity index 98% rename from Rust/DESIGN_DOCUMENT_LIB.md rename to Rust/DESIGN_DOCUMENT_RUST_LIB.md index a0df25b..f11ef23 100644 --- a/Rust/DESIGN_DOCUMENT_LIB.md +++ b/Rust/DESIGN_DOCUMENT_RUST_LIB.md @@ -1,6 +1,6 @@ # Rust Library Development Guidelines -**Document Version:** v3 +**Document Version:** v4 > **Note on Versioning:** > - This document version is independent — reused across projects @@ -20,7 +20,7 @@ All detailed documentation of features and systems belongs in the `docs/` folder The API reference is generated by `cargo doc` from doc comments — `docs/` holds the prose documentation that does not fit in doc comments. -The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_LIB.md`, `PROJECT.md`, `CHANGELOG.md`. +The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_RUST_LIB.md`, `PROJECT.md`, `CHANGELOG.md`. --- diff --git a/check_versions.py b/check_versions.py index 083b4fc..54cd653 100644 --- a/check_versions.py +++ b/check_versions.py @@ -1,4 +1,4 @@ -"""Report sibling projects whose copies of the guideline documents are out of date. +"""Report sibling projects whose copies of the guideline documents are out of date, and offer to sync them. This repository is the source of truth for ``CLAUDE.md``, ``AGENTS.md`` and the ``DESIGN_DOCUMENT*.md`` family. Every project keeps a committed copy in its root, so the copies @@ -6,9 +6,20 @@ drift as soon as a document here is bumped. This script walks the sibling direct repository, reads the ``Document Version`` header of every copy it finds and reports the ones that are behind, missing, or modified locally. +It also finds copies still using a pre-rename file name (``DESIGN_DOCUMENT.md`` and friends, from +before the language became part of the name) and reports them as ``OLD NAME``. + +``--sync`` offers to overwrite the outdated copies with the master version, renaming the old names on +the way. Only ``BEHIND`` and ``OLD NAME`` copies are ever touched: a ``MODIFIED`` copy holds local +edits that overwriting would discard, an unversioned copy cannot be compared, and ``DUPLICATE`` means +both the old and the new name exist, which needs a human to decide. + Standard library only, so it runs without a virtual environment: - python check_versions.py + python check_versions.py # report only + python check_versions.py --sync # report, then offer to copy the outdated ones + python check_versions.py --sync --yes # copy without asking (for scripts) + python check_versions.py --sync --dry-run # show what --sync would copy, change nothing python check_versions.py --root .. # scan a different directory python check_versions.py --quiet # print only projects needing attention """ @@ -16,24 +27,37 @@ Standard library only, so it runs without a virtual environment: from __future__ import annotations import argparse +import os import re +import shutil import sys from dataclasses import dataclass from pathlib import Path -# Guideline documents owned by this repository, mapped to their master copy. +# Guideline documents owned by this repository, mapped to their master copy. The language is part of +# every design document name, so a project may carry several of them without any collision. MASTERS: dict[str, str] = { "CLAUDE.md": "Claude/CLAUDE.md", "AGENTS.md": "Claude/AGENTS.md", - "DESIGN_DOCUMENT.md": "Python/DESIGN_DOCUMENT.md", - "DESIGN_DOCUMENT_MODULE.md": "Python/DESIGN_DOCUMENT_MODULE.md", - "DESIGN_DOCUMENT_LIB.md": "Rust/DESIGN_DOCUMENT_LIB.md", + "DESIGN_DOCUMENT_PYTHON.md": "Python/DESIGN_DOCUMENT_PYTHON.md", + "DESIGN_DOCUMENT_PYTHON_MODULE.md": "Python/DESIGN_DOCUMENT_PYTHON_MODULE.md", + "DESIGN_DOCUMENT_RUST.md": "Rust/DESIGN_DOCUMENT_RUST.md", + "DESIGN_DOCUMENT_RUST_LIB.md": "Rust/DESIGN_DOCUMENT_RUST_LIB.md", "DESIGN_DOCUMENT_GODOT.md": "Godot/DESIGN_DOCUMENT_GODOT.md", } -# A Rust application uses the same file name as a Python one; the language decides which master -# applies. Detected from the project manifest. -RUST_OVERRIDES: dict[str, str] = {"DESIGN_DOCUMENT.md": "Rust/DESIGN_DOCUMENT.md"} +# Names used before the language became part of the file name. A project still carrying one of these +# is reported as LEGACY and renamed by --sync. This mapping is migration code: once every project has +# been synchronised it can be deleted, and with it the last place where the old collision matters. +LEGACY_NAMES: dict[str, str] = { + "DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_PYTHON.md", + "DESIGN_DOCUMENT_MODULE.md": "DESIGN_DOCUMENT_PYTHON_MODULE.md", + "DESIGN_DOCUMENT_LIB.md": "DESIGN_DOCUMENT_RUST_LIB.md", +} + +# The old DESIGN_DOCUMENT.md is ambiguous on its own - Python and Rust applications shared the name. +# Only the project manifest can tell them apart, and only for legacy copies. +LEGACY_RUST_NAMES: dict[str, str] = {"DESIGN_DOCUMENT.md": "DESIGN_DOCUMENT_RUST.md"} VERSION_PATTERN = re.compile(r"^\*\*Document Version:\*\*\s*(v\d+)", re.MULTILINE) @@ -41,6 +65,8 @@ STATUS_OK = "ok" STATUS_BEHIND = "behind" STATUS_MODIFIED = "modified" STATUS_UNVERSIONED = "unversioned" +STATUS_LEGACY = "legacy" +STATUS_DUPLICATE = "duplicate" @dataclass(frozen=True) @@ -52,11 +78,23 @@ class Result: project_version: str | None master_version: str | None status: str + copy_path: Path + master_path: Path + target_path: Path @property def needs_attention(self) -> bool: return self.status != STATUS_OK + @property + def can_sync(self) -> bool: + """Behind and legacy copies can be replaced; anything else may hold work we would destroy.""" + return self.status in {STATUS_BEHIND, STATUS_LEGACY} + + @property + def is_rename(self) -> bool: + return self.copy_path != self.target_path + def read_text(path: Path) -> str: """Read a document, tolerating the BOM some editors leave behind.""" @@ -83,6 +121,42 @@ def is_rust_project(project: Path) -> bool: return (project / "Cargo.toml").is_file() +def detect_newline(path: Path) -> str: + """Return the line ending a file already uses, so rewriting it does not churn every line.""" + data = path.read_bytes() + crlf = data.count(b"\r\n") + lf = data.count(b"\n") - crlf + if crlf and crlf >= lf: + return "\r\n" + if lf: + return "\n" + return os.linesep + + +def sync_document(master: Path, copy: Path, target: Path) -> None: + """Overwrite a project copy with its master, renaming it first when the file name changed. + + The copy keeps the line endings it already used: writing the master's bytes verbatim would flip + every line on a project that uses the other convention, turning a one-line version bump into a + whole-file diff. Renaming before writing lets git see one rename instead of a delete plus an add. + """ + newline = detect_newline(copy) + backup = copy.with_suffix(copy.suffix + ".bak") + shutil.copyfile(copy, backup) + renamed = False + try: + if copy != target: + os.replace(copy, target) + renamed = True + target.write_text(normalise(read_text(master)) + "\n", encoding="utf-8", newline=newline) + except OSError: + if renamed and target.exists(): + os.replace(target, copy) + shutil.copyfile(backup, copy) + raise + backup.unlink() + + def load_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: """Map each document name to its master path and version.""" masters: dict[str, tuple[Path, str | None]] = {} @@ -93,20 +167,15 @@ def load_masters(repo: Path) -> dict[str, tuple[Path, str | None]]: return masters -def check_project(project: Path, repo: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]: +def check_project(project: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]: """Compare every guideline document present in one project against its master.""" results: list[Result] = [] - rust = is_rust_project(project) for name, (master_path, master_version) in masters.items(): copy = project / name if not copy.is_file(): continue - if rust and name in RUST_OVERRIDES: - master_path = repo / RUST_OVERRIDES[name] - master_version = read_version(master_path) - copy_version = read_version(copy) if copy_version is None or master_version is None: @@ -114,12 +183,40 @@ def check_project(project: Path, repo: Path, masters: dict[str, tuple[Path, str elif version_key(copy_version) < version_key(master_version): status = STATUS_BEHIND elif normalise(read_text(copy)) != normalise(read_text(master_path)): - # Same version number but different content — someone edited the copy in place. + # Same version number but different content - someone edited the copy in place. status = STATUS_MODIFIED else: status = STATUS_OK - results.append(Result(project.name, name, copy_version, master_version, status)) + results.append(Result(project.name, name, copy_version, master_version, status, copy, master_path, copy)) + + results.extend(check_legacy(project, masters)) + return results + + +def check_legacy(project: Path, masters: dict[str, tuple[Path, str | None]]) -> list[Result]: + """Report copies still using a pre-rename file name, and where each one should move to.""" + results: list[Result] = [] + rust = is_rust_project(project) + + for old_name, new_name in LEGACY_NAMES.items(): + copy = project / old_name + if not copy.is_file(): + continue + + if rust and old_name in LEGACY_RUST_NAMES: + new_name = LEGACY_RUST_NAMES[old_name] + + target = project / new_name + master_path, master_version = masters[new_name] + + # Both names present: the rename already happened and the old file is a leftover. Removing + # it is not this script's call, so only report it. + status = STATUS_DUPLICATE if target.is_file() else STATUS_LEGACY + label = f"{old_name} -> {new_name}" + results.append( + Result(project.name, label, read_version(copy), master_version, status, copy, master_path, target) + ) return results @@ -135,13 +232,66 @@ def find_projects(root: Path, repo: Path) -> list[Path]: def format_row(result: Result) -> str: marker = { - STATUS_OK: "ok ", - STATUS_BEHIND: "BEHIND ", - STATUS_MODIFIED: "MODIFIED", - STATUS_UNVERSIONED: "NO VER. ", + STATUS_OK: "ok ", + STATUS_BEHIND: "BEHIND ", + STATUS_MODIFIED: "MODIFIED ", + STATUS_UNVERSIONED: "NO VER. ", + STATUS_LEGACY: "OLD NAME ", + STATUS_DUPLICATE: "DUPLICATE", }[result.status] versions = f"{result.project_version or '-'} -> {result.master_version or '-'}" - return f" {marker} {result.document:<26} {versions}" + return f" {marker} {result.document:<50} {versions}" + + +def confirm(prompt: str) -> bool: + """Ask a yes/no question. Anything other than an explicit yes - including a closed stdin - is no.""" + if not sys.stdin or not sys.stdin.isatty(): + print(f"{prompt} [y/N] (stdin is not a terminal, assuming no; use --yes to copy)") + return False + try: + return input(f"{prompt} [y/N] ").strip().lower() in {"y", "yes"} + except (EOFError, KeyboardInterrupt): + print() + return False + + +def run_sync(pending: list[Result], assume_yes: bool, dry_run: bool) -> int: + """Copy the masters over the outdated project copies. Returns the number of documents left stale.""" + renames = sum(1 for result in pending if result.is_rename) + print(f"{len(pending)} document(s) can be updated from this repository:") + labels = {result: f"{result.project}/{result.document}" for result in pending} + width = max(len(label) for label in labels.values()) + for result in pending: + note = " (rename)" if result.is_rename else "" + print(f" {labels[result]:<{width}} {result.project_version} -> {result.master_version}{note}") + print() + + if dry_run: + print("Dry run - nothing was written.") + return len(pending) + + action = f"Overwrite {len(pending)} file(s)" + if renames: + action += f", renaming {renames} of them" + if not assume_yes and not confirm(f"{action}?"): + print("Nothing was written.") + return len(pending) + + failed = 0 + for result in pending: + try: + sync_document(result.master_path, result.copy_path, result.target_path) + except OSError as error: + print(f" FAILED {result.project}/{result.document}: {error}", file=sys.stderr) + failed += 1 + else: + verb = "renamed" if result.is_rename else "updated" + print(f" {verb} {result.project}/{result.target_path.name} -> {result.master_version}") + + print() + print(f"{len(pending) - failed} document(s) updated." if not failed else f"{failed} document(s) failed to update.") + print("Review and commit the changes in each project (a sync is its own commit).") + return failed def main() -> int: @@ -153,6 +303,9 @@ def main() -> int: help="directory holding the projects (default: the parent of this repository)", ) parser.add_argument("--quiet", action="store_true", help="list only projects that need attention") + parser.add_argument("--sync", action="store_true", help="offer to overwrite outdated copies with the master") + parser.add_argument("--yes", action="store_true", help="with --sync, copy without asking for confirmation") + parser.add_argument("--dry-run", action="store_true", help="with --sync, show what would be copied and stop") args = parser.parse_args() repo = Path(__file__).resolve().parent @@ -164,27 +317,28 @@ def main() -> int: return 2 print(f"Master documents in {repo.name}:") + width = max(len(name) for name in masters) for name, (_, version) in sorted(masters.items()): - print(f" {name:<26} {version or '(no version header)'}") + print(f" {name:<{width}} {version or '(no version header)'}") print() - stale = 0 + attention: list[Result] = [] unmanaged: list[str] = [] for project in find_projects(root, repo): - results = check_project(project, repo, masters) + results = check_project(project, masters) if not results: unmanaged.append(project.name) continue - attention = [result for result in results if result.needs_attention] - stale += len(attention) + stale = [result for result in results if result.needs_attention] + attention.extend(stale) - if args.quiet and not attention: + if args.quiet and not stale: continue print(f"{project.name}") - for result in results if not args.quiet else attention: + for result in results if not args.quiet else stale: print(format_row(result)) print() @@ -192,8 +346,26 @@ def main() -> int: print(f"No guideline documents: {', '.join(unmanaged)}") print() - if stale: - print(f"{stale} document(s) need attention.") + if not attention: + print("All projects are up to date.") + return 0 + + syncable = [result for result in attention if result.can_sync] + blocked = len(attention) - len(syncable) + + if args.sync and syncable: + remaining = run_sync(syncable, args.yes, args.dry_run) + blocked + else: + if syncable and not args.sync: + print(f"Run with --sync to copy {len(syncable)} outdated document(s) from this repository.") + remaining = len(attention) + + if blocked: + print(f"{blocked} document(s) left alone - MODIFIED holds local edits, NO VER. cannot be compared,") + print("and DUPLICATE means both the old and the new file name exist; remove the old one by hand.") + + if remaining: + print(f"{remaining} document(s) need attention.") return 1 print("All projects are up to date.")