From ddf7234c9dd66a162d740383d54d10657cef5c81 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Thu, 13 Aug 2026 17:47:26 -0700 Subject: [PATCH] Add project management and GitHub source support --- README.md | 37 + .../articles/boring-deployments/contents.lr | 18 - content/tags/v0-4-0/contents.lr | 5 - devlog_editor.py | 1769 +++++++++++++++++ scripts/devlog_editor.py | 1769 +++++++++++++++++ scripts/git_repo.py | 433 ++++ scripts/management_launcher.py | 261 +++ scripts/project_editor.py | 702 +++++++ scripts/project_providers.py | 104 + scripts/project_sources.py | 41 +- support/devlog_editor.py | 1769 +++++++++++++++++ tests/test_git_repo.py | 162 ++ tests/test_project_editor.py | 228 +++ tests/test_project_sources.py | 51 + 14 files changed, 7310 insertions(+), 39 deletions(-) delete mode 100644 content/articles/boring-deployments/contents.lr delete mode 100644 content/tags/v0-4-0/contents.lr create mode 100644 devlog_editor.py create mode 100644 scripts/devlog_editor.py create mode 100644 scripts/git_repo.py create mode 100644 scripts/management_launcher.py create mode 100644 scripts/project_editor.py create mode 100644 scripts/project_providers.py create mode 100644 support/devlog_editor.py create mode 100644 tests/test_git_repo.py create mode 100644 tests/test_project_editor.py diff --git a/README.md b/README.md index 7d74787..aea42b4 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,24 @@ test -f build/index.html The project currently has no third-party Lektor packages or plugins. +### Local management launcher + +To check the checkout's Git synchronization state and open the focused desktop +editors from one small window, run: + +```bash +python scripts/management_launcher.py +``` + +The launcher locates the repository containing its script, fetches metadata +from the configured `origin`, and reports the current branch, upstream, +working-tree changes, and ahead/behind state in plain language. It offers only +fast-forward pulls for a clean, remote-ahead checkout and normal, non-force +pushes for a local-ahead checkout. Diverged histories, missing tracking +configuration, and dirty pulls require manual review. Refresh rechecks the +state without restarting the window. Each editor is started as a separate +Python process and remains directly runnable with its documented command. + ### Local blog editor For a focused desktop form that creates and edits records under `content/blog/`, @@ -355,6 +373,25 @@ from `PATH` or `support/ffmpeg.exe`, with the fixed 44.1 kHz mono, 96 kbps MP3 preset. Replacement and removal require confirmation; failed conversion leaves any existing narration unchanged. +### Local project editor + +To add, edit, or remove a site-approved remote project source, run: + +```bash +python scripts/project_editor.py +``` + +The project editor asks for one public GitHub or Gitea repository web URL and +derives the anonymous Git and provider API URLs stored in +`configs/project-sources.ini`. Project prose, technology labels, images, and +devlog records remain owned by the external project repository under +`.labyricorn/`. Its explicit **Check Devlog Status** action performs the +importer's public-provider and remote-snapshot validation against a disposable +read-only Git mirror, then removes that mirror. When setup is missing, the +editor directs the user to copy the standalone `devlog_editor.py` into the +project checkout, initialize and author the devlog, publish the changes, and +recheck. It never edits, commits, or pushes an external project repository. + ## Production filesystem and permissions ```text diff --git a/content/articles/boring-deployments/contents.lr b/content/articles/boring-deployments/contents.lr deleted file mode 100644 index c93cb54..0000000 --- a/content/articles/boring-deployments/contents.lr +++ /dev/null @@ -1,18 +0,0 @@ -_model: entry ---- -title: Boring Deployments Are a Feature ---- -date: 2026-08-10 ---- -kicker: Article ---- -summary: Why a small, transparent deployment chain is often the most resilient one. ---- -body: -A static site does not need a sprawling production stack. A canonical Git -repository, an isolated build tool, immutable release directories, an atomic -symlink, and nginx are enough to make deployment understandable and reliable. - -The important property is not novelty. It is that a failed build cannot replace -the working site. - diff --git a/content/tags/v0-4-0/contents.lr b/content/tags/v0-4-0/contents.lr deleted file mode 100644 index 6b8d4be..0000000 --- a/content/tags/v0-4-0/contents.lr +++ /dev/null @@ -1,5 +0,0 @@ -_model: tag ---- -title: v0.4.0 ---- -summary: Work associated with the version 0.4.0 contract and milestone. diff --git a/devlog_editor.py b/devlog_editor.py new file mode 100644 index 0000000..3b3bc2e --- /dev/null +++ b/devlog_editor.py @@ -0,0 +1,1769 @@ +#!/usr/bin/env python3 +"""Standalone editor for repository-owned Labyricorn project and devlog content.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import date +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unicodedata +from urllib.parse import urlsplit, urlunsplit + + +SCHEMA_VERSION = "1" +DEVLOG_RELATIVE = Path(".labyricorn") / "devlog" +PROJECT_RELATIVE = Path(".labyricorn") / "project" / "contents.lr" +PUBLISHING_PATHS = ( + ".labyricorn/AGENTS.md", + ".labyricorn/README.md", + ".labyricorn/project", + ".labyricorn/devlog", +) +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SEPARATOR_RE = re.compile(r"(?m)(^---[ \t]*(?:\r\n|\n|$))") +FIELD_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*):(.*)") +RAW_HTML_RE = re.compile(r"<\s*(?:!|/?[A-Za-z])[\s\S]*?>") +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +IGNORED_DOCUMENTATION = {"AGENTS.md", "README.md"} +MAX_FILES = 100 +MAX_FILE_SIZE = 5 * 1024 * 1024 +MAX_TOTAL_SIZE = 20 * 1024 * 1024 + +ROOT_AGENTS_TEXT = """# Instructions for Labyricorn publishing content + +These instructions apply to the entire `.labyricorn/` directory. More specific +instructions in `project/AGENTS.md` and `devlog/AGENTS.md` also apply within +those directories. + +## Purpose and ownership + +- This directory is the repository-owned source for the project's exhibition + page and development log on Labyricorn. +- This project repository is authoritative for the content. The Labyricorn site + imports it as read-only content. +- Read `.labyricorn/README.md` and the nearest scoped `AGENTS.md` before editing + a publishing record. +- Keep publishing changes focused. Do not alter application code merely to + support an exhibition or devlog edit unless the user separately requests it. + +## Authorization and content contract + +- Preserve unrelated and uncommitted work. Never discard changes to obtain a + clean working tree. +- Do not commit or push unless the user explicitly requests publication. +- Use native Lektor records named `contents.lr` and preserve `schema_version: 1` + until a coordinated schema migration is approved. +- Keep attachments inside the record subtree that owns them. +- Use UTF-8 text, `YYYY-MM-DD` dates, and stable lowercase hyphenated slugs. +- Do not add templates, models, plugins, workflows, executable files, builds, + application state, credentials, raw HTML, scripts, or active third-party + content below `.labyricorn/`. +- Treat published URLs as durable. Ask before renaming or removing a published + record; redirects or archival behavior may be required first. + +## Security, synchronization, and review + +- Never store or print passwords, API tokens, refresh credentials, private keys, + `.netrc` contents, or other secrets in this directory. +- Do not claim content is public merely because a push succeeded. Report push + status and Labyricorn synchronization status separately. +- Parse changed records, confirm referenced source commits exist, run + `git diff --check`, and review the exact publishing diff before committing. +- At handoff, report changed publishing records, validation performed, commit + and push status, and synchronization status if known. +""" + +ROOT_README_TEMPLATE = """# Labyricorn publishing content + +This directory is the repository-owned source for {project_title}'s project +exhibition and development log on Labyricorn. The project repository remains +the source of truth; the Labyricorn site imports this content as read-only. + +The content uses native Lektor records: + +```text +.labyricorn/ +├── AGENTS.md +├── README.md +├── project/ +│ ├── AGENTS.md +│ ├── contents.lr +│ └── +└── devlog/ + ├── AGENTS.md + ├── contents.lr + └── / + ├── contents.lr + └── +``` + +Rules: + +- `project/contents.lr` uses the `project` model. +- `devlog/contents.lr` uses the `devlog` model. +- Each entry is a directory below `devlog/` containing a `contents.lr` record + using the `devlog-entry` model. +- Entry directory names are stable public slugs. Do not rename a published entry + without arranging a redirect on the Labyricorn site. +- Dates use `YYYY-MM-DD`; `source_commit` uses the full relevant commit ID. +- Publishing attachments stay inside the record subtree that owns them. +- Do not put credentials, builds, application state, or executable code here. + +Editing these records does not itself prove that the public site synchronized. +Report repository publication and Labyricorn synchronization separately. +""" + +PROJECT_AGENTS_TEXT = """# Instructions for the Labyricorn project record + +These instructions supplement `.labyricorn/AGENTS.md` and apply to the +`project/` directory. + +## Record structure + +- Maintain exactly one project record at `project/contents.lr` using + `_model: project` and `schema_version: 1`. +- Keep `project_id` stable. It is the durable identity used by the importer and + public URL. +- Ask before changing the repository URL, default branch, project status, + author, start date, or project identity. +- Store project-owned images in this directory and reference them by filename + from `contents.lr`. Only PNG, JPEG, and WebP images are accepted. + +## Editorial guidance + +- Write the exhibition page as a concise project narrative, not as a copy of + the repository README or as release documentation. +- Verify technical and status claims against the repository before editing. +- Distinguish completed work, formal design work, experiments, and future work. +- Do not manually add repository-derived commit, license, language, release, + issue, star, or fork metadata. The Labyricorn importer owns those values. +- Keep the summary suitable for listings and social previews. + +## Review requirements + +- Confirm all required project fields are present and any image reference stays + inside `project/`. +- Check Markdown links are intentional HTTPS links and prose makes no unsupported + claims. +- Validate the record and run `git diff --check` before committing. +""" + +DEVLOG_AGENTS_TEXT = """# Instructions for the Labyricorn development log + +These instructions apply to `.labyricorn/devlog/` and help coding assistants +maintain the project's public development narrative safely. + +## Format and ownership + +- Keep the devlog index at `devlog/contents.lr` using `_model: devlog` and + `schema_version: 1`. +- Store each entry at `devlog//contents.lr` using + `_model: devlog-entry` and `schema_version: 1`. +- Use lowercase hyphenated entry slugs. Published slugs are durable public URLs; + do not rename or delete them without explicit approval and a redirect or + archival decision. +- Every entry must contain `title`, `date`, `author`, `summary`, `tags`, + `source_commit`, and Markdown `body` fields. +- Keep entry attachments in that entry's directory. Only PNG, JPEG, and WebP + images are accepted by the Labyricorn importer. +- Do not add templates, models, plugins, executable code, builds, application + state, credentials, or active third-party content below this directory. + +## Content guidance + +- Base entries on verifiable repository history, project documentation, and + implemented behavior. Do not invent motivations, results, release status, + user feedback, or completion claims. +- Explain the milestone, why it mattered, and what changed. Keep one main + milestone per entry and write a summary that works in a chronological list. + Do not merely expand a commit message or enumerate every changed file. +- Distinguish design and schema contracts from completed runtime behavior. +- Link to the relevant canonical commit using the repository's HTTPS web URL. +- Use the referenced commit's actual `YYYY-MM-DD` calendar date for retrospective + entries, not the date on which the prose was drafted. +- Set `source_commit` to the full lowercase 40-character commit ID most directly + associated with the milestone. It must be an ancestor of the published branch. +- Topics are comma-separated display labels. Unknown topics are allowed: the + site keeps them visible but unlinked. Do not create or edit the site's tracked + tag registry from this project repository. +- Use Markdown without raw HTML, scripts, embedded credentials, or active + external content. +- Multiple entries may share a date. The site resolves ties from source history; + do not invent timestamps or manual next/previous links. +- Do not rewrite older entries merely because the implementation later changed. + Add a new entry or a clearly labelled correction when historical context is + needed. + +## Safety and review + +- Preserve unrelated project files and uncommitted work. The devlog owns only + `.labyricorn/devlog/`. +- Do not commit or push unless the user explicitly requests publication. +- Before publication, verify claims against the referenced commit, validate all + records, check that the source-commit link uses the same full commit ID, review + the exact devlog diff, and confirm unrelated files are not staged in the + publishing commit. +- Prefer the repository-root `devlog_editor.py` for routine validation and safe + publication when it is available. +""" + +PROJECT_REQUIRED_FIELDS = { + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "tags", "body", +} +DEVLOG_REQUIRED_FIELDS = {"_model", "schema_version", "title", "summary"} +ENTRY_REQUIRED_FIELDS = { + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +} +ENTRY_FIELD_ORDER = ( + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +) +PROJECT_FIELD_ORDER = ( + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "logo", "tags", "body", +) + + +class DevlogError(Exception): + """An expected error suitable for display to a nontechnical user.""" + + +class GitError(DevlogError): + """A safe summary of a failed Git operation.""" + + +def is_link(path: Path) -> bool: + return path.is_symlink() or bool(getattr(path, "is_junction", lambda: False)()) + + +def suggest_slug(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + return re.sub(r"[^a-z0-9]+", "-", normalized.casefold()).strip("-") + + +def is_publishing_path(value: str) -> bool: + value = value.replace("\\", "/") + return any(value == root or value.startswith(root + "/") for root in PUBLISHING_PATHS) + + +def validate_iso_date(value: str, label: str) -> str: + value = value.strip() + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise DevlogError(f"{label} must use YYYY-MM-DD.") from exc + if parsed.isoformat() != value: + raise DevlogError(f"{label} must use YYYY-MM-DD.") + return value + + +def normalized_topics(value: str) -> str: + values: list[str] = [] + for item in re.split(r"[,\n]", value): + item = item.strip().replace("\t", " ") + if item and item not in values: + values.append(item) + return ", ".join(values) + + +@dataclass +class LrDocument: + blocks: list[str] + delimiters: list[str] + newline: str + source: str + + @classmethod + def parse(cls, text: str, source: str) -> "LrDocument": + parts = SEPARATOR_RE.split(text) + blocks = [parts[0]] + delimiters: list[str] = [] + for index in range(1, len(parts), 2): + delimiters.append(parts[index]) + blocks.append(parts[index + 1]) + if not blocks or any(not block.strip() for block in blocks): + raise DevlogError(f"{source}: empty or malformed record block.") + document = cls(blocks, delimiters, "\r\n" if "\r\n" in text else "\n", source) + document.field_indexes() + return document + + def render(self) -> str: + pieces = [self.blocks[0]] + for delimiter, block in zip(self.delimiters, self.blocks[1:]): + pieces.extend((delimiter, block)) + return "".join(pieces) + + def parse_block(self, block: str) -> tuple[str, str]: + line_end = block.find("\n") + if line_end < 0: + first_line, remainder = block, "" + else: + first_line = block[:line_end].rstrip("\r") + remainder = block[line_end + 1 :] + match = FIELD_RE.fullmatch(first_line) + if match is None: + raise DevlogError(f"{self.source}: malformed field header {first_line!r}.") + key, inline = match.groups() + if inline.startswith(" "): + inline = inline[1:] + if inline: + value = inline + if remainder.rstrip("\r\n"): + value += "\n" + remainder.rstrip("\r\n") + else: + value = remainder.rstrip("\r\n") + if value.startswith("\r\n"): + value = value[2:] + elif value.startswith("\n"): + value = value[1:] + return key, value + + def field_indexes(self) -> dict[str, int]: + indexes: dict[str, int] = {} + for index, block in enumerate(self.blocks): + key, _value = self.parse_block(block) + if key in indexes: + raise DevlogError(f"{self.source}: duplicate field {key!r}.") + indexes[key] = index + return indexes + + def values(self) -> dict[str, str]: + return {key: self.parse_block(self.blocks[index])[1] for key, index in self.field_indexes().items()} + + def get(self, key: str, default: str = "") -> str: + index = self.field_indexes().get(key) + return default if index is None else self.parse_block(self.blocks[index])[1] + + def set_field( + self, + key: str, + value: str, + *, + multiline: bool = False, + field_order: tuple[str, ...] = ENTRY_FIELD_ORDER, + ) -> None: + indexes = self.field_indexes() + current = indexes.get(key) + if current is not None and self.get(key) == value: + return + newline = self.newline + if multiline or "\n" in value: + clean = value.rstrip("\r\n") + rendered = f"{key}:{newline}{newline}{clean}{newline}" if clean else f"{key}:{newline}" + else: + rendered = f"{key}: {value}{newline}" + if current is not None: + self.blocks[current] = rendered + return + desired = field_order.index(key) if key in field_order else len(field_order) + insertion = len(self.blocks) + for candidate, index in indexes.items(): + candidate_order = field_order.index(candidate) if candidate in field_order else len(field_order) + if candidate_order > desired: + insertion = min(insertion, index) + self.blocks.insert(insertion, rendered) + delimiter = f"---{newline}" + self.delimiters.insert(0 if insertion == 0 else insertion - 1, delimiter) + + +@dataclass +class DevlogEntry: + slug: str + path: Path + document: LrDocument + original_bytes: bytes + + @property + def title(self) -> str: + return self.document.get("title") + + @property + def publication_date(self) -> str: + return self.document.get("date") + + +@dataclass +class RepositoryStatus: + name: str + branch: str + remote_name: str + remote_url: str + upstream: str + ahead: int + behind: int + changed_count: int + publishing_changed_count: int + conflicts: list[str] + operation: str + short_status: str + + @property + def state_text(self) -> str: + if self.operation: + return f"{self.operation} is in progress — manual Git assistance required" + if self.conflicts: + return "Repository has unresolved conflicts — manual Git assistance required" + if not self.branch: + return "Detached Git checkout — manual Git assistance required" + if not self.remote_name: + return "No remote is configured" + if not self.upstream: + return "No upstream branch is configured" + if self.ahead and self.behind: + return "Local and remote histories have diverged — manual Git assistance required" + if self.behind: + return f"Remote has {self.behind} newer commit(s)" + if self.ahead: + return f"Local branch has {self.ahead} unpublished commit(s)" + return "Up to date (as of the last fetch)" + + +class GitRepository: + def __init__(self, start: Path): + self.start = start.resolve() + try: + result = subprocess.run( + ["git", "-C", str(self.start), "rev-parse", "--show-toplevel"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not respond while locating the repository.") from exc + if result.returncode != 0: + raise GitError("devlog_editor.py is not inside a Git repository.") + self.root = Path(result.stdout.strip()).resolve() + if self.start != self.root: + raise GitError( + "Place devlog_editor.py in the root of this project repository, then run it again.\n\n" + f"Detected repository root: {self.root}" + ) + self.git_dir = Path(self.run("rev-parse", "--absolute-git-dir").strip()).resolve() + + @staticmethod + def _safe_url(value: str) -> str: + value = value.strip() + try: + parsed = urlsplit(value) + except ValueError: + return "configured" + if parsed.scheme and parsed.hostname: + host = parsed.hostname + if parsed.port: + host += f":{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return re.sub(r"//[^/@\s]+@", "//***@", value) + + def _sanitize(self, message: str) -> str: + message = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", message) + return message.strip() + + def run(self, *args: str, timeout: int = 30, check: bool = True) -> str: + try: + result = subprocess.run( + ["git", "-C", str(self.root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not finish in time. Check the network and Git authentication.") from exc + if check and result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) or "Git returned an error." + raise GitError(detail) + return result.stdout + + def result(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["git", "-C", str(self.root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise GitError("Git could not complete the requested operation.") from exc + + @staticmethod + def count_porcelain(raw: str) -> int: + chunks = raw.split("\0") + count = 0 + index = 0 + while index < len(chunks): + record = chunks[index] + if not record: + index += 1 + continue + count += 1 + code = record[:2] + index += 2 if "R" in code or "C" in code else 1 + return count + + def branch(self) -> str: + return self.run("branch", "--show-current").strip() + + def upstream_configuration(self, branch: str) -> tuple[str, str, str, str]: + if not branch: + return "", "", "", "" + remote = self.run("config", "--get", f"branch.{branch}.remote", check=False).strip() + if not remote: + remotes = self.run("remote", check=False).splitlines() + if "origin" in remotes: + remote = "origin" + merge_ref = self.run("config", "--get", f"branch.{branch}.merge", check=False).strip() + upstream = self.run( + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", check=False + ).strip() + url = self.run("remote", "get-url", remote, check=False).strip() if remote and remote != "." else "" + return remote, merge_ref, upstream, url + + def operation_in_progress(self) -> str: + states = ( + ("MERGE_HEAD", "A merge"), ("rebase-merge", "A rebase"), + ("rebase-apply", "A rebase"), ("CHERRY_PICK_HEAD", "A cherry-pick"), + ("REVERT_HEAD", "A revert"), ("BISECT_LOG", "A bisect"), + ) + for marker, label in states: + path_text = self.run("rev-parse", "--git-path", marker).strip() + marker_path = Path(path_text) + if path_text and not marker_path.is_absolute(): + marker_path = self.root / marker_path + if path_text and marker_path.exists(): + return label + return "" + + def status(self) -> RepositoryStatus: + branch = self.branch() + remote, _merge_ref, upstream, remote_url = self.upstream_configuration(branch) + ahead = behind = 0 + if upstream: + counts = self.run("rev-list", "--left-right", "--count", f"HEAD...{upstream}").split() + if len(counts) == 2: + ahead, behind = map(int, counts) + raw = self.run("status", "--porcelain=v1", "-z", "--untracked-files=all") + publishing_raw = self.run( + "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", + *PUBLISHING_PATHS, + ) + conflicts = [ + item for item in self.run("diff", "--name-only", "--diff-filter=U", "-z").split("\0") if item + ] + display_url = self._safe_url(remote_url) if remote_url else "" + name_source = display_url.rstrip("/").rsplit("/", 1)[-1] if display_url else self.root.name + name = name_source.removesuffix(".git") or self.root.name + short_status = self.run("status", "--short", "--untracked-files=all").strip() + return RepositoryStatus( + name, branch, remote, display_url, upstream, ahead, behind, + self.count_porcelain(raw), self.count_porcelain(publishing_raw), conflicts, + self.operation_in_progress(), short_status, + ) + + def fetch(self) -> RepositoryStatus: + status = self.status() + if not status.remote_name or status.remote_name == ".": + raise GitError("This branch has no network remote to fetch.") + self.run("fetch", "--prune", status.remote_name, timeout=120) + return self.status() + + def get_latest(self) -> RepositoryStatus: + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "Git could not fast-forward without risking local work. No files were discarded.\n\n" + + (detail or "Commit or move the overlapping changes, then try again.") + ) + return self.status() + + @staticmethod + def ensure_safe_base(status: RepositoryStatus) -> None: + if status.operation: + raise GitError(f"{status.operation} is already in progress. Manual Git assistance is required.") + if status.conflicts: + raise GitError("The repository has unresolved conflicts. Manual Git assistance is required.") + if not status.branch: + raise GitError("The repository is in a detached checkout. Manual Git assistance is required.") + if not status.remote_name or not status.upstream: + raise GitError("The current branch needs a configured remote and upstream before it can synchronize.") + + def validate_commit(self, commit: str, imported_head: str = "HEAD") -> None: + if not COMMIT_RE.fullmatch(commit): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + result = self.result("merge-base", "--is-ancestor", commit, imported_head) + if result.returncode != 0: + raise DevlogError( + f"Source commit {commit[:10]} is not an ancestor of the current branch." + ) + + def head_defaults(self) -> tuple[str, str, str]: + raw = self.run("show", "-s", "--format=%H%x00%cs%x00%an", "HEAD").rstrip("\n") + parts = raw.split("\0", 2) + if len(parts) != 3: + raise GitError("Could not read the current commit metadata.") + return parts[0], parts[1], parts[2] + + def project_defaults(self) -> dict[str, str]: + status = self.status() + _commit, current_date, author = self.head_defaults() + dates = [line.strip() for line in self.run("log", "--reverse", "--format=%cs").splitlines() if line.strip()] + started = dates[0] if dates else current_date + repository_url = status.remote_url.rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if parsed.scheme != "https" or not parsed.netloc: + repository_url = "" + title = re.sub(r"[-_]+", " ", status.name).strip().title() or self.root.name + return { + "project_id": suggest_slug(status.name), + "title": title, + "status": "active", + "started": started, + "author": author, + "repository_url": repository_url, + "default_branch": status.branch, + "tags": "", + "summary": "", + "body": "", + "devlog_title": f"{title} development log", + "devlog_summary": f"Milestones and design decisions from the development of {title}.", + } + + def owned_outgoing_paths(self, upstream: str) -> tuple[bool, list[str]]: + paths = [ + item for item in self.run("diff", "--name-only", "-z", f"{upstream}..HEAD").split("\0") if item + ] + unrelated = [path for path in paths if not is_publishing_path(path)] + return not unrelated, unrelated + + def publish(self, content: "DevlogRepository") -> str: + content.validate_all(validate_commits=True) + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "The remote is newer, but Git could not fast-forward while preserving local files. " + "Publishing stopped and no files were discarded.\n\n" + detail + ) + content.validate_all(validate_commits=True) + status = self.status() + + self.run("add", "-A", "--", *PUBLISHING_PATHS) + staged = self.result("diff", "--cached", "--quiet", "--", *PUBLISHING_PATHS) + created_commit = staged.returncode == 1 + if staged.returncode not in {0, 1}: + raise GitError("Git could not inspect the staged Labyricorn changes.") + if created_commit: + result = self.result( + "commit", "-m", "Update Labyricorn project and devlog", "--", + *PUBLISHING_PATHS, timeout=60 + ) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + if "identity unknown" in detail.casefold() or "user.email" in detail.casefold(): + detail += "\n\nConfigure your Git name and email, then try publishing again." + raise GitError("Git could not create the Labyricorn publishing commit.\n\n" + detail) + + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError( + "The remote changed while publishing. Your publishing commit is safe locally, but automatic " + "publishing stopped.\n\n" + divergence_message() + ) + if not status.ahead: + return "No unpublished Labyricorn changes were found." + owned, unrelated = self.owned_outgoing_paths(status.upstream) + if not owned: + examples = "\n".join(f" • {path}" for path in unrelated[:8]) + raise GitError( + "Pushing would also publish existing commits that change files outside the approved " + "Labyricorn project/devlog paths. " + "The editor stopped at its ownership boundary. Manual Git assistance is required.\n\n" + examples + ) + branch = status.branch + _remote, merge_ref, _upstream, _url = self.upstream_configuration(branch) + if not merge_ref.startswith("refs/heads/"): + raise GitError("The configured upstream branch is not a normal remote branch.") + result = self.result("push", status.remote_name, f"HEAD:{merge_ref}", timeout=120) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + hint = ( + "\n\nGit authentication is not configured or was rejected. Configure access for this " + "repository and use Publish again; the publishing commit remains safe locally." + if any(word in detail.casefold() for word in ("authentication", "credential", "permission denied", "could not read username")) + else "\n\nThe publishing commit remains safe locally. Check the network or remote, then try Publish again." + ) + raise GitError("Git could not push the Labyricorn changes.\n\n" + detail + hint) + return "Labyricorn project and devlog changes were committed and pushed successfully." + + +def divergence_message() -> str: + return ( + "The local and remote repositories both contain changes that the other does not have.\n\n" + "Automatic publishing has stopped to protect the repository. Someone familiar with Git " + "needs to resolve the repository history before publishing can continue." + ) + + +class DevlogRepository: + def __init__(self, git: GitRepository): + self.git = git + self.root = git.root + self.labyricorn_root = (self.root / ".labyricorn").resolve() + self.devlog_root = (self.root / DEVLOG_RELATIVE).resolve() + self.project_path = (self.root / PROJECT_RELATIVE).resolve() + + def ensure_within(self, path: Path, parent: Path) -> Path: + resolved = path.resolve() + try: + resolved.relative_to(parent.resolve()) + except ValueError as exc: + raise DevlogError(f"Unsafe path outside {parent}: {path}") from exc + return resolved + + def read_document(self, path: Path) -> tuple[LrDocument, bytes]: + try: + raw = path.read_bytes() + if len(raw) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing file exceeds {MAX_FILE_SIZE} bytes: {path}") + text = raw.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise DevlogError(f"Cannot read {path}: {exc}") from exc + document = LrDocument.parse(text, str(path)) + self.reject_raw_html(document) + return document, raw + + @staticmethod + def reject_raw_html(document: LrDocument) -> None: + for key, value in document.values().items(): + if key != "repository_url" and RAW_HTML_RE.search(value): + raise DevlogError(f"{document.source}: raw HTML is not allowed in {key}.") + + def validate_project(self) -> LrDocument: + if not self.project_path.is_file(): + raise DevlogError( + "This repository has no .labyricorn/project/contents.lr project identity record." + ) + self.ensure_within(self.project_path, self.labyricorn_root) + document, _raw = self.read_document(self.project_path) + values = document.values() + missing = PROJECT_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Project identity record is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "project" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Project identity record uses an unsupported model or schema version.") + self.clean_project_values(values) + logo = values.get("logo", "").strip() + if logo: + logo_path = self.project_path.parent / logo + if Path(logo).name != logo or logo_path.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project logo must be a local PNG, JPEG, or WebP filename.") + if not logo_path.is_file(): + raise DevlogError(f"Project logo is missing: .labyricorn/project/{logo}") + return document + + def clean_project_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = { + key: str(value).replace("\r\n", "\n").replace("\r", "\n") + for key, value in values.items() + } + for required in ( + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "body", + ): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"Project {required.replace('_', ' ')} is required.") + cleaned["project_id"] = cleaned["project_id"].strip() + if not SLUG_RE.fullmatch(cleaned["project_id"]): + raise DevlogError("Project ID must be a stable lowercase hyphenated slug.") + for scalar in ("title", "author", "status", "default_branch"): + cleaned[scalar] = " ".join(cleaned[scalar].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["started"] = validate_iso_date(cleaned["started"], "Project start date") + if cleaned["status"] not in {"active", "released", "maintained", "archived"}: + raise DevlogError("Project status must be active, released, maintained, or archived.") + branch = cleaned["default_branch"] + if not re.fullmatch(r"[A-Za-z0-9._/-]+", branch) or ".." in branch: + raise DevlogError("Project default branch is invalid.") + repository_url = cleaned["repository_url"].strip().rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if ( + parsed.scheme != "https" or not parsed.hostname or parsed.username + or parsed.password or parsed.query or parsed.fragment + ): + raise DevlogError("Project repository URL must be a credential-free HTTPS web URL.") + cleaned["repository_url"] = repository_url + for field in ("title", "summary", "author", "tags", "body"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in project {field}.") + return cleaned + + def initialization_state(self) -> tuple[str, str]: + if not (self.root / ".labyricorn").exists(): + return ( + "ready_project", + "This repository does not have Labyricorn project publishing content yet.", + ) + if is_link(self.root / ".labyricorn"): + return "malformed", ".labyricorn must not be a symbolic link or junction." + try: + project = self.validate_project() + except DevlogError as exc: + return "malformed", str(exc) + devlog_path = self.root / DEVLOG_RELATIVE + if not devlog_path.exists(): + return "ready_devlog", f"{project.get('title')} does not have a Labyricorn devlog yet." + if is_link(devlog_path) or not devlog_path.is_dir(): + return "malformed", ".labyricorn/devlog is not a normal directory." + index = devlog_path / "contents.lr" + if not index.is_file(): + return "malformed", ".labyricorn/devlog exists but has no contents.lr index. It was not overwritten." + try: + self.validate_all(validate_commits=False) + except DevlogError as exc: + return "malformed", str(exc) + return "initialized", "" + + def validate_image(self, path: Path) -> None: + try: + data = path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read image {path}: {exc}") from exc + suffix = path.suffix.lower() + signatures = { + ".png": (b"\x89PNG\r\n\x1a\n",), ".jpg": (b"\xff\xd8\xff",), + ".jpeg": (b"\xff\xd8\xff",), ".webp": (b"RIFF",), + } + if len(data) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing image exceeds {MAX_FILE_SIZE} bytes: {path}") + if not any(data.startswith(prefix) for prefix in signatures[suffix]): + raise DevlogError(f"Image signature does not match its extension: {path}") + if suffix == ".webp" and data[8:12] != b"WEBP": + raise DevlogError(f"Image signature does not match its extension: {path}") + + def validate_tree(self) -> None: + laby = self.root / ".labyricorn" + if not laby.is_dir() or is_link(laby): + raise DevlogError(".labyricorn must be a normal directory.") + files = 0 + total = 0 + for path in laby.rglob("*"): + if is_link(path): + raise DevlogError(f"Linked publishing paths are not allowed: {path}") + relative = path.relative_to(laby) + parts = relative.parts + if path.is_dir(): + allowed = ( + parts in {("project",), ("devlog",)} + or (len(parts) == 2 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1])) + ) + if not allowed: + raise DevlogError(f"Unsupported publishing directory: .labyricorn/{relative.as_posix()}") + continue + if path.name in IGNORED_DOCUMENTATION: + continue + files += 1 + try: + size = path.stat().st_size + except OSError as exc: + raise DevlogError(f"Cannot inspect {path}: {exc}") from exc + total += size + is_project_record = parts == ("project", "contents.lr") + is_devlog_index = parts == ("devlog", "contents.lr") + is_entry_record = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and parts[2] == "contents.lr" + ) + is_project_image = len(parts) == 2 and parts[0] == "project" and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + is_entry_image = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + ) + if not (is_project_record or is_devlog_index or is_entry_record or is_project_image or is_entry_image): + raise DevlogError(f"Unsupported publishing file: .labyricorn/{relative.as_posix()}") + if is_project_image or is_entry_image: + self.validate_image(path) + if files > MAX_FILES or total > MAX_TOTAL_SIZE: + raise DevlogError("The publishing tree exceeds the importer file or size limit.") + required_guidance = ( + laby / "AGENTS.md", + laby / "README.md", + laby / "project" / "AGENTS.md", + laby / "devlog" / "AGENTS.md", + ) + missing_guidance = [path.relative_to(self.root).as_posix() for path in required_guidance if not path.is_file()] + if missing_guidance: + raise DevlogError( + "Labyricorn assistant guidance is incomplete; missing: " + + ", ".join(missing_guidance) + ) + + def validate_index(self) -> LrDocument: + path = self.devlog_root / "contents.lr" + if not path.is_file(): + raise DevlogError("Devlog index is missing: .labyricorn/devlog/contents.lr") + document, _raw = self.read_document(path) + values = document.values() + missing = DEVLOG_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Devlog index is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "devlog" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Devlog index uses an unsupported model or schema version.") + if not values["title"].strip() or not values["summary"].strip(): + raise DevlogError("Devlog index title and summary must not be empty.") + return document + + def clean_entry_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = {key: value.replace("\r\n", "\n").replace("\r", "\n") for key, value in values.items()} + for required in ("title", "author", "summary", "body"): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"{required.replace('_', ' ').title()} is required.") + cleaned["title"] = " ".join(cleaned["title"].splitlines()).strip() + cleaned["author"] = " ".join(cleaned["author"].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["date"] = validate_iso_date(cleaned.get("date", ""), "Publication date") + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["source_commit"] = cleaned.get("source_commit", "").strip() + if not COMMIT_RE.fullmatch(cleaned["source_commit"]): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + for field in ("title", "author", "summary", "body", "tags"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in {field}.") + self.git.validate_commit(cleaned["source_commit"]) + return cleaned + + def validate_loaded_entry(self, document: LrDocument, slug: str, *, validate_commit: bool) -> None: + values = document.values() + missing = ENTRY_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"devlog/{slug}/contents.lr is missing: {', '.join(sorted(missing))}.") + if document.get("_model") != "devlog-entry" or document.get("schema_version") != SCHEMA_VERSION: + raise DevlogError(f"devlog/{slug}/contents.lr uses an unsupported model or schema version.") + validate_iso_date(values["date"], "Publication date") + if not COMMIT_RE.fullmatch(values["source_commit"]): + raise DevlogError(f"devlog/{slug}: source_commit must be a full lowercase commit ID.") + for required in ("title", "author", "summary", "body"): + if not values[required].strip(): + raise DevlogError(f"devlog/{slug}: {required} must not be empty.") + if validate_commit: + self.git.validate_commit(values["source_commit"]) + + def list_entries(self, *, validate_commits: bool = False) -> list[DevlogEntry]: + if not self.devlog_root.is_dir(): + return [] + entries: list[DevlogEntry] = [] + for directory in self.devlog_root.iterdir(): + if not directory.is_dir(): + continue + if not SLUG_RE.fullmatch(directory.name): + raise DevlogError(f"Invalid devlog entry slug: {directory.name}") + entries.append(self.load_entry(directory.name, validate_commit=validate_commits)) + entries.sort(key=lambda entry: (entry.publication_date, entry.title, entry.slug), reverse=True) + return entries + + def load_entry(self, slug: str, *, validate_commit: bool = False) -> DevlogEntry: + self.validate_slug(slug) + path = self.ensure_within(self.devlog_root / slug / "contents.lr", self.devlog_root) + if not path.is_file(): + raise DevlogError(f"Devlog entry does not exist: {slug}") + document, raw = self.read_document(path) + self.validate_loaded_entry(document, slug, validate_commit=validate_commit) + return DevlogEntry(slug, path, document, raw) + + def validate_all(self, *, validate_commits: bool) -> None: + self.validate_tree() + self.validate_project() + self.validate_index() + self.list_entries(validate_commits=validate_commits) + + @staticmethod + def validate_slug(slug: str) -> None: + if not SLUG_RE.fullmatch(slug): + raise DevlogError("Slug must use lowercase letters, numbers, and single hyphens only.") + + def initialize( + self, + title: str, + summary: str, + project_values: dict[str, str] | None = None, + ) -> None: + state, message = self.initialization_state() + if state not in {"ready_project", "ready_devlog"}: + raise DevlogError(message or "Labyricorn publishing cannot be initialized in its current state.") + title = " ".join(title.splitlines()).strip() + summary = summary.replace("\r", " ").replace("\n", " ").strip() + if not title or not summary: + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(title) or RAW_HTML_RE.search(summary): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + devlog_index = self.root / DEVLOG_RELATIVE / "contents.lr" + devlog_payload = ( + f"_model: devlog\n---\nschema_version: {SCHEMA_VERSION}\n---\n" + f"title: {title}\n---\nsummary: {summary}\n" + ).encode("utf-8") + + files: dict[Path, bytes] = { + devlog_index: devlog_payload, + devlog_index.parent / "AGENTS.md": DEVLOG_AGENTS_TEXT.encode("utf-8"), + } + directories: list[Path] = [] + labyricorn = self.root / ".labyricorn" + project_directory = labyricorn / "project" + devlog_directory = labyricorn / "devlog" + + if state == "ready_project": + if project_values is None: + raise DevlogError("Project information is required for first-time initialization.") + cleaned = self.clean_project_values(project_values) + project_record = project_directory / "contents.lr" + logo_source_text = project_values.get("logo_source", "").strip() + logo_name = "" + logo_data = b"" + if logo_source_text: + logo_source = Path(logo_source_text).expanduser().resolve() + if is_link(logo_source) or not logo_source.is_file(): + raise DevlogError("The selected project image is not a normal file.") + if logo_source.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project image must be PNG, JPEG, or WebP.") + self.validate_image(logo_source) + try: + logo_data = logo_source.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read the selected project image: {exc}") from exc + logo_name = logo_source.name + document = LrDocument.parse( + f"_model: project\n---\nschema_version: {SCHEMA_VERSION}\n", + str(project_record), + ) + project_keys = [ + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "tags", "body", + ] + if logo_name: + cleaned["logo"] = logo_name + project_keys.insert(project_keys.index("tags"), "logo") + for key in project_keys: + document.set_field( + key, + cleaned[key], + multiline=key in {"summary", "body"}, + field_order=PROJECT_FIELD_ORDER, + ) + files.update( + { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=cleaned["title"] + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + project_record: document.render().encode("utf-8"), + } + ) + if logo_name: + files[project_directory / logo_name] = logo_data + directories.extend((labyricorn, project_directory, devlog_directory)) + else: + directories.append(devlog_directory) + optional_guidance = { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=self.validate_project().get("title") + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + } + files.update({path: payload for path, payload in optional_guidance.items() if not path.exists()}) + + created: list[Path] = [] + created_directories: list[Path] = [] + try: + for directory in directories: + directory.mkdir() + created_directories.append(directory) + for destination, payload in files.items(): + with destination.open("xb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + created.append(destination) + self.validate_all(validate_commits=False) + except (OSError, DevlogError) as exc: + try: + for path in reversed(created): + path.unlink(missing_ok=True) + for directory in reversed(created_directories): + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + if isinstance(exc, DevlogError): + raise + raise DevlogError(f"Cannot initialize Labyricorn publishing: {exc}") from exc + + def apply_values(self, document: LrDocument, values: dict[str, str]) -> None: + for key in ("title", "date", "author", "summary", "tags", "source_commit", "body"): + document.set_field(key, values[key], multiline=key in {"summary", "body"}) + + def create_entry(self, slug: str, values: dict[str, str]) -> DevlogEntry: + self.validate_slug(slug) + cleaned = self.clean_entry_values(values) + directory = self.ensure_within(self.devlog_root / slug, self.devlog_root) + destination = directory / "contents.lr" + if directory.exists(): + raise DevlogError(f"Devlog entry already exists: {slug}") + document = LrDocument.parse( + f"_model: devlog-entry\n---\nschema_version: {SCHEMA_VERSION}\n---\ntitle: placeholder\n", + str(destination), + ) + self.apply_values(document, cleaned) + try: + directory.mkdir() + with destination.open("xb") as output: + output.write(document.render().encode("utf-8")) + output.flush() + os.fsync(output.fileno()) + except OSError as exc: + try: + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + raise DevlogError(f"Cannot create {destination}: {exc}") from exc + return self.load_entry(slug, validate_commit=True) + + def save_entry(self, entry: DevlogEntry, values: dict[str, str]) -> DevlogEntry: + cleaned = self.clean_entry_values(values) + destination = self.ensure_within(entry.path, self.devlog_root) + try: + current = destination.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {destination}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before saving.") + self.apply_values(entry.document, cleaned) + payload = entry.document.render().encode("utf-8") + if payload != current: + self.atomic_replace(destination, payload) + return self.load_entry(entry.slug, validate_commit=True) + + @staticmethod + def atomic_replace(destination: Path, payload: bytes) -> None: + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(mode="wb", prefix=".contents.lr.", dir=destination.parent, delete=False) as output: + temporary = Path(output.name) + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.chmod(temporary, destination.stat().st_mode) + os.replace(temporary, destination) + except OSError as exc: + if temporary is not None: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise DevlogError(f"Cannot save {destination}: {exc}") from exc + + def delete_entry(self, entry: DevlogEntry) -> None: + self.validate_slug(entry.slug) + directory = self.ensure_within(entry.path.parent, self.devlog_root) + if directory.parent != self.devlog_root or directory.name != entry.slug or is_link(directory): + raise DevlogError(f"Unsafe devlog entry directory: {directory}") + try: + current = entry.path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {entry.path}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before deleting.") + children = list(directory.iterdir()) + for child in children: + if is_link(child) or not child.is_file(): + raise DevlogError(f"Refusing to delete an entry containing an unsafe path: {child}") + if child.name != "contents.lr" and child.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError(f"Refusing to delete an unsupported entry file: {child}") + try: + for child in children: + child.unlink() + directory.rmdir() + except OSError as exc: + raise DevlogError(f"Cannot delete {directory}: {exc}") from exc + + +def run_gui(content: DevlogRepository) -> None: + import tkinter as tk + from tkinter import filedialog, messagebox, simpledialog, ttk + + class DevlogEditorApp: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Labyricorn Devlog") + self.root.minsize(900, 640) + self.current_entry: DevlogEntry | None = None + self.snapshot: dict[str, str] | None = None + self.mode = "new" + + outer = ttk.Frame(self.root, padding=12) + outer.pack(fill=tk.BOTH, expand=True) + outer.columnconfigure(0, weight=1) + outer.rowconfigure(1, weight=1) + + project = ttk.LabelFrame(outer, text="Project", padding=10) + project.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + project.columnconfigure(0, weight=1) + self.repo_var = tk.StringVar() + self.state_var = tk.StringVar() + self.changes_var = tk.StringVar() + ttk.Label(project, textvariable=self.repo_var).grid(row=0, column=0, sticky="w") + ttk.Label(project, textvariable=self.state_var).grid(row=1, column=0, sticky="w", pady=(3, 0)) + ttk.Label(project, textvariable=self.changes_var).grid(row=2, column=0, sticky="w", pady=(3, 0)) + controls = ttk.Frame(project) + controls.grid(row=0, column=1, rowspan=3, sticky="e") + ttk.Button(controls, text="Refresh", command=self.refresh_remote).grid(row=0, column=0, padx=3) + ttk.Button(controls, text="Get Latest Version", command=self.get_latest).grid(row=0, column=1, padx=3) + ttk.Button(controls, text="Details", command=self.show_details).grid(row=0, column=2, padx=3) + + self.devlog = ttk.LabelFrame(outer, text="Devlog", padding=10) + self.devlog.grid(row=1, column=0, sticky="nsew") + self.devlog.columnconfigure(0, weight=1) + self.devlog.rowconfigure(0, weight=1) + + self.status_var = tk.StringVar() + footer = ttk.Frame(outer) + footer.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + footer.columnconfigure(0, weight=1) + ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w") + ttk.Button(footer, text="Publish Labyricorn Changes", command=self.publish).grid(row=0, column=1, sticky="e") + + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.bind("", lambda _event: self.save()) + self.build_content_area() + self.refresh_status() + + def clear_devlog(self) -> None: + for child in self.devlog.winfo_children(): + child.destroy() + + def build_content_area(self) -> None: + self.clear_devlog() + state, message = content.initialization_state() + if state != "initialized": + panel = ttk.Frame(self.devlog, padding=30) + panel.grid(row=0, column=0, sticky="nsew") + panel.columnconfigure(0, weight=1) + ttk.Label(panel, text=message, wraplength=700, justify="center").grid(row=0, column=0, pady=(80, 15)) + button_text = ( + "Initialize Labyricorn Project & Devlog" + if state == "ready_project" + else "Initialize Devlog" + ) + button = ttk.Button(panel, text=button_text, command=self.initialize) + button.grid(row=1, column=0) + if state not in {"ready_project", "ready_devlog"}: + button.configure(state="disabled") + self.status_var.set( + "Labyricorn publishing is not initialized" + if state in {"ready_project", "ready_devlog"} + else "Labyricorn publishing needs attention" + ) + return + + pane = ttk.Panedwindow(self.devlog, orient=tk.HORIZONTAL) + pane.grid(row=0, column=0, sticky="nsew") + browser = ttk.Frame(pane, padding=5) + form = ttk.Frame(pane, padding=5) + pane.add(browser, weight=1) + pane.add(form, weight=3) + + columns = ("date", "title", "slug") + self.tree = ttk.Treeview(browser, columns=columns, show="headings") + for column, label, width in (("date", "Date", 90), ("title", "Title", 220), ("slug", "Slug", 170)): + self.tree.heading(column, text=label) + self.tree.column(column, width=width, stretch=column != "date") + scroll = ttk.Scrollbar(browser, orient=tk.VERTICAL, command=self.tree.yview) + self.tree.configure(yscrollcommand=scroll.set) + self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + scroll.grid(row=0, column=3, sticky="ns") + browser.rowconfigure(0, weight=1) + browser.columnconfigure(0, weight=1) + ttk.Button(browser, text="New", command=self.new_entry).grid(row=1, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(browser, text="Edit", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0)) + ttk.Button(browser, text="Delete", command=self.delete_selected).grid(row=1, column=2, sticky="ew", pady=(8, 0)) + self.tree.bind("", lambda _event: self.edit_selected()) + + self.title_var = tk.StringVar() + self.slug_var = tk.StringVar() + self.date_var = tk.StringVar() + self.author_var = tk.StringVar() + self.commit_var = tk.StringVar() + self.topics_var = tk.StringVar() + form.columnconfigure(1, weight=1) + form.columnconfigure(3, weight=1) + form.rowconfigure(7, weight=1) + ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w") + ttk.Entry(form, textvariable=self.title_var).grid(row=0, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Slug").grid(row=1, column=0, sticky="w") + self.slug_entry = ttk.Entry(form, textvariable=self.slug_var) + self.slug_entry.grid(row=1, column=1, columnspan=2, sticky="ew", pady=3) + ttk.Button(form, text="Suggest", command=self.fill_slug).grid(row=1, column=3, sticky="e", padx=(5, 0)) + ttk.Label(form, text="Publication date").grid(row=2, column=0, sticky="w") + ttk.Entry(form, textvariable=self.date_var).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(form, text="Author").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(form, textvariable=self.author_var).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(form, text="Source commit").grid(row=3, column=0, sticky="w") + ttk.Entry(form, textvariable=self.commit_var).grid(row=3, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Topics (comma-separated)").grid(row=4, column=0, sticky="w") + ttk.Entry(form, textvariable=self.topics_var).grid(row=4, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Summary").grid(row=5, column=0, sticky="nw", pady=(5, 0)) + self.summary_text = tk.Text(form, height=4, wrap="word", undo=True) + self.summary_text.grid(row=5, column=1, columnspan=3, sticky="nsew", pady=3) + ttk.Label(form, text="Body (Markdown)").grid(row=7, column=0, sticky="nw", pady=(5, 0)) + body_frame = ttk.Frame(form) + body_frame.grid(row=7, column=1, columnspan=3, sticky="nsew", pady=3) + body_frame.rowconfigure(0, weight=1) + body_frame.columnconfigure(0, weight=1) + self.body_text = tk.Text(body_frame, wrap="word", undo=True) + body_scroll = ttk.Scrollbar(body_frame, orient=tk.VERTICAL, command=self.body_text.yview) + self.body_text.configure(yscrollcommand=body_scroll.set) + self.body_text.grid(row=0, column=0, sticky="nsew") + body_scroll.grid(row=0, column=1, sticky="ns") + ttk.Button(form, text="Save Entry", command=self.save).grid(row=8, column=3, sticky="e", pady=(8, 0)) + self.reload_entries() + self.new_entry(confirm=False) + + def initialize(self) -> None: + state, _message = content.initialization_state() + project_values: dict[str, str] | None = None + if state == "ready_project": + try: + result = self.ask_project_initialization(content.git.project_defaults()) + except DevlogError as exc: + messagebox.showerror("Cannot prepare initialization", str(exc)) + return + if result is None: + return + title = result.pop("devlog_title") + summary = result.pop("devlog_summary") + project_values = result + elif state == "ready_devlog": + try: + project = content.validate_project() + except DevlogError as exc: + messagebox.showerror("Cannot initialize devlog", str(exc)) + return + title = simpledialog.askstring( + "Initialize Devlog", "Devlog title:", + initialvalue=f"{project.get('title')} development log", parent=self.root + ) + if title is None: + return + summary = simpledialog.askstring( + "Initialize Devlog", "Short description of this development log:", parent=self.root + ) + if summary is None: + return + else: + messagebox.showerror("Cannot initialize", "The publishing structure is incomplete or malformed.") + return + try: + content.initialize(title, summary, project_values) + except DevlogError as exc: + messagebox.showerror("Cannot initialize Labyricorn publishing", str(exc)) + return + self.build_content_area() + self.refresh_status() + self.status_var.set("Initialized the Labyricorn project record and devlog") + + def ask_project_initialization( + self, defaults: dict[str, str] + ) -> dict[str, str] | None: + dialog = tk.Toplevel(self.root) + dialog.title("Initialize Labyricorn Project & Devlog") + dialog.transient(self.root) + dialog.grab_set() + dialog.minsize(760, 680) + dialog.geometry("820x760") + frame = ttk.Frame(dialog, padding=14) + frame.pack(fill=tk.BOTH, expand=True) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(3, weight=1) + frame.rowconfigure(8, weight=1) + + variables = { + key: tk.StringVar(value=defaults.get(key, "")) + for key in ( + "project_id", "title", "status", "started", "author", + "repository_url", "default_branch", "tags", "logo_source", + "devlog_title", + ) + } + ttk.Label(frame, text="Project exhibition record").grid( + row=0, column=0, columnspan=4, sticky="w", pady=(0, 8) + ) + ttk.Label(frame, text="Project title").grid(row=1, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["title"]).grid(row=1, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Stable project ID").grid(row=1, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["project_id"]).grid(row=1, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Status").grid(row=2, column=0, sticky="w") + ttk.Combobox( + frame, textvariable=variables["status"], state="readonly", + values=("active", "released", "maintained", "archived"), + ).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Started (YYYY-MM-DD)").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["started"]).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Author").grid(row=3, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["author"]).grid(row=3, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Default branch").grid(row=3, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["default_branch"]).grid(row=3, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Repository web URL").grid(row=4, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["repository_url"]).grid( + row=4, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Technologies/topics").grid(row=5, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["tags"]).grid(row=5, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Project image (optional)").grid(row=5, column=2, sticky="w", padx=(12, 0)) + image_picker = ttk.Frame(frame) + image_picker.grid(row=5, column=3, sticky="ew", pady=3) + image_picker.columnconfigure(0, weight=1) + ttk.Entry(image_picker, textvariable=variables["logo_source"]).grid(row=0, column=0, sticky="ew") + + def choose_logo() -> None: + selected = filedialog.askopenfilename( + title="Choose project image", + filetypes=( + ("Supported images", "*.png *.jpg *.jpeg *.webp"), + ("All files", "*.*"), + ), + parent=dialog, + ) + if selected: + variables["logo_source"].set(selected) + + ttk.Button(image_picker, text="Browse…", command=choose_logo).grid( + row=0, column=1, padx=(5, 0) + ) + ttk.Label(frame, text="Project summary").grid(row=6, column=0, sticky="nw", pady=(5, 0)) + project_summary = tk.Text(frame, height=3, wrap="word", undo=True) + project_summary.grid(row=6, column=1, columnspan=3, sticky="nsew", pady=3) + project_summary.insert("1.0", defaults.get("summary", "")) + ttk.Label(frame, text="Project narrative\n(Markdown)").grid(row=8, column=0, sticky="nw", pady=(5, 0)) + project_body = tk.Text(frame, height=8, wrap="word", undo=True) + project_body.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3) + project_body.insert("1.0", defaults.get("body", "")) + + ttk.Separator(frame).grid(row=9, column=0, columnspan=4, sticky="ew", pady=10) + ttk.Label(frame, text="Development log").grid(row=10, column=0, columnspan=4, sticky="w") + ttk.Label(frame, text="Devlog title").grid(row=11, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["devlog_title"]).grid( + row=11, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Devlog summary").grid(row=12, column=0, sticky="nw", pady=(5, 0)) + devlog_summary = tk.Text(frame, height=3, wrap="word", undo=True) + devlog_summary.grid(row=12, column=1, columnspan=3, sticky="nsew", pady=3) + devlog_summary.insert("1.0", defaults.get("devlog_summary", "")) + + result: dict[str, str] | None = None + + def accept() -> None: + nonlocal result + candidate = {key: variable.get() for key, variable in variables.items()} + candidate["summary"] = project_summary.get("1.0", "end-1c") + candidate["body"] = project_body.get("1.0", "end-1c") + candidate["devlog_summary"] = devlog_summary.get("1.0", "end-1c") + try: + content.clean_project_values(candidate) + if not candidate["devlog_title"].strip() or not candidate["devlog_summary"].strip(): + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(candidate["devlog_title"]) or RAW_HTML_RE.search(candidate["devlog_summary"]): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + except DevlogError as exc: + messagebox.showerror("Check project information", str(exc), parent=dialog) + return + result = candidate + dialog.destroy() + + buttons = ttk.Frame(frame) + buttons.grid(row=13, column=0, columnspan=4, sticky="e", pady=(12, 0)) + ttk.Button(buttons, text="Cancel", command=dialog.destroy).grid(row=0, column=0, padx=(0, 8)) + ttk.Button(buttons, text="Initialize", command=accept).grid(row=0, column=1) + dialog.protocol("WM_DELETE_WINDOW", dialog.destroy) + dialog.wait_window() + return result + + def collect_values(self) -> dict[str, str]: + return { + "title": self.title_var.get(), "date": self.date_var.get(), + "author": self.author_var.get(), "source_commit": self.commit_var.get(), + "tags": self.topics_var.get(), + "summary": self.summary_text.get("1.0", "end-1c"), + "body": self.body_text.get("1.0", "end-1c"), + } + + def set_values(self, values: dict[str, str]) -> None: + self.title_var.set(values.get("title", "")) + self.date_var.set(values.get("date", "")) + self.author_var.set(values.get("author", "")) + self.commit_var.set(values.get("source_commit", "")) + self.topics_var.set(normalized_topics(values.get("tags", ""))) + for widget, key in ((self.summary_text, "summary"), (self.body_text, "body")): + widget.delete("1.0", tk.END) + widget.insert("1.0", values.get(key, "")) + + def has_unsaved(self) -> bool: + return self.snapshot is not None and self.collect_values() != self.snapshot + + def may_discard(self) -> bool: + return not self.has_unsaved() or messagebox.askyesno("Discard changes?", "Discard unsaved changes in the current form?") + + def reload_entries(self, selected: str | None = None) -> None: + self.tree.delete(*self.tree.get_children()) + try: + entries = content.list_entries() + except DevlogError as exc: + messagebox.showerror("Cannot load devlog", str(exc)) + return + for entry in entries: + self.tree.insert("", tk.END, iid=entry.slug, values=(entry.publication_date, entry.title, entry.slug)) + if selected and self.tree.exists(selected): + self.tree.selection_set(selected) + self.tree.see(selected) + + def new_entry(self, confirm: bool = True) -> None: + if confirm and not self.may_discard(): + return + try: + commit, commit_date, author = content.git.head_defaults() + except DevlogError as exc: + messagebox.showerror("Cannot read Git history", str(exc)) + return + self.mode = "new" + self.current_entry = None + self.slug_entry.configure(state="normal") + self.slug_var.set("") + self.set_values({"date": commit_date, "author": author, "source_commit": commit}) + self.snapshot = self.collect_values() + self.status_var.set("Creating a new devlog entry") + + def edit_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to edit.") + return + if not self.may_discard(): + return + try: + entry = content.load_entry(selection[0], validate_commit=True) + except DevlogError as exc: + messagebox.showerror("Cannot load entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.status_var.set(f"Editing .labyricorn/devlog/{entry.slug}/contents.lr") + + def fill_slug(self) -> None: + if self.mode == "new": + self.slug_var.set(suggest_slug(self.title_var.get())) + + def save(self) -> None: + if not hasattr(self, "title_var"): + return + try: + if self.mode == "new": + entry = content.create_entry(self.slug_var.get().strip(), self.collect_values()) + else: + if self.current_entry is None: + raise DevlogError("No entry is loaded.") + entry = content.save_entry(self.current_entry, self.collect_values()) + except DevlogError as exc: + messagebox.showerror("Cannot save entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.reload_entries(entry.slug) + self.refresh_status() + self.status_var.set(f"Saved .labyricorn/devlog/{entry.slug}/contents.lr") + + def delete_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to delete.") + return + try: + entry = content.load_entry(selection[0]) + attachments = sum(1 for path in entry.path.parent.iterdir() if path.is_file() and path != entry.path) + except (DevlogError, OSError) as exc: + messagebox.showerror("Cannot inspect entry", str(exc)) + return + note = f"\n\nThis also removes {attachments} entry attachment(s)." if attachments else "" + if not messagebox.askyesno( + "Delete devlog entry?", + f"Delete {entry.title!r} ({entry.slug}) from the working tree?{note}\n\n" + "Git can recover the deletion until it is published.", icon="warning", + ): + return + try: + content.delete_entry(entry) + except DevlogError as exc: + messagebox.showerror("Cannot delete entry", str(exc)) + return + self.snapshot = None + self.reload_entries() + self.new_entry(confirm=False) + self.refresh_status() + self.status_var.set(f"Deleted .labyricorn/devlog/{entry.slug}") + + def refresh_status(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + self.state_var.set(f"Status: {exc}") + return + remote = status.remote_name or "None" + self.repo_var.set(f"Repository: {status.name} Branch: {status.branch or 'detached'} Remote: {remote}") + self.state_var.set(f"Status: {status.state_text}") + word = "file" if status.publishing_changed_count == 1 else "files" + self.changes_var.set( + f"Labyricorn changes: {status.publishing_changed_count} unpublished {word}" + ) + + def refresh_remote(self) -> None: + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + content.git.fetch() + if content.initialization_state()[0] == "initialized": + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot refresh repository", str(exc)) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def get_latest(self) -> None: + if self.has_unsaved() and not messagebox.askyesno( + "Unsaved form changes", "The form has unsaved text. Continue only if it is also saved on disk?" + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + before = content.git.status() + after = content.git.get_latest() + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot get latest version", str(exc)) + else: + self.build_content_area() + messagebox.showinfo( + "Repository updated", + "The repository is already current." if not before.behind else f"Fast-forwarded by {before.behind} commit(s). Local files were preserved.", + ) + self.state_var.set(f"Status: {after.state_text}") + finally: + self.root.configure(cursor="") + self.refresh_status() + + def publish(self) -> None: + if self.has_unsaved(): + messagebox.showinfo("Save the entry first", "Save or discard the current form changes before publishing.") + return + if not messagebox.askyesno( + "Publish Labyricorn changes?", + "Validate, commit, and push only the project record, publishing guidance, " + "approved project images, and devlog inside .labyricorn/?\n\n" + "All files outside that boundary will be left out of the publishing commit.", + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + result = content.git.publish(content) + except DevlogError as exc: + messagebox.showerror("Publishing stopped safely", str(exc)) + else: + messagebox.showinfo("Publish Labyricorn Changes", result) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def show_details(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + messagebox.showerror("Cannot read details", str(exc)) + return + details = [ + f"Repository root: {content.git.root}", f"Branch: {status.branch or 'detached'}", + f"Remote: {status.remote_name or 'None'}", f"Remote URL: {status.remote_url or 'None'}", + f"Upstream: {status.upstream or 'None'}", f"Ahead / behind: {status.ahead} / {status.behind}", + f"Changed files: {status.changed_count}", + f"Labyricorn changed files: {status.publishing_changed_count}", + ] + if status.short_status: + details.extend(("", "Working-tree details:", status.short_status)) + messagebox.showinfo("Repository details", "\n".join(details)) + + def close(self) -> None: + if not hasattr(self, "title_var") or self.may_discard(): + self.root.destroy() + + try: + app = DevlogEditorApp() + except tk.TclError as exc: + raise DevlogError(f"Cannot open the Tkinter window: {exc}") from exc + app.root.mainloop() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--validate", action="store_true", + help="validate the local Labyricorn devlog without opening the GUI", + ) + args = parser.parse_args() + try: + git = GitRepository(Path(__file__).resolve().parent) + content = DevlogRepository(git) + if args.validate: + content.validate_all(validate_commits=True) + print("devlog-editor: devlog is valid") + else: + run_gui(content) + except DevlogError as exc: + parser.exit(1, f"devlog-editor: ERROR: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/devlog_editor.py b/scripts/devlog_editor.py new file mode 100644 index 0000000..3b3bc2e --- /dev/null +++ b/scripts/devlog_editor.py @@ -0,0 +1,1769 @@ +#!/usr/bin/env python3 +"""Standalone editor for repository-owned Labyricorn project and devlog content.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import date +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unicodedata +from urllib.parse import urlsplit, urlunsplit + + +SCHEMA_VERSION = "1" +DEVLOG_RELATIVE = Path(".labyricorn") / "devlog" +PROJECT_RELATIVE = Path(".labyricorn") / "project" / "contents.lr" +PUBLISHING_PATHS = ( + ".labyricorn/AGENTS.md", + ".labyricorn/README.md", + ".labyricorn/project", + ".labyricorn/devlog", +) +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SEPARATOR_RE = re.compile(r"(?m)(^---[ \t]*(?:\r\n|\n|$))") +FIELD_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*):(.*)") +RAW_HTML_RE = re.compile(r"<\s*(?:!|/?[A-Za-z])[\s\S]*?>") +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +IGNORED_DOCUMENTATION = {"AGENTS.md", "README.md"} +MAX_FILES = 100 +MAX_FILE_SIZE = 5 * 1024 * 1024 +MAX_TOTAL_SIZE = 20 * 1024 * 1024 + +ROOT_AGENTS_TEXT = """# Instructions for Labyricorn publishing content + +These instructions apply to the entire `.labyricorn/` directory. More specific +instructions in `project/AGENTS.md` and `devlog/AGENTS.md` also apply within +those directories. + +## Purpose and ownership + +- This directory is the repository-owned source for the project's exhibition + page and development log on Labyricorn. +- This project repository is authoritative for the content. The Labyricorn site + imports it as read-only content. +- Read `.labyricorn/README.md` and the nearest scoped `AGENTS.md` before editing + a publishing record. +- Keep publishing changes focused. Do not alter application code merely to + support an exhibition or devlog edit unless the user separately requests it. + +## Authorization and content contract + +- Preserve unrelated and uncommitted work. Never discard changes to obtain a + clean working tree. +- Do not commit or push unless the user explicitly requests publication. +- Use native Lektor records named `contents.lr` and preserve `schema_version: 1` + until a coordinated schema migration is approved. +- Keep attachments inside the record subtree that owns them. +- Use UTF-8 text, `YYYY-MM-DD` dates, and stable lowercase hyphenated slugs. +- Do not add templates, models, plugins, workflows, executable files, builds, + application state, credentials, raw HTML, scripts, or active third-party + content below `.labyricorn/`. +- Treat published URLs as durable. Ask before renaming or removing a published + record; redirects or archival behavior may be required first. + +## Security, synchronization, and review + +- Never store or print passwords, API tokens, refresh credentials, private keys, + `.netrc` contents, or other secrets in this directory. +- Do not claim content is public merely because a push succeeded. Report push + status and Labyricorn synchronization status separately. +- Parse changed records, confirm referenced source commits exist, run + `git diff --check`, and review the exact publishing diff before committing. +- At handoff, report changed publishing records, validation performed, commit + and push status, and synchronization status if known. +""" + +ROOT_README_TEMPLATE = """# Labyricorn publishing content + +This directory is the repository-owned source for {project_title}'s project +exhibition and development log on Labyricorn. The project repository remains +the source of truth; the Labyricorn site imports this content as read-only. + +The content uses native Lektor records: + +```text +.labyricorn/ +├── AGENTS.md +├── README.md +├── project/ +│ ├── AGENTS.md +│ ├── contents.lr +│ └── +└── devlog/ + ├── AGENTS.md + ├── contents.lr + └── / + ├── contents.lr + └── +``` + +Rules: + +- `project/contents.lr` uses the `project` model. +- `devlog/contents.lr` uses the `devlog` model. +- Each entry is a directory below `devlog/` containing a `contents.lr` record + using the `devlog-entry` model. +- Entry directory names are stable public slugs. Do not rename a published entry + without arranging a redirect on the Labyricorn site. +- Dates use `YYYY-MM-DD`; `source_commit` uses the full relevant commit ID. +- Publishing attachments stay inside the record subtree that owns them. +- Do not put credentials, builds, application state, or executable code here. + +Editing these records does not itself prove that the public site synchronized. +Report repository publication and Labyricorn synchronization separately. +""" + +PROJECT_AGENTS_TEXT = """# Instructions for the Labyricorn project record + +These instructions supplement `.labyricorn/AGENTS.md` and apply to the +`project/` directory. + +## Record structure + +- Maintain exactly one project record at `project/contents.lr` using + `_model: project` and `schema_version: 1`. +- Keep `project_id` stable. It is the durable identity used by the importer and + public URL. +- Ask before changing the repository URL, default branch, project status, + author, start date, or project identity. +- Store project-owned images in this directory and reference them by filename + from `contents.lr`. Only PNG, JPEG, and WebP images are accepted. + +## Editorial guidance + +- Write the exhibition page as a concise project narrative, not as a copy of + the repository README or as release documentation. +- Verify technical and status claims against the repository before editing. +- Distinguish completed work, formal design work, experiments, and future work. +- Do not manually add repository-derived commit, license, language, release, + issue, star, or fork metadata. The Labyricorn importer owns those values. +- Keep the summary suitable for listings and social previews. + +## Review requirements + +- Confirm all required project fields are present and any image reference stays + inside `project/`. +- Check Markdown links are intentional HTTPS links and prose makes no unsupported + claims. +- Validate the record and run `git diff --check` before committing. +""" + +DEVLOG_AGENTS_TEXT = """# Instructions for the Labyricorn development log + +These instructions apply to `.labyricorn/devlog/` and help coding assistants +maintain the project's public development narrative safely. + +## Format and ownership + +- Keep the devlog index at `devlog/contents.lr` using `_model: devlog` and + `schema_version: 1`. +- Store each entry at `devlog//contents.lr` using + `_model: devlog-entry` and `schema_version: 1`. +- Use lowercase hyphenated entry slugs. Published slugs are durable public URLs; + do not rename or delete them without explicit approval and a redirect or + archival decision. +- Every entry must contain `title`, `date`, `author`, `summary`, `tags`, + `source_commit`, and Markdown `body` fields. +- Keep entry attachments in that entry's directory. Only PNG, JPEG, and WebP + images are accepted by the Labyricorn importer. +- Do not add templates, models, plugins, executable code, builds, application + state, credentials, or active third-party content below this directory. + +## Content guidance + +- Base entries on verifiable repository history, project documentation, and + implemented behavior. Do not invent motivations, results, release status, + user feedback, or completion claims. +- Explain the milestone, why it mattered, and what changed. Keep one main + milestone per entry and write a summary that works in a chronological list. + Do not merely expand a commit message or enumerate every changed file. +- Distinguish design and schema contracts from completed runtime behavior. +- Link to the relevant canonical commit using the repository's HTTPS web URL. +- Use the referenced commit's actual `YYYY-MM-DD` calendar date for retrospective + entries, not the date on which the prose was drafted. +- Set `source_commit` to the full lowercase 40-character commit ID most directly + associated with the milestone. It must be an ancestor of the published branch. +- Topics are comma-separated display labels. Unknown topics are allowed: the + site keeps them visible but unlinked. Do not create or edit the site's tracked + tag registry from this project repository. +- Use Markdown without raw HTML, scripts, embedded credentials, or active + external content. +- Multiple entries may share a date. The site resolves ties from source history; + do not invent timestamps or manual next/previous links. +- Do not rewrite older entries merely because the implementation later changed. + Add a new entry or a clearly labelled correction when historical context is + needed. + +## Safety and review + +- Preserve unrelated project files and uncommitted work. The devlog owns only + `.labyricorn/devlog/`. +- Do not commit or push unless the user explicitly requests publication. +- Before publication, verify claims against the referenced commit, validate all + records, check that the source-commit link uses the same full commit ID, review + the exact devlog diff, and confirm unrelated files are not staged in the + publishing commit. +- Prefer the repository-root `devlog_editor.py` for routine validation and safe + publication when it is available. +""" + +PROJECT_REQUIRED_FIELDS = { + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "tags", "body", +} +DEVLOG_REQUIRED_FIELDS = {"_model", "schema_version", "title", "summary"} +ENTRY_REQUIRED_FIELDS = { + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +} +ENTRY_FIELD_ORDER = ( + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +) +PROJECT_FIELD_ORDER = ( + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "logo", "tags", "body", +) + + +class DevlogError(Exception): + """An expected error suitable for display to a nontechnical user.""" + + +class GitError(DevlogError): + """A safe summary of a failed Git operation.""" + + +def is_link(path: Path) -> bool: + return path.is_symlink() or bool(getattr(path, "is_junction", lambda: False)()) + + +def suggest_slug(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + return re.sub(r"[^a-z0-9]+", "-", normalized.casefold()).strip("-") + + +def is_publishing_path(value: str) -> bool: + value = value.replace("\\", "/") + return any(value == root or value.startswith(root + "/") for root in PUBLISHING_PATHS) + + +def validate_iso_date(value: str, label: str) -> str: + value = value.strip() + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise DevlogError(f"{label} must use YYYY-MM-DD.") from exc + if parsed.isoformat() != value: + raise DevlogError(f"{label} must use YYYY-MM-DD.") + return value + + +def normalized_topics(value: str) -> str: + values: list[str] = [] + for item in re.split(r"[,\n]", value): + item = item.strip().replace("\t", " ") + if item and item not in values: + values.append(item) + return ", ".join(values) + + +@dataclass +class LrDocument: + blocks: list[str] + delimiters: list[str] + newline: str + source: str + + @classmethod + def parse(cls, text: str, source: str) -> "LrDocument": + parts = SEPARATOR_RE.split(text) + blocks = [parts[0]] + delimiters: list[str] = [] + for index in range(1, len(parts), 2): + delimiters.append(parts[index]) + blocks.append(parts[index + 1]) + if not blocks or any(not block.strip() for block in blocks): + raise DevlogError(f"{source}: empty or malformed record block.") + document = cls(blocks, delimiters, "\r\n" if "\r\n" in text else "\n", source) + document.field_indexes() + return document + + def render(self) -> str: + pieces = [self.blocks[0]] + for delimiter, block in zip(self.delimiters, self.blocks[1:]): + pieces.extend((delimiter, block)) + return "".join(pieces) + + def parse_block(self, block: str) -> tuple[str, str]: + line_end = block.find("\n") + if line_end < 0: + first_line, remainder = block, "" + else: + first_line = block[:line_end].rstrip("\r") + remainder = block[line_end + 1 :] + match = FIELD_RE.fullmatch(first_line) + if match is None: + raise DevlogError(f"{self.source}: malformed field header {first_line!r}.") + key, inline = match.groups() + if inline.startswith(" "): + inline = inline[1:] + if inline: + value = inline + if remainder.rstrip("\r\n"): + value += "\n" + remainder.rstrip("\r\n") + else: + value = remainder.rstrip("\r\n") + if value.startswith("\r\n"): + value = value[2:] + elif value.startswith("\n"): + value = value[1:] + return key, value + + def field_indexes(self) -> dict[str, int]: + indexes: dict[str, int] = {} + for index, block in enumerate(self.blocks): + key, _value = self.parse_block(block) + if key in indexes: + raise DevlogError(f"{self.source}: duplicate field {key!r}.") + indexes[key] = index + return indexes + + def values(self) -> dict[str, str]: + return {key: self.parse_block(self.blocks[index])[1] for key, index in self.field_indexes().items()} + + def get(self, key: str, default: str = "") -> str: + index = self.field_indexes().get(key) + return default if index is None else self.parse_block(self.blocks[index])[1] + + def set_field( + self, + key: str, + value: str, + *, + multiline: bool = False, + field_order: tuple[str, ...] = ENTRY_FIELD_ORDER, + ) -> None: + indexes = self.field_indexes() + current = indexes.get(key) + if current is not None and self.get(key) == value: + return + newline = self.newline + if multiline or "\n" in value: + clean = value.rstrip("\r\n") + rendered = f"{key}:{newline}{newline}{clean}{newline}" if clean else f"{key}:{newline}" + else: + rendered = f"{key}: {value}{newline}" + if current is not None: + self.blocks[current] = rendered + return + desired = field_order.index(key) if key in field_order else len(field_order) + insertion = len(self.blocks) + for candidate, index in indexes.items(): + candidate_order = field_order.index(candidate) if candidate in field_order else len(field_order) + if candidate_order > desired: + insertion = min(insertion, index) + self.blocks.insert(insertion, rendered) + delimiter = f"---{newline}" + self.delimiters.insert(0 if insertion == 0 else insertion - 1, delimiter) + + +@dataclass +class DevlogEntry: + slug: str + path: Path + document: LrDocument + original_bytes: bytes + + @property + def title(self) -> str: + return self.document.get("title") + + @property + def publication_date(self) -> str: + return self.document.get("date") + + +@dataclass +class RepositoryStatus: + name: str + branch: str + remote_name: str + remote_url: str + upstream: str + ahead: int + behind: int + changed_count: int + publishing_changed_count: int + conflicts: list[str] + operation: str + short_status: str + + @property + def state_text(self) -> str: + if self.operation: + return f"{self.operation} is in progress — manual Git assistance required" + if self.conflicts: + return "Repository has unresolved conflicts — manual Git assistance required" + if not self.branch: + return "Detached Git checkout — manual Git assistance required" + if not self.remote_name: + return "No remote is configured" + if not self.upstream: + return "No upstream branch is configured" + if self.ahead and self.behind: + return "Local and remote histories have diverged — manual Git assistance required" + if self.behind: + return f"Remote has {self.behind} newer commit(s)" + if self.ahead: + return f"Local branch has {self.ahead} unpublished commit(s)" + return "Up to date (as of the last fetch)" + + +class GitRepository: + def __init__(self, start: Path): + self.start = start.resolve() + try: + result = subprocess.run( + ["git", "-C", str(self.start), "rev-parse", "--show-toplevel"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not respond while locating the repository.") from exc + if result.returncode != 0: + raise GitError("devlog_editor.py is not inside a Git repository.") + self.root = Path(result.stdout.strip()).resolve() + if self.start != self.root: + raise GitError( + "Place devlog_editor.py in the root of this project repository, then run it again.\n\n" + f"Detected repository root: {self.root}" + ) + self.git_dir = Path(self.run("rev-parse", "--absolute-git-dir").strip()).resolve() + + @staticmethod + def _safe_url(value: str) -> str: + value = value.strip() + try: + parsed = urlsplit(value) + except ValueError: + return "configured" + if parsed.scheme and parsed.hostname: + host = parsed.hostname + if parsed.port: + host += f":{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return re.sub(r"//[^/@\s]+@", "//***@", value) + + def _sanitize(self, message: str) -> str: + message = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", message) + return message.strip() + + def run(self, *args: str, timeout: int = 30, check: bool = True) -> str: + try: + result = subprocess.run( + ["git", "-C", str(self.root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not finish in time. Check the network and Git authentication.") from exc + if check and result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) or "Git returned an error." + raise GitError(detail) + return result.stdout + + def result(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["git", "-C", str(self.root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise GitError("Git could not complete the requested operation.") from exc + + @staticmethod + def count_porcelain(raw: str) -> int: + chunks = raw.split("\0") + count = 0 + index = 0 + while index < len(chunks): + record = chunks[index] + if not record: + index += 1 + continue + count += 1 + code = record[:2] + index += 2 if "R" in code or "C" in code else 1 + return count + + def branch(self) -> str: + return self.run("branch", "--show-current").strip() + + def upstream_configuration(self, branch: str) -> tuple[str, str, str, str]: + if not branch: + return "", "", "", "" + remote = self.run("config", "--get", f"branch.{branch}.remote", check=False).strip() + if not remote: + remotes = self.run("remote", check=False).splitlines() + if "origin" in remotes: + remote = "origin" + merge_ref = self.run("config", "--get", f"branch.{branch}.merge", check=False).strip() + upstream = self.run( + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", check=False + ).strip() + url = self.run("remote", "get-url", remote, check=False).strip() if remote and remote != "." else "" + return remote, merge_ref, upstream, url + + def operation_in_progress(self) -> str: + states = ( + ("MERGE_HEAD", "A merge"), ("rebase-merge", "A rebase"), + ("rebase-apply", "A rebase"), ("CHERRY_PICK_HEAD", "A cherry-pick"), + ("REVERT_HEAD", "A revert"), ("BISECT_LOG", "A bisect"), + ) + for marker, label in states: + path_text = self.run("rev-parse", "--git-path", marker).strip() + marker_path = Path(path_text) + if path_text and not marker_path.is_absolute(): + marker_path = self.root / marker_path + if path_text and marker_path.exists(): + return label + return "" + + def status(self) -> RepositoryStatus: + branch = self.branch() + remote, _merge_ref, upstream, remote_url = self.upstream_configuration(branch) + ahead = behind = 0 + if upstream: + counts = self.run("rev-list", "--left-right", "--count", f"HEAD...{upstream}").split() + if len(counts) == 2: + ahead, behind = map(int, counts) + raw = self.run("status", "--porcelain=v1", "-z", "--untracked-files=all") + publishing_raw = self.run( + "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", + *PUBLISHING_PATHS, + ) + conflicts = [ + item for item in self.run("diff", "--name-only", "--diff-filter=U", "-z").split("\0") if item + ] + display_url = self._safe_url(remote_url) if remote_url else "" + name_source = display_url.rstrip("/").rsplit("/", 1)[-1] if display_url else self.root.name + name = name_source.removesuffix(".git") or self.root.name + short_status = self.run("status", "--short", "--untracked-files=all").strip() + return RepositoryStatus( + name, branch, remote, display_url, upstream, ahead, behind, + self.count_porcelain(raw), self.count_porcelain(publishing_raw), conflicts, + self.operation_in_progress(), short_status, + ) + + def fetch(self) -> RepositoryStatus: + status = self.status() + if not status.remote_name or status.remote_name == ".": + raise GitError("This branch has no network remote to fetch.") + self.run("fetch", "--prune", status.remote_name, timeout=120) + return self.status() + + def get_latest(self) -> RepositoryStatus: + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "Git could not fast-forward without risking local work. No files were discarded.\n\n" + + (detail or "Commit or move the overlapping changes, then try again.") + ) + return self.status() + + @staticmethod + def ensure_safe_base(status: RepositoryStatus) -> None: + if status.operation: + raise GitError(f"{status.operation} is already in progress. Manual Git assistance is required.") + if status.conflicts: + raise GitError("The repository has unresolved conflicts. Manual Git assistance is required.") + if not status.branch: + raise GitError("The repository is in a detached checkout. Manual Git assistance is required.") + if not status.remote_name or not status.upstream: + raise GitError("The current branch needs a configured remote and upstream before it can synchronize.") + + def validate_commit(self, commit: str, imported_head: str = "HEAD") -> None: + if not COMMIT_RE.fullmatch(commit): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + result = self.result("merge-base", "--is-ancestor", commit, imported_head) + if result.returncode != 0: + raise DevlogError( + f"Source commit {commit[:10]} is not an ancestor of the current branch." + ) + + def head_defaults(self) -> tuple[str, str, str]: + raw = self.run("show", "-s", "--format=%H%x00%cs%x00%an", "HEAD").rstrip("\n") + parts = raw.split("\0", 2) + if len(parts) != 3: + raise GitError("Could not read the current commit metadata.") + return parts[0], parts[1], parts[2] + + def project_defaults(self) -> dict[str, str]: + status = self.status() + _commit, current_date, author = self.head_defaults() + dates = [line.strip() for line in self.run("log", "--reverse", "--format=%cs").splitlines() if line.strip()] + started = dates[0] if dates else current_date + repository_url = status.remote_url.rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if parsed.scheme != "https" or not parsed.netloc: + repository_url = "" + title = re.sub(r"[-_]+", " ", status.name).strip().title() or self.root.name + return { + "project_id": suggest_slug(status.name), + "title": title, + "status": "active", + "started": started, + "author": author, + "repository_url": repository_url, + "default_branch": status.branch, + "tags": "", + "summary": "", + "body": "", + "devlog_title": f"{title} development log", + "devlog_summary": f"Milestones and design decisions from the development of {title}.", + } + + def owned_outgoing_paths(self, upstream: str) -> tuple[bool, list[str]]: + paths = [ + item for item in self.run("diff", "--name-only", "-z", f"{upstream}..HEAD").split("\0") if item + ] + unrelated = [path for path in paths if not is_publishing_path(path)] + return not unrelated, unrelated + + def publish(self, content: "DevlogRepository") -> str: + content.validate_all(validate_commits=True) + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "The remote is newer, but Git could not fast-forward while preserving local files. " + "Publishing stopped and no files were discarded.\n\n" + detail + ) + content.validate_all(validate_commits=True) + status = self.status() + + self.run("add", "-A", "--", *PUBLISHING_PATHS) + staged = self.result("diff", "--cached", "--quiet", "--", *PUBLISHING_PATHS) + created_commit = staged.returncode == 1 + if staged.returncode not in {0, 1}: + raise GitError("Git could not inspect the staged Labyricorn changes.") + if created_commit: + result = self.result( + "commit", "-m", "Update Labyricorn project and devlog", "--", + *PUBLISHING_PATHS, timeout=60 + ) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + if "identity unknown" in detail.casefold() or "user.email" in detail.casefold(): + detail += "\n\nConfigure your Git name and email, then try publishing again." + raise GitError("Git could not create the Labyricorn publishing commit.\n\n" + detail) + + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError( + "The remote changed while publishing. Your publishing commit is safe locally, but automatic " + "publishing stopped.\n\n" + divergence_message() + ) + if not status.ahead: + return "No unpublished Labyricorn changes were found." + owned, unrelated = self.owned_outgoing_paths(status.upstream) + if not owned: + examples = "\n".join(f" • {path}" for path in unrelated[:8]) + raise GitError( + "Pushing would also publish existing commits that change files outside the approved " + "Labyricorn project/devlog paths. " + "The editor stopped at its ownership boundary. Manual Git assistance is required.\n\n" + examples + ) + branch = status.branch + _remote, merge_ref, _upstream, _url = self.upstream_configuration(branch) + if not merge_ref.startswith("refs/heads/"): + raise GitError("The configured upstream branch is not a normal remote branch.") + result = self.result("push", status.remote_name, f"HEAD:{merge_ref}", timeout=120) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + hint = ( + "\n\nGit authentication is not configured or was rejected. Configure access for this " + "repository and use Publish again; the publishing commit remains safe locally." + if any(word in detail.casefold() for word in ("authentication", "credential", "permission denied", "could not read username")) + else "\n\nThe publishing commit remains safe locally. Check the network or remote, then try Publish again." + ) + raise GitError("Git could not push the Labyricorn changes.\n\n" + detail + hint) + return "Labyricorn project and devlog changes were committed and pushed successfully." + + +def divergence_message() -> str: + return ( + "The local and remote repositories both contain changes that the other does not have.\n\n" + "Automatic publishing has stopped to protect the repository. Someone familiar with Git " + "needs to resolve the repository history before publishing can continue." + ) + + +class DevlogRepository: + def __init__(self, git: GitRepository): + self.git = git + self.root = git.root + self.labyricorn_root = (self.root / ".labyricorn").resolve() + self.devlog_root = (self.root / DEVLOG_RELATIVE).resolve() + self.project_path = (self.root / PROJECT_RELATIVE).resolve() + + def ensure_within(self, path: Path, parent: Path) -> Path: + resolved = path.resolve() + try: + resolved.relative_to(parent.resolve()) + except ValueError as exc: + raise DevlogError(f"Unsafe path outside {parent}: {path}") from exc + return resolved + + def read_document(self, path: Path) -> tuple[LrDocument, bytes]: + try: + raw = path.read_bytes() + if len(raw) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing file exceeds {MAX_FILE_SIZE} bytes: {path}") + text = raw.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise DevlogError(f"Cannot read {path}: {exc}") from exc + document = LrDocument.parse(text, str(path)) + self.reject_raw_html(document) + return document, raw + + @staticmethod + def reject_raw_html(document: LrDocument) -> None: + for key, value in document.values().items(): + if key != "repository_url" and RAW_HTML_RE.search(value): + raise DevlogError(f"{document.source}: raw HTML is not allowed in {key}.") + + def validate_project(self) -> LrDocument: + if not self.project_path.is_file(): + raise DevlogError( + "This repository has no .labyricorn/project/contents.lr project identity record." + ) + self.ensure_within(self.project_path, self.labyricorn_root) + document, _raw = self.read_document(self.project_path) + values = document.values() + missing = PROJECT_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Project identity record is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "project" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Project identity record uses an unsupported model or schema version.") + self.clean_project_values(values) + logo = values.get("logo", "").strip() + if logo: + logo_path = self.project_path.parent / logo + if Path(logo).name != logo or logo_path.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project logo must be a local PNG, JPEG, or WebP filename.") + if not logo_path.is_file(): + raise DevlogError(f"Project logo is missing: .labyricorn/project/{logo}") + return document + + def clean_project_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = { + key: str(value).replace("\r\n", "\n").replace("\r", "\n") + for key, value in values.items() + } + for required in ( + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "body", + ): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"Project {required.replace('_', ' ')} is required.") + cleaned["project_id"] = cleaned["project_id"].strip() + if not SLUG_RE.fullmatch(cleaned["project_id"]): + raise DevlogError("Project ID must be a stable lowercase hyphenated slug.") + for scalar in ("title", "author", "status", "default_branch"): + cleaned[scalar] = " ".join(cleaned[scalar].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["started"] = validate_iso_date(cleaned["started"], "Project start date") + if cleaned["status"] not in {"active", "released", "maintained", "archived"}: + raise DevlogError("Project status must be active, released, maintained, or archived.") + branch = cleaned["default_branch"] + if not re.fullmatch(r"[A-Za-z0-9._/-]+", branch) or ".." in branch: + raise DevlogError("Project default branch is invalid.") + repository_url = cleaned["repository_url"].strip().rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if ( + parsed.scheme != "https" or not parsed.hostname or parsed.username + or parsed.password or parsed.query or parsed.fragment + ): + raise DevlogError("Project repository URL must be a credential-free HTTPS web URL.") + cleaned["repository_url"] = repository_url + for field in ("title", "summary", "author", "tags", "body"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in project {field}.") + return cleaned + + def initialization_state(self) -> tuple[str, str]: + if not (self.root / ".labyricorn").exists(): + return ( + "ready_project", + "This repository does not have Labyricorn project publishing content yet.", + ) + if is_link(self.root / ".labyricorn"): + return "malformed", ".labyricorn must not be a symbolic link or junction." + try: + project = self.validate_project() + except DevlogError as exc: + return "malformed", str(exc) + devlog_path = self.root / DEVLOG_RELATIVE + if not devlog_path.exists(): + return "ready_devlog", f"{project.get('title')} does not have a Labyricorn devlog yet." + if is_link(devlog_path) or not devlog_path.is_dir(): + return "malformed", ".labyricorn/devlog is not a normal directory." + index = devlog_path / "contents.lr" + if not index.is_file(): + return "malformed", ".labyricorn/devlog exists but has no contents.lr index. It was not overwritten." + try: + self.validate_all(validate_commits=False) + except DevlogError as exc: + return "malformed", str(exc) + return "initialized", "" + + def validate_image(self, path: Path) -> None: + try: + data = path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read image {path}: {exc}") from exc + suffix = path.suffix.lower() + signatures = { + ".png": (b"\x89PNG\r\n\x1a\n",), ".jpg": (b"\xff\xd8\xff",), + ".jpeg": (b"\xff\xd8\xff",), ".webp": (b"RIFF",), + } + if len(data) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing image exceeds {MAX_FILE_SIZE} bytes: {path}") + if not any(data.startswith(prefix) for prefix in signatures[suffix]): + raise DevlogError(f"Image signature does not match its extension: {path}") + if suffix == ".webp" and data[8:12] != b"WEBP": + raise DevlogError(f"Image signature does not match its extension: {path}") + + def validate_tree(self) -> None: + laby = self.root / ".labyricorn" + if not laby.is_dir() or is_link(laby): + raise DevlogError(".labyricorn must be a normal directory.") + files = 0 + total = 0 + for path in laby.rglob("*"): + if is_link(path): + raise DevlogError(f"Linked publishing paths are not allowed: {path}") + relative = path.relative_to(laby) + parts = relative.parts + if path.is_dir(): + allowed = ( + parts in {("project",), ("devlog",)} + or (len(parts) == 2 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1])) + ) + if not allowed: + raise DevlogError(f"Unsupported publishing directory: .labyricorn/{relative.as_posix()}") + continue + if path.name in IGNORED_DOCUMENTATION: + continue + files += 1 + try: + size = path.stat().st_size + except OSError as exc: + raise DevlogError(f"Cannot inspect {path}: {exc}") from exc + total += size + is_project_record = parts == ("project", "contents.lr") + is_devlog_index = parts == ("devlog", "contents.lr") + is_entry_record = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and parts[2] == "contents.lr" + ) + is_project_image = len(parts) == 2 and parts[0] == "project" and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + is_entry_image = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + ) + if not (is_project_record or is_devlog_index or is_entry_record or is_project_image or is_entry_image): + raise DevlogError(f"Unsupported publishing file: .labyricorn/{relative.as_posix()}") + if is_project_image or is_entry_image: + self.validate_image(path) + if files > MAX_FILES or total > MAX_TOTAL_SIZE: + raise DevlogError("The publishing tree exceeds the importer file or size limit.") + required_guidance = ( + laby / "AGENTS.md", + laby / "README.md", + laby / "project" / "AGENTS.md", + laby / "devlog" / "AGENTS.md", + ) + missing_guidance = [path.relative_to(self.root).as_posix() for path in required_guidance if not path.is_file()] + if missing_guidance: + raise DevlogError( + "Labyricorn assistant guidance is incomplete; missing: " + + ", ".join(missing_guidance) + ) + + def validate_index(self) -> LrDocument: + path = self.devlog_root / "contents.lr" + if not path.is_file(): + raise DevlogError("Devlog index is missing: .labyricorn/devlog/contents.lr") + document, _raw = self.read_document(path) + values = document.values() + missing = DEVLOG_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Devlog index is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "devlog" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Devlog index uses an unsupported model or schema version.") + if not values["title"].strip() or not values["summary"].strip(): + raise DevlogError("Devlog index title and summary must not be empty.") + return document + + def clean_entry_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = {key: value.replace("\r\n", "\n").replace("\r", "\n") for key, value in values.items()} + for required in ("title", "author", "summary", "body"): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"{required.replace('_', ' ').title()} is required.") + cleaned["title"] = " ".join(cleaned["title"].splitlines()).strip() + cleaned["author"] = " ".join(cleaned["author"].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["date"] = validate_iso_date(cleaned.get("date", ""), "Publication date") + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["source_commit"] = cleaned.get("source_commit", "").strip() + if not COMMIT_RE.fullmatch(cleaned["source_commit"]): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + for field in ("title", "author", "summary", "body", "tags"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in {field}.") + self.git.validate_commit(cleaned["source_commit"]) + return cleaned + + def validate_loaded_entry(self, document: LrDocument, slug: str, *, validate_commit: bool) -> None: + values = document.values() + missing = ENTRY_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"devlog/{slug}/contents.lr is missing: {', '.join(sorted(missing))}.") + if document.get("_model") != "devlog-entry" or document.get("schema_version") != SCHEMA_VERSION: + raise DevlogError(f"devlog/{slug}/contents.lr uses an unsupported model or schema version.") + validate_iso_date(values["date"], "Publication date") + if not COMMIT_RE.fullmatch(values["source_commit"]): + raise DevlogError(f"devlog/{slug}: source_commit must be a full lowercase commit ID.") + for required in ("title", "author", "summary", "body"): + if not values[required].strip(): + raise DevlogError(f"devlog/{slug}: {required} must not be empty.") + if validate_commit: + self.git.validate_commit(values["source_commit"]) + + def list_entries(self, *, validate_commits: bool = False) -> list[DevlogEntry]: + if not self.devlog_root.is_dir(): + return [] + entries: list[DevlogEntry] = [] + for directory in self.devlog_root.iterdir(): + if not directory.is_dir(): + continue + if not SLUG_RE.fullmatch(directory.name): + raise DevlogError(f"Invalid devlog entry slug: {directory.name}") + entries.append(self.load_entry(directory.name, validate_commit=validate_commits)) + entries.sort(key=lambda entry: (entry.publication_date, entry.title, entry.slug), reverse=True) + return entries + + def load_entry(self, slug: str, *, validate_commit: bool = False) -> DevlogEntry: + self.validate_slug(slug) + path = self.ensure_within(self.devlog_root / slug / "contents.lr", self.devlog_root) + if not path.is_file(): + raise DevlogError(f"Devlog entry does not exist: {slug}") + document, raw = self.read_document(path) + self.validate_loaded_entry(document, slug, validate_commit=validate_commit) + return DevlogEntry(slug, path, document, raw) + + def validate_all(self, *, validate_commits: bool) -> None: + self.validate_tree() + self.validate_project() + self.validate_index() + self.list_entries(validate_commits=validate_commits) + + @staticmethod + def validate_slug(slug: str) -> None: + if not SLUG_RE.fullmatch(slug): + raise DevlogError("Slug must use lowercase letters, numbers, and single hyphens only.") + + def initialize( + self, + title: str, + summary: str, + project_values: dict[str, str] | None = None, + ) -> None: + state, message = self.initialization_state() + if state not in {"ready_project", "ready_devlog"}: + raise DevlogError(message or "Labyricorn publishing cannot be initialized in its current state.") + title = " ".join(title.splitlines()).strip() + summary = summary.replace("\r", " ").replace("\n", " ").strip() + if not title or not summary: + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(title) or RAW_HTML_RE.search(summary): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + devlog_index = self.root / DEVLOG_RELATIVE / "contents.lr" + devlog_payload = ( + f"_model: devlog\n---\nschema_version: {SCHEMA_VERSION}\n---\n" + f"title: {title}\n---\nsummary: {summary}\n" + ).encode("utf-8") + + files: dict[Path, bytes] = { + devlog_index: devlog_payload, + devlog_index.parent / "AGENTS.md": DEVLOG_AGENTS_TEXT.encode("utf-8"), + } + directories: list[Path] = [] + labyricorn = self.root / ".labyricorn" + project_directory = labyricorn / "project" + devlog_directory = labyricorn / "devlog" + + if state == "ready_project": + if project_values is None: + raise DevlogError("Project information is required for first-time initialization.") + cleaned = self.clean_project_values(project_values) + project_record = project_directory / "contents.lr" + logo_source_text = project_values.get("logo_source", "").strip() + logo_name = "" + logo_data = b"" + if logo_source_text: + logo_source = Path(logo_source_text).expanduser().resolve() + if is_link(logo_source) or not logo_source.is_file(): + raise DevlogError("The selected project image is not a normal file.") + if logo_source.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project image must be PNG, JPEG, or WebP.") + self.validate_image(logo_source) + try: + logo_data = logo_source.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read the selected project image: {exc}") from exc + logo_name = logo_source.name + document = LrDocument.parse( + f"_model: project\n---\nschema_version: {SCHEMA_VERSION}\n", + str(project_record), + ) + project_keys = [ + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "tags", "body", + ] + if logo_name: + cleaned["logo"] = logo_name + project_keys.insert(project_keys.index("tags"), "logo") + for key in project_keys: + document.set_field( + key, + cleaned[key], + multiline=key in {"summary", "body"}, + field_order=PROJECT_FIELD_ORDER, + ) + files.update( + { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=cleaned["title"] + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + project_record: document.render().encode("utf-8"), + } + ) + if logo_name: + files[project_directory / logo_name] = logo_data + directories.extend((labyricorn, project_directory, devlog_directory)) + else: + directories.append(devlog_directory) + optional_guidance = { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=self.validate_project().get("title") + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + } + files.update({path: payload for path, payload in optional_guidance.items() if not path.exists()}) + + created: list[Path] = [] + created_directories: list[Path] = [] + try: + for directory in directories: + directory.mkdir() + created_directories.append(directory) + for destination, payload in files.items(): + with destination.open("xb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + created.append(destination) + self.validate_all(validate_commits=False) + except (OSError, DevlogError) as exc: + try: + for path in reversed(created): + path.unlink(missing_ok=True) + for directory in reversed(created_directories): + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + if isinstance(exc, DevlogError): + raise + raise DevlogError(f"Cannot initialize Labyricorn publishing: {exc}") from exc + + def apply_values(self, document: LrDocument, values: dict[str, str]) -> None: + for key in ("title", "date", "author", "summary", "tags", "source_commit", "body"): + document.set_field(key, values[key], multiline=key in {"summary", "body"}) + + def create_entry(self, slug: str, values: dict[str, str]) -> DevlogEntry: + self.validate_slug(slug) + cleaned = self.clean_entry_values(values) + directory = self.ensure_within(self.devlog_root / slug, self.devlog_root) + destination = directory / "contents.lr" + if directory.exists(): + raise DevlogError(f"Devlog entry already exists: {slug}") + document = LrDocument.parse( + f"_model: devlog-entry\n---\nschema_version: {SCHEMA_VERSION}\n---\ntitle: placeholder\n", + str(destination), + ) + self.apply_values(document, cleaned) + try: + directory.mkdir() + with destination.open("xb") as output: + output.write(document.render().encode("utf-8")) + output.flush() + os.fsync(output.fileno()) + except OSError as exc: + try: + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + raise DevlogError(f"Cannot create {destination}: {exc}") from exc + return self.load_entry(slug, validate_commit=True) + + def save_entry(self, entry: DevlogEntry, values: dict[str, str]) -> DevlogEntry: + cleaned = self.clean_entry_values(values) + destination = self.ensure_within(entry.path, self.devlog_root) + try: + current = destination.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {destination}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before saving.") + self.apply_values(entry.document, cleaned) + payload = entry.document.render().encode("utf-8") + if payload != current: + self.atomic_replace(destination, payload) + return self.load_entry(entry.slug, validate_commit=True) + + @staticmethod + def atomic_replace(destination: Path, payload: bytes) -> None: + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(mode="wb", prefix=".contents.lr.", dir=destination.parent, delete=False) as output: + temporary = Path(output.name) + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.chmod(temporary, destination.stat().st_mode) + os.replace(temporary, destination) + except OSError as exc: + if temporary is not None: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise DevlogError(f"Cannot save {destination}: {exc}") from exc + + def delete_entry(self, entry: DevlogEntry) -> None: + self.validate_slug(entry.slug) + directory = self.ensure_within(entry.path.parent, self.devlog_root) + if directory.parent != self.devlog_root or directory.name != entry.slug or is_link(directory): + raise DevlogError(f"Unsafe devlog entry directory: {directory}") + try: + current = entry.path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {entry.path}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before deleting.") + children = list(directory.iterdir()) + for child in children: + if is_link(child) or not child.is_file(): + raise DevlogError(f"Refusing to delete an entry containing an unsafe path: {child}") + if child.name != "contents.lr" and child.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError(f"Refusing to delete an unsupported entry file: {child}") + try: + for child in children: + child.unlink() + directory.rmdir() + except OSError as exc: + raise DevlogError(f"Cannot delete {directory}: {exc}") from exc + + +def run_gui(content: DevlogRepository) -> None: + import tkinter as tk + from tkinter import filedialog, messagebox, simpledialog, ttk + + class DevlogEditorApp: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Labyricorn Devlog") + self.root.minsize(900, 640) + self.current_entry: DevlogEntry | None = None + self.snapshot: dict[str, str] | None = None + self.mode = "new" + + outer = ttk.Frame(self.root, padding=12) + outer.pack(fill=tk.BOTH, expand=True) + outer.columnconfigure(0, weight=1) + outer.rowconfigure(1, weight=1) + + project = ttk.LabelFrame(outer, text="Project", padding=10) + project.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + project.columnconfigure(0, weight=1) + self.repo_var = tk.StringVar() + self.state_var = tk.StringVar() + self.changes_var = tk.StringVar() + ttk.Label(project, textvariable=self.repo_var).grid(row=0, column=0, sticky="w") + ttk.Label(project, textvariable=self.state_var).grid(row=1, column=0, sticky="w", pady=(3, 0)) + ttk.Label(project, textvariable=self.changes_var).grid(row=2, column=0, sticky="w", pady=(3, 0)) + controls = ttk.Frame(project) + controls.grid(row=0, column=1, rowspan=3, sticky="e") + ttk.Button(controls, text="Refresh", command=self.refresh_remote).grid(row=0, column=0, padx=3) + ttk.Button(controls, text="Get Latest Version", command=self.get_latest).grid(row=0, column=1, padx=3) + ttk.Button(controls, text="Details", command=self.show_details).grid(row=0, column=2, padx=3) + + self.devlog = ttk.LabelFrame(outer, text="Devlog", padding=10) + self.devlog.grid(row=1, column=0, sticky="nsew") + self.devlog.columnconfigure(0, weight=1) + self.devlog.rowconfigure(0, weight=1) + + self.status_var = tk.StringVar() + footer = ttk.Frame(outer) + footer.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + footer.columnconfigure(0, weight=1) + ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w") + ttk.Button(footer, text="Publish Labyricorn Changes", command=self.publish).grid(row=0, column=1, sticky="e") + + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.bind("", lambda _event: self.save()) + self.build_content_area() + self.refresh_status() + + def clear_devlog(self) -> None: + for child in self.devlog.winfo_children(): + child.destroy() + + def build_content_area(self) -> None: + self.clear_devlog() + state, message = content.initialization_state() + if state != "initialized": + panel = ttk.Frame(self.devlog, padding=30) + panel.grid(row=0, column=0, sticky="nsew") + panel.columnconfigure(0, weight=1) + ttk.Label(panel, text=message, wraplength=700, justify="center").grid(row=0, column=0, pady=(80, 15)) + button_text = ( + "Initialize Labyricorn Project & Devlog" + if state == "ready_project" + else "Initialize Devlog" + ) + button = ttk.Button(panel, text=button_text, command=self.initialize) + button.grid(row=1, column=0) + if state not in {"ready_project", "ready_devlog"}: + button.configure(state="disabled") + self.status_var.set( + "Labyricorn publishing is not initialized" + if state in {"ready_project", "ready_devlog"} + else "Labyricorn publishing needs attention" + ) + return + + pane = ttk.Panedwindow(self.devlog, orient=tk.HORIZONTAL) + pane.grid(row=0, column=0, sticky="nsew") + browser = ttk.Frame(pane, padding=5) + form = ttk.Frame(pane, padding=5) + pane.add(browser, weight=1) + pane.add(form, weight=3) + + columns = ("date", "title", "slug") + self.tree = ttk.Treeview(browser, columns=columns, show="headings") + for column, label, width in (("date", "Date", 90), ("title", "Title", 220), ("slug", "Slug", 170)): + self.tree.heading(column, text=label) + self.tree.column(column, width=width, stretch=column != "date") + scroll = ttk.Scrollbar(browser, orient=tk.VERTICAL, command=self.tree.yview) + self.tree.configure(yscrollcommand=scroll.set) + self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + scroll.grid(row=0, column=3, sticky="ns") + browser.rowconfigure(0, weight=1) + browser.columnconfigure(0, weight=1) + ttk.Button(browser, text="New", command=self.new_entry).grid(row=1, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(browser, text="Edit", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0)) + ttk.Button(browser, text="Delete", command=self.delete_selected).grid(row=1, column=2, sticky="ew", pady=(8, 0)) + self.tree.bind("", lambda _event: self.edit_selected()) + + self.title_var = tk.StringVar() + self.slug_var = tk.StringVar() + self.date_var = tk.StringVar() + self.author_var = tk.StringVar() + self.commit_var = tk.StringVar() + self.topics_var = tk.StringVar() + form.columnconfigure(1, weight=1) + form.columnconfigure(3, weight=1) + form.rowconfigure(7, weight=1) + ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w") + ttk.Entry(form, textvariable=self.title_var).grid(row=0, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Slug").grid(row=1, column=0, sticky="w") + self.slug_entry = ttk.Entry(form, textvariable=self.slug_var) + self.slug_entry.grid(row=1, column=1, columnspan=2, sticky="ew", pady=3) + ttk.Button(form, text="Suggest", command=self.fill_slug).grid(row=1, column=3, sticky="e", padx=(5, 0)) + ttk.Label(form, text="Publication date").grid(row=2, column=0, sticky="w") + ttk.Entry(form, textvariable=self.date_var).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(form, text="Author").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(form, textvariable=self.author_var).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(form, text="Source commit").grid(row=3, column=0, sticky="w") + ttk.Entry(form, textvariable=self.commit_var).grid(row=3, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Topics (comma-separated)").grid(row=4, column=0, sticky="w") + ttk.Entry(form, textvariable=self.topics_var).grid(row=4, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Summary").grid(row=5, column=0, sticky="nw", pady=(5, 0)) + self.summary_text = tk.Text(form, height=4, wrap="word", undo=True) + self.summary_text.grid(row=5, column=1, columnspan=3, sticky="nsew", pady=3) + ttk.Label(form, text="Body (Markdown)").grid(row=7, column=0, sticky="nw", pady=(5, 0)) + body_frame = ttk.Frame(form) + body_frame.grid(row=7, column=1, columnspan=3, sticky="nsew", pady=3) + body_frame.rowconfigure(0, weight=1) + body_frame.columnconfigure(0, weight=1) + self.body_text = tk.Text(body_frame, wrap="word", undo=True) + body_scroll = ttk.Scrollbar(body_frame, orient=tk.VERTICAL, command=self.body_text.yview) + self.body_text.configure(yscrollcommand=body_scroll.set) + self.body_text.grid(row=0, column=0, sticky="nsew") + body_scroll.grid(row=0, column=1, sticky="ns") + ttk.Button(form, text="Save Entry", command=self.save).grid(row=8, column=3, sticky="e", pady=(8, 0)) + self.reload_entries() + self.new_entry(confirm=False) + + def initialize(self) -> None: + state, _message = content.initialization_state() + project_values: dict[str, str] | None = None + if state == "ready_project": + try: + result = self.ask_project_initialization(content.git.project_defaults()) + except DevlogError as exc: + messagebox.showerror("Cannot prepare initialization", str(exc)) + return + if result is None: + return + title = result.pop("devlog_title") + summary = result.pop("devlog_summary") + project_values = result + elif state == "ready_devlog": + try: + project = content.validate_project() + except DevlogError as exc: + messagebox.showerror("Cannot initialize devlog", str(exc)) + return + title = simpledialog.askstring( + "Initialize Devlog", "Devlog title:", + initialvalue=f"{project.get('title')} development log", parent=self.root + ) + if title is None: + return + summary = simpledialog.askstring( + "Initialize Devlog", "Short description of this development log:", parent=self.root + ) + if summary is None: + return + else: + messagebox.showerror("Cannot initialize", "The publishing structure is incomplete or malformed.") + return + try: + content.initialize(title, summary, project_values) + except DevlogError as exc: + messagebox.showerror("Cannot initialize Labyricorn publishing", str(exc)) + return + self.build_content_area() + self.refresh_status() + self.status_var.set("Initialized the Labyricorn project record and devlog") + + def ask_project_initialization( + self, defaults: dict[str, str] + ) -> dict[str, str] | None: + dialog = tk.Toplevel(self.root) + dialog.title("Initialize Labyricorn Project & Devlog") + dialog.transient(self.root) + dialog.grab_set() + dialog.minsize(760, 680) + dialog.geometry("820x760") + frame = ttk.Frame(dialog, padding=14) + frame.pack(fill=tk.BOTH, expand=True) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(3, weight=1) + frame.rowconfigure(8, weight=1) + + variables = { + key: tk.StringVar(value=defaults.get(key, "")) + for key in ( + "project_id", "title", "status", "started", "author", + "repository_url", "default_branch", "tags", "logo_source", + "devlog_title", + ) + } + ttk.Label(frame, text="Project exhibition record").grid( + row=0, column=0, columnspan=4, sticky="w", pady=(0, 8) + ) + ttk.Label(frame, text="Project title").grid(row=1, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["title"]).grid(row=1, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Stable project ID").grid(row=1, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["project_id"]).grid(row=1, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Status").grid(row=2, column=0, sticky="w") + ttk.Combobox( + frame, textvariable=variables["status"], state="readonly", + values=("active", "released", "maintained", "archived"), + ).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Started (YYYY-MM-DD)").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["started"]).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Author").grid(row=3, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["author"]).grid(row=3, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Default branch").grid(row=3, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["default_branch"]).grid(row=3, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Repository web URL").grid(row=4, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["repository_url"]).grid( + row=4, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Technologies/topics").grid(row=5, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["tags"]).grid(row=5, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Project image (optional)").grid(row=5, column=2, sticky="w", padx=(12, 0)) + image_picker = ttk.Frame(frame) + image_picker.grid(row=5, column=3, sticky="ew", pady=3) + image_picker.columnconfigure(0, weight=1) + ttk.Entry(image_picker, textvariable=variables["logo_source"]).grid(row=0, column=0, sticky="ew") + + def choose_logo() -> None: + selected = filedialog.askopenfilename( + title="Choose project image", + filetypes=( + ("Supported images", "*.png *.jpg *.jpeg *.webp"), + ("All files", "*.*"), + ), + parent=dialog, + ) + if selected: + variables["logo_source"].set(selected) + + ttk.Button(image_picker, text="Browse…", command=choose_logo).grid( + row=0, column=1, padx=(5, 0) + ) + ttk.Label(frame, text="Project summary").grid(row=6, column=0, sticky="nw", pady=(5, 0)) + project_summary = tk.Text(frame, height=3, wrap="word", undo=True) + project_summary.grid(row=6, column=1, columnspan=3, sticky="nsew", pady=3) + project_summary.insert("1.0", defaults.get("summary", "")) + ttk.Label(frame, text="Project narrative\n(Markdown)").grid(row=8, column=0, sticky="nw", pady=(5, 0)) + project_body = tk.Text(frame, height=8, wrap="word", undo=True) + project_body.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3) + project_body.insert("1.0", defaults.get("body", "")) + + ttk.Separator(frame).grid(row=9, column=0, columnspan=4, sticky="ew", pady=10) + ttk.Label(frame, text="Development log").grid(row=10, column=0, columnspan=4, sticky="w") + ttk.Label(frame, text="Devlog title").grid(row=11, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["devlog_title"]).grid( + row=11, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Devlog summary").grid(row=12, column=0, sticky="nw", pady=(5, 0)) + devlog_summary = tk.Text(frame, height=3, wrap="word", undo=True) + devlog_summary.grid(row=12, column=1, columnspan=3, sticky="nsew", pady=3) + devlog_summary.insert("1.0", defaults.get("devlog_summary", "")) + + result: dict[str, str] | None = None + + def accept() -> None: + nonlocal result + candidate = {key: variable.get() for key, variable in variables.items()} + candidate["summary"] = project_summary.get("1.0", "end-1c") + candidate["body"] = project_body.get("1.0", "end-1c") + candidate["devlog_summary"] = devlog_summary.get("1.0", "end-1c") + try: + content.clean_project_values(candidate) + if not candidate["devlog_title"].strip() or not candidate["devlog_summary"].strip(): + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(candidate["devlog_title"]) or RAW_HTML_RE.search(candidate["devlog_summary"]): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + except DevlogError as exc: + messagebox.showerror("Check project information", str(exc), parent=dialog) + return + result = candidate + dialog.destroy() + + buttons = ttk.Frame(frame) + buttons.grid(row=13, column=0, columnspan=4, sticky="e", pady=(12, 0)) + ttk.Button(buttons, text="Cancel", command=dialog.destroy).grid(row=0, column=0, padx=(0, 8)) + ttk.Button(buttons, text="Initialize", command=accept).grid(row=0, column=1) + dialog.protocol("WM_DELETE_WINDOW", dialog.destroy) + dialog.wait_window() + return result + + def collect_values(self) -> dict[str, str]: + return { + "title": self.title_var.get(), "date": self.date_var.get(), + "author": self.author_var.get(), "source_commit": self.commit_var.get(), + "tags": self.topics_var.get(), + "summary": self.summary_text.get("1.0", "end-1c"), + "body": self.body_text.get("1.0", "end-1c"), + } + + def set_values(self, values: dict[str, str]) -> None: + self.title_var.set(values.get("title", "")) + self.date_var.set(values.get("date", "")) + self.author_var.set(values.get("author", "")) + self.commit_var.set(values.get("source_commit", "")) + self.topics_var.set(normalized_topics(values.get("tags", ""))) + for widget, key in ((self.summary_text, "summary"), (self.body_text, "body")): + widget.delete("1.0", tk.END) + widget.insert("1.0", values.get(key, "")) + + def has_unsaved(self) -> bool: + return self.snapshot is not None and self.collect_values() != self.snapshot + + def may_discard(self) -> bool: + return not self.has_unsaved() or messagebox.askyesno("Discard changes?", "Discard unsaved changes in the current form?") + + def reload_entries(self, selected: str | None = None) -> None: + self.tree.delete(*self.tree.get_children()) + try: + entries = content.list_entries() + except DevlogError as exc: + messagebox.showerror("Cannot load devlog", str(exc)) + return + for entry in entries: + self.tree.insert("", tk.END, iid=entry.slug, values=(entry.publication_date, entry.title, entry.slug)) + if selected and self.tree.exists(selected): + self.tree.selection_set(selected) + self.tree.see(selected) + + def new_entry(self, confirm: bool = True) -> None: + if confirm and not self.may_discard(): + return + try: + commit, commit_date, author = content.git.head_defaults() + except DevlogError as exc: + messagebox.showerror("Cannot read Git history", str(exc)) + return + self.mode = "new" + self.current_entry = None + self.slug_entry.configure(state="normal") + self.slug_var.set("") + self.set_values({"date": commit_date, "author": author, "source_commit": commit}) + self.snapshot = self.collect_values() + self.status_var.set("Creating a new devlog entry") + + def edit_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to edit.") + return + if not self.may_discard(): + return + try: + entry = content.load_entry(selection[0], validate_commit=True) + except DevlogError as exc: + messagebox.showerror("Cannot load entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.status_var.set(f"Editing .labyricorn/devlog/{entry.slug}/contents.lr") + + def fill_slug(self) -> None: + if self.mode == "new": + self.slug_var.set(suggest_slug(self.title_var.get())) + + def save(self) -> None: + if not hasattr(self, "title_var"): + return + try: + if self.mode == "new": + entry = content.create_entry(self.slug_var.get().strip(), self.collect_values()) + else: + if self.current_entry is None: + raise DevlogError("No entry is loaded.") + entry = content.save_entry(self.current_entry, self.collect_values()) + except DevlogError as exc: + messagebox.showerror("Cannot save entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.reload_entries(entry.slug) + self.refresh_status() + self.status_var.set(f"Saved .labyricorn/devlog/{entry.slug}/contents.lr") + + def delete_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to delete.") + return + try: + entry = content.load_entry(selection[0]) + attachments = sum(1 for path in entry.path.parent.iterdir() if path.is_file() and path != entry.path) + except (DevlogError, OSError) as exc: + messagebox.showerror("Cannot inspect entry", str(exc)) + return + note = f"\n\nThis also removes {attachments} entry attachment(s)." if attachments else "" + if not messagebox.askyesno( + "Delete devlog entry?", + f"Delete {entry.title!r} ({entry.slug}) from the working tree?{note}\n\n" + "Git can recover the deletion until it is published.", icon="warning", + ): + return + try: + content.delete_entry(entry) + except DevlogError as exc: + messagebox.showerror("Cannot delete entry", str(exc)) + return + self.snapshot = None + self.reload_entries() + self.new_entry(confirm=False) + self.refresh_status() + self.status_var.set(f"Deleted .labyricorn/devlog/{entry.slug}") + + def refresh_status(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + self.state_var.set(f"Status: {exc}") + return + remote = status.remote_name or "None" + self.repo_var.set(f"Repository: {status.name} Branch: {status.branch or 'detached'} Remote: {remote}") + self.state_var.set(f"Status: {status.state_text}") + word = "file" if status.publishing_changed_count == 1 else "files" + self.changes_var.set( + f"Labyricorn changes: {status.publishing_changed_count} unpublished {word}" + ) + + def refresh_remote(self) -> None: + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + content.git.fetch() + if content.initialization_state()[0] == "initialized": + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot refresh repository", str(exc)) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def get_latest(self) -> None: + if self.has_unsaved() and not messagebox.askyesno( + "Unsaved form changes", "The form has unsaved text. Continue only if it is also saved on disk?" + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + before = content.git.status() + after = content.git.get_latest() + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot get latest version", str(exc)) + else: + self.build_content_area() + messagebox.showinfo( + "Repository updated", + "The repository is already current." if not before.behind else f"Fast-forwarded by {before.behind} commit(s). Local files were preserved.", + ) + self.state_var.set(f"Status: {after.state_text}") + finally: + self.root.configure(cursor="") + self.refresh_status() + + def publish(self) -> None: + if self.has_unsaved(): + messagebox.showinfo("Save the entry first", "Save or discard the current form changes before publishing.") + return + if not messagebox.askyesno( + "Publish Labyricorn changes?", + "Validate, commit, and push only the project record, publishing guidance, " + "approved project images, and devlog inside .labyricorn/?\n\n" + "All files outside that boundary will be left out of the publishing commit.", + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + result = content.git.publish(content) + except DevlogError as exc: + messagebox.showerror("Publishing stopped safely", str(exc)) + else: + messagebox.showinfo("Publish Labyricorn Changes", result) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def show_details(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + messagebox.showerror("Cannot read details", str(exc)) + return + details = [ + f"Repository root: {content.git.root}", f"Branch: {status.branch or 'detached'}", + f"Remote: {status.remote_name or 'None'}", f"Remote URL: {status.remote_url or 'None'}", + f"Upstream: {status.upstream or 'None'}", f"Ahead / behind: {status.ahead} / {status.behind}", + f"Changed files: {status.changed_count}", + f"Labyricorn changed files: {status.publishing_changed_count}", + ] + if status.short_status: + details.extend(("", "Working-tree details:", status.short_status)) + messagebox.showinfo("Repository details", "\n".join(details)) + + def close(self) -> None: + if not hasattr(self, "title_var") or self.may_discard(): + self.root.destroy() + + try: + app = DevlogEditorApp() + except tk.TclError as exc: + raise DevlogError(f"Cannot open the Tkinter window: {exc}") from exc + app.root.mainloop() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--validate", action="store_true", + help="validate the local Labyricorn devlog without opening the GUI", + ) + args = parser.parse_args() + try: + git = GitRepository(Path(__file__).resolve().parent) + content = DevlogRepository(git) + if args.validate: + content.validate_all(validate_commits=True) + print("devlog-editor: devlog is valid") + else: + run_gui(content) + except DevlogError as exc: + parser.exit(1, f"devlog-editor: ERROR: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/git_repo.py b/scripts/git_repo.py new file mode 100644 index 0000000..32bdd40 --- /dev/null +++ b/scripts/git_repo.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +"""Small, conservative Git helper for the Labyricorn management launcher.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +import os +from pathlib import Path +import re +import subprocess +from urllib.parse import urlsplit, urlunsplit + + +SYNCED = "synchronized" +REMOTE_AHEAD = "remote-ahead" +LOCAL_AHEAD = "local-ahead" +DIVERGED = "diverged" +UNAVAILABLE = "unavailable" + + +class GitRepositoryError(Exception): + """An expected Git problem that can be displayed directly to a user.""" + + +@dataclass(frozen=True) +class RepositoryState: + root: Path + repository_name: str + branch: str + origin_url: str | None + upstream: str | None + upstream_branch: str | None + dirty_count: int + ahead: int | None + behind: int | None + sync_state: str + problem: str | None = None + origin_push_url: str | None = None + + @property + def status_text(self) -> str: + if self.sync_state == SYNCED: + return "Up to date" + if self.sync_state == REMOTE_AHEAD: + noun = "commit" if self.behind == 1 else "commits" + return f"Remote has {self.behind} newer {noun}" + if self.sync_state == LOCAL_AHEAD: + noun = "commit" if self.ahead == 1 else "commits" + return f"Local has {self.ahead} unpushed {noun}" + if self.sync_state == DIVERGED: + return "Local and remote have diverged" + return "Unavailable" + + @property + def working_tree_text(self) -> str: + if not self.dirty_count: + return "Clean" + noun = "path" if self.dirty_count == 1 else "paths" + return f"{self.dirty_count} changed {noun}" + + @property + def can_pull(self) -> bool: + return self.sync_state == REMOTE_AHEAD and self.dirty_count == 0 + + @property + def can_push(self) -> bool: + return self.sync_state == LOCAL_AHEAD + + +class GitRepository: + """Narrow wrapper around the installed Git CLI for one working tree.""" + + def __init__(self, root: Path): + self.root = root.resolve() + + @classmethod + def locate(cls, start: Path) -> "GitRepository": + start = start.resolve() + directory = start if start.is_dir() else start.parent + result = _run_git(directory, "rev-parse", "--show-toplevel", check=False) + if result.returncode != 0: + detail = _error_detail(result) + raise GitRepositoryError( + f"The launcher is not inside a Git repository. {detail}" + ) + root = Path(result.stdout.strip()).resolve() + try: + start.relative_to(root) + except ValueError as exc: + raise GitRepositoryError( + "Git returned a repository that does not contain the launcher." + ) from exc + return cls(root) + + def refresh(self) -> RepositoryState: + """Read local state, safely fetch origin metadata, and compare commits.""" + before_fetch = self._read_state(compare_remote=False) + if before_fetch.problem: + return before_fetch + result = self._git( + "fetch", "--no-tags", "--prune", "origin", check=False, timeout=60 + ) + if result.returncode != 0: + return replace( + before_fetch, + sync_state=UNAVAILABLE, + ahead=None, + behind=None, + problem=f"Fetch failed: {self._safe_detail(result)}", + ) + return self._read_state(compare_remote=True) + + def pull(self, expected: RepositoryState) -> RepositoryState: + current = self.refresh() + self._verify_action_state(expected, current) + if current.dirty_count: + raise GitRepositoryError( + "Pull is disabled because the working tree has uncommitted changes. " + "Review or commit them before pulling." + ) + if current.sync_state != REMOTE_AHEAD: + raise GitRepositoryError(self._not_actionable_message(current, "pull")) + assert current.upstream_branch is not None + result = self._git( + "pull", + "--ff-only", + "origin", + current.upstream_branch, + check=False, + timeout=60, + ) + if result.returncode != 0: + raise GitRepositoryError(f"Pull failed: {self._safe_detail(result)}") + return self.refresh() + + def push(self, expected: RepositoryState) -> RepositoryState: + current = self.refresh() + self._verify_action_state(expected, current) + if current.sync_state != LOCAL_AHEAD: + raise GitRepositoryError(self._not_actionable_message(current, "push")) + assert current.upstream_branch is not None + result = self._git( + "push", + "origin", + f"HEAD:refs/heads/{current.upstream_branch}", + check=False, + timeout=60, + ) + if result.returncode != 0: + refreshed = self.refresh() + if refreshed.sync_state == DIVERGED: + raise GitRepositoryError( + "Push failed because the remote changed and the histories now " + "diverge. Resolve this manually." + ) + raise GitRepositoryError(f"Push failed: {self._safe_detail(result)}") + return self.refresh() + + def _read_state(self, *, compare_remote: bool) -> RepositoryState: + self._verify_root() + branch_result = self._git( + "symbolic-ref", "--quiet", "--short", "HEAD", check=False + ) + branch = branch_result.stdout.strip() + detached = branch_result.returncode != 0 or not branch + if detached: + branch = "(detached HEAD)" + + origin_result = self._git("remote", "get-url", "origin", check=False) + raw_origin = origin_result.stdout.strip() if origin_result.returncode == 0 else "" + origin_url = _display_url(raw_origin) if raw_origin else None + push_result = self._git( + "remote", "get-url", "--push", "origin", check=False + ) + raw_push_url = push_result.stdout.strip() if push_result.returncode == 0 else "" + origin_push_url = _display_url(raw_push_url) if raw_push_url else origin_url + repository_name = _repository_name(raw_origin) if raw_origin else self.root.name + + status_result = self._git( + "status", "--porcelain=v1", "--untracked-files=normal", check=True + ) + dirty_count = len(status_result.stdout.splitlines()) + + if detached: + return RepositoryState( + self.root, + repository_name, + branch, + origin_url, + None, + None, + dirty_count, + None, + None, + UNAVAILABLE, + "The checkout has a detached HEAD. Switch branches manually before syncing.", + origin_push_url, + ) + if not raw_origin: + return RepositoryState( + self.root, + repository_name, + branch, + None, + None, + None, + dirty_count, + None, + None, + UNAVAILABLE, + "No usable origin remote is configured. Configure it manually before syncing.", + origin_push_url, + ) + + upstream_result = self._git( + "rev-parse", + "--abbrev-ref", + "--symbolic-full-name", + "@{upstream}", + check=False, + ) + upstream = upstream_result.stdout.strip() + remote_result = self._git( + "config", "--get", f"branch.{branch}.remote", check=False + ) + merge_result = self._git( + "config", "--get", f"branch.{branch}.merge", check=False + ) + upstream_remote = remote_result.stdout.strip() + merge_ref = merge_result.stdout.strip() + if ( + not upstream + or upstream_remote != "origin" + or not merge_ref.startswith("refs/heads/") + ): + return RepositoryState( + self.root, + repository_name, + branch, + origin_url, + upstream or None, + None, + dirty_count, + None, + None, + UNAVAILABLE, + "The current branch has no usable upstream on origin. Configure its " + "tracking branch manually before syncing.", + origin_push_url, + ) + upstream_branch = merge_ref.removeprefix("refs/heads/") + expected_upstream = f"origin/{upstream_branch}" + if upstream != expected_upstream: + return RepositoryState( + self.root, + repository_name, + branch, + origin_url, + upstream, + upstream_branch, + dirty_count, + None, + None, + UNAVAILABLE, + f"The upstream relationship is ambiguous ({upstream}). Resolve it manually.", + origin_push_url, + ) + if not compare_remote: + return RepositoryState( + self.root, + repository_name, + branch, + origin_url, + upstream, + upstream_branch, + dirty_count, + None, + None, + UNAVAILABLE, + origin_push_url=origin_push_url, + ) + + counts_result = self._git( + "rev-list", "--left-right", "--count", f"HEAD...{upstream}", check=False + ) + if counts_result.returncode != 0: + return RepositoryState( + self.root, + repository_name, + branch, + origin_url, + upstream, + upstream_branch, + dirty_count, + None, + None, + UNAVAILABLE, + f"Cannot compare local and remote commits: {self._safe_detail(counts_result)}", + origin_push_url, + ) + try: + ahead, behind = (int(value) for value in counts_result.stdout.split()) + except (TypeError, ValueError) as exc: + raise GitRepositoryError("Git returned an invalid ahead/behind result.") from exc + + if ahead and behind: + sync_state = DIVERGED + elif ahead: + sync_state = LOCAL_AHEAD + elif behind: + sync_state = REMOTE_AHEAD + else: + sync_state = SYNCED + return RepositoryState( + self.root, + repository_name, + branch, + origin_url, + upstream, + upstream_branch, + dirty_count, + ahead, + behind, + sync_state, + origin_push_url=origin_push_url, + ) + + def _verify_root(self) -> None: + result = self._git("rev-parse", "--show-toplevel", check=False) + if result.returncode != 0 or Path(result.stdout.strip()).resolve() != self.root: + raise GitRepositoryError("The repository containing the launcher has changed.") + + def _verify_action_state( + self, expected: RepositoryState, current: RepositoryState + ) -> None: + if current.root != expected.root: + raise GitRepositoryError("The repository changed; refresh before syncing.") + if current.branch != expected.branch: + raise GitRepositoryError( + f"The current branch changed from {expected.branch} to {current.branch}. " + "Review the refreshed status before syncing." + ) + if ( + current.origin_url != expected.origin_url + or current.origin_push_url != expected.origin_push_url + or current.upstream != expected.upstream + ): + raise GitRepositoryError( + "The remote or upstream configuration changed. Review the refreshed " + "status before syncing." + ) + if current.problem: + raise GitRepositoryError(current.problem) + + @staticmethod + def _not_actionable_message(state: RepositoryState, action: str) -> str: + if state.sync_state == DIVERGED: + return ( + "Local and remote histories have diverged. The launcher will not choose " + f"a {action} strategy; resolve the histories manually." + ) + if state.sync_state == SYNCED: + return "The repository is already up to date." + return f"A safe {action} is not available for the current repository state." + + def _git( + self, + *args: str, + check: bool, + timeout: int = 15, + ) -> subprocess.CompletedProcess[str]: + result = _run_git(self.root, *args, check=False, timeout=timeout) + if check and result.returncode != 0: + raise GitRepositoryError(f"Git failed: {self._safe_detail(result)}") + return result + + def _safe_detail(self, result: subprocess.CompletedProcess[str]) -> str: + return re.sub(r"(https?://)[^/\s@]+@", r"\1", _error_detail(result)) + + +def _run_git( + cwd: Path, + *args: str, + check: bool, + timeout: int = 15, +) -> subprocess.CompletedProcess[str]: + command = ["git", *args] + options: dict[str, object] = { + "cwd": cwd, + "capture_output": True, + "text": True, + "encoding": "utf-8", + "errors": "replace", + "check": False, + "timeout": timeout, + "env": {**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + } + if os.name == "nt": + options["creationflags"] = subprocess.CREATE_NO_WINDOW + try: + result = subprocess.run(command, **options) + except FileNotFoundError as exc: + raise GitRepositoryError( + "Git is not installed or is not available on PATH." + ) from exc + except subprocess.TimeoutExpired as exc: + raise GitRepositoryError("Git did not finish within the safety timeout.") from exc + if check and result.returncode != 0: + raise GitRepositoryError(f"Git failed: {_error_detail(result)}") + return result + + +def _error_detail(result: subprocess.CompletedProcess[str]) -> str: + return (result.stderr.strip() or result.stdout.strip() or "unknown Git error").replace( + "\n", " " + ) + + +def _display_url(value: str) -> str: + """Remove HTTP credentials before a remote URL reaches the GUI.""" + parsed = urlsplit(value) + if parsed.scheme in {"http", "https"} and parsed.hostname: + host = parsed.hostname + if parsed.port: + host = f"{host}:{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment)) + return value + + +def _repository_name(value: str) -> str: + path = urlsplit(value).path if "://" in value else re.split(r"[\\/:]", value)[-1] + name = path.rstrip("/").rsplit("/", 1)[-1] + return name.removesuffix(".git") or "repository" diff --git a/scripts/management_launcher.py b/scripts/management_launcher.py new file mode 100644 index 0000000..c153f25 --- /dev/null +++ b/scripts/management_launcher.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Small desktop front door for Labyricorn repository sync and editors.""" + +from __future__ import annotations + +from pathlib import Path +import subprocess +import sys +import threading +from typing import Callable + +from git_repo import GitRepository, GitRepositoryError, RepositoryState + + +SCRIPT_DIR = Path(__file__).resolve().parent +SITE_ROOT = SCRIPT_DIR.parent +EDITOR_SCRIPTS = ( + ("Blog Editor", "blog_editor.py", True), + ("Article Editor", "article_editor.py", True), + ("Tag Editor", "tag_editor.py", True), + ("Project Editor", "project_editor.py", True), +) + + +def run_gui() -> None: + import tkinter as tk + from tkinter import messagebox, ttk + + class ManagementLauncher: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Labyricorn Management") + self.root.minsize(620, 430) + self.repository: GitRepository | None = None + self.state: RepositoryState | None = None + self.busy = False + + outer = ttk.Frame(self.root, padding=14) + outer.grid(row=0, column=0, sticky="nsew") + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + outer.columnconfigure(0, weight=1) + + ttk.Label( + outer, + text="Labyricorn Management", + font=("TkDefaultFont", 15, "bold"), + ).grid(row=0, column=0, sticky="w", pady=(0, 12)) + + repository_frame = ttk.LabelFrame(outer, text="Repository", padding=10) + repository_frame.grid(row=1, column=0, sticky="ew") + repository_frame.columnconfigure(1, weight=1) + self.values: dict[str, tk.StringVar] = {} + fields = ( + ("Repository root", "root"), + ("Repository", "repository"), + ("Branch", "branch"), + ("Remote", "remote"), + ("Upstream", "upstream"), + ("Status", "status"), + ("Working tree", "working_tree"), + ) + for row, (label, key) in enumerate(fields): + ttk.Label(repository_frame, text=f"{label}:").grid( + row=row, column=0, sticky="nw", padx=(0, 10), pady=2 + ) + value = tk.StringVar(value="Checking…" if key == "status" else "—") + self.values[key] = value + ttk.Label(repository_frame, textvariable=value, wraplength=460).grid( + row=row, column=1, sticky="w", pady=2 + ) + + self.details_var = tk.StringVar(value="Fetching remote metadata…") + ttk.Label( + repository_frame, + textvariable=self.details_var, + wraplength=560, + foreground="#7a5200", + ).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(8, 2)) + + controls = ttk.Frame(repository_frame) + controls.grid( + row=len(fields) + 1, + column=0, + columnspan=2, + sticky="w", + pady=(10, 0), + ) + self.refresh_button = ttk.Button(controls, text="Refresh", command=self.refresh) + self.refresh_button.grid(row=0, column=0) + self.pull_button = ttk.Button( + controls, text="Pull", command=self.pull, state="disabled" + ) + self.pull_button.grid(row=0, column=1, padx=(8, 0)) + self.push_button = ttk.Button( + controls, text="Push", command=self.push, state="disabled" + ) + self.push_button.grid(row=0, column=2, padx=(8, 0)) + + editors_frame = ttk.LabelFrame(outer, text="Editors", padding=10) + editors_frame.grid(row=2, column=0, sticky="ew", pady=(14, 0)) + editors_frame.columnconfigure(0, weight=1) + editor_row = 0 + for label, filename, required in EDITOR_SCRIPTS: + path = SCRIPT_DIR / filename + if not required and not path.is_file(): + continue + button = ttk.Button( + editors_frame, + text=label, + command=lambda selected=path, name=label: self.launch_editor( + selected, name + ), + ) + button.grid(row=editor_row, column=0, sticky="ew", pady=3) + if not path.is_file(): + button.configure(state="disabled") + ttk.Label(editors_frame, text=f"{filename} is missing").grid( + row=editor_row, column=1, sticky="w", padx=(10, 0) + ) + editor_row += 1 + + self.root.after(50, self.refresh) + + def refresh(self) -> None: + def operation() -> RepositoryState: + repository = GitRepository.locate(SCRIPT_DIR) + state = repository.refresh() + self.repository = repository + return state + + self._start_git_task("Refreshing repository status", operation) + + def pull(self) -> None: + if self.repository is None or self.state is None: + return + repository = self.repository + expected = self.state + self._start_git_task( + "Pulling with fast-forward only", lambda: repository.pull(expected) + ) + + def push(self) -> None: + if self.repository is None or self.state is None: + return + repository = self.repository + expected = self.state + self._start_git_task( + "Pushing local commits", lambda: repository.push(expected) + ) + + def _start_git_task( + self, label: str, operation: Callable[[], RepositoryState] + ) -> None: + if self.busy: + return + self.busy = True + self.details_var.set(f"{label}…") + self._update_buttons() + + def worker() -> None: + try: + state = operation() + except (GitRepositoryError, OSError) as exc: + self.root.after(0, self._finish_error, label, str(exc)) + else: + self.root.after(0, self._finish_success, state) + + threading.Thread(target=worker, daemon=True).start() + + def _finish_success(self, state: RepositoryState) -> None: + self.busy = False + self.state = state + self.values["root"].set(str(state.root)) + self.values["repository"].set(state.repository_name) + self.values["branch"].set(state.branch) + if not state.origin_url: + remote_text = "Not configured" + elif state.origin_push_url and state.origin_push_url != state.origin_url: + remote_text = ( + f"origin — {state.origin_url}\nPush target — {state.origin_push_url}" + ) + else: + remote_text = f"origin — {state.origin_url}" + self.values["remote"].set(remote_text) + self.values["upstream"].set(state.upstream or "Not configured") + self.values["status"].set(state.status_text) + self.values["working_tree"].set(state.working_tree_text) + if state.problem: + self.details_var.set(state.problem) + elif state.sync_state == "diverged": + self.details_var.set( + f"Local is {state.ahead} ahead and {state.behind} behind. " + "Resolve the histories manually; no automatic action is offered." + ) + elif state.dirty_count and state.sync_state == "remote-ahead": + self.details_var.set( + "Pull is unavailable until the working-tree changes are " + "reviewed or committed." + ) + else: + self.details_var.set("Remote metadata fetched successfully.") + self._update_buttons() + + def _finish_error(self, label: str, detail: str) -> None: + self.busy = False + self.state = None + self.details_var.set(detail) + self.values["status"].set("Unavailable") + self._update_buttons() + messagebox.showerror(label, detail, parent=self.root) + + def _update_buttons(self) -> None: + self.refresh_button.configure(state="disabled" if self.busy else "normal") + pull_enabled = ( + not self.busy and self.state is not None and self.state.can_pull + ) + push_enabled = ( + not self.busy and self.state is not None and self.state.can_push + ) + self.pull_button.configure(state="normal" if pull_enabled else "disabled") + self.push_button.configure(state="normal" if push_enabled else "disabled") + + def launch_editor(self, path: Path, label: str) -> None: + if not path.is_file(): + messagebox.showerror( + f"Cannot launch {label}", + f"The editor script is missing: {path}", + parent=self.root, + ) + return + try: + subprocess.Popen( + [sys.executable, str(path), "--site-root", str(SITE_ROOT)], + cwd=SITE_ROOT, + ) + except OSError as exc: + messagebox.showerror( + f"Cannot launch {label}", + f"Could not start {path.name}: {exc}", + parent=self.root, + ) + + try: + app = ManagementLauncher() + except tk.TclError as exc: + raise GitRepositoryError(f"Cannot open the Tkinter window: {exc}") from exc + app.root.mainloop() + + +def main() -> int: + try: + run_gui() + except GitRepositoryError as exc: + print(f"management-launcher: ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/project_editor.py b/scripts/project_editor.py new file mode 100644 index 0000000..d9938cb --- /dev/null +++ b/scripts/project_editor.py @@ -0,0 +1,702 @@ +#!/usr/bin/env python3 +"""Manage Labyricorn's allowlisted project sources and check devlog readiness.""" + +from __future__ import annotations + +import argparse +import configparser +from dataclasses import dataclass +import importlib +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import threading +from typing import Any + +from project_providers import ( + ProjectProviderError, + derive_repository_urls, + validate_public_https_url, +) + + +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +BRANCH_RE = re.compile(r"^[A-Za-z0-9._/-]+$") +SECTION_RE = re.compile(r"(?m)^\[([^\]\r\n]+)\][^\r\n]*(?:\r?\n|$)") +KNOWN_OPTIONS = ("repository", "web_url", "api_url", "branch", "featured_order") + + +class ProjectEditorError(Exception): + """An expected project-editor problem suitable for display to a user.""" + + +def _load_project_importer() -> Any: + try: + return importlib.import_module("project_sources") + except ModuleNotFoundError as exc: + if exc.name != "lektor": + raise + raise ProjectEditorError( + "The Lektor Python package required by the project importer is unavailable. " + "Run Project Editor with the same Python environment used for Lektor builds." + ) from exc + + +def _validate_url(value: str, field: str) -> str: + """Mirror the importer's credential-free public HTTPS URL policy.""" + try: + return validate_public_https_url(value, field) + except ProjectProviderError as exc: + raise ProjectEditorError(f"{exc}.") from exc + + +@dataclass(frozen=True) +class ProjectSource: + project_id: str + repository: str + web_url: str + api_url: str + branch: str + featured_order: int | None + unknown_options: tuple[tuple[str, str], ...] + registry_bytes: bytes + + def importer_source(self) -> dict[str, str | int]: + source: dict[str, str | int] = { + "project_id": self.project_id, + "repository": self.repository, + "web_url": self.web_url, + "api_url": self.api_url, + "branch": self.branch, + } + if self.featured_order is not None: + source["featured_order"] = self.featured_order + return source + + +@dataclass(frozen=True) +class DevlogReadiness: + status: str + summary: str + details: str + ready: bool = False + + +def _clean_stored_values( + project_id: str, + repository: str, + web_url: str, + api_url: str, + branch: str, + featured_order: str | int | None, +) -> tuple[str, str, str, str, str, int | None]: + project_id = project_id.strip() + if not SLUG_RE.fullmatch(project_id): + raise ProjectEditorError( + "Project ID must use lowercase letters, numbers, and single hyphens only." + ) + repository = _validate_url(repository.strip(), "Repository URL") + web_url = _validate_url(web_url.strip(), "Repository web URL") + api_url = _validate_url(api_url.strip(), "Repository API URL") + branch = branch.strip() + if not branch or not BRANCH_RE.fullmatch(branch) or ".." in branch: + raise ProjectEditorError("Branch contains unsupported characters.") + if featured_order is None or str(featured_order).strip() == "": + parsed_order = None + else: + try: + parsed_order = int(featured_order) + except (TypeError, ValueError) as exc: + raise ProjectEditorError("Featured order must be an integer or left blank.") from exc + return project_id, repository, web_url, api_url, branch, parsed_order + + +def _clean_editor_values( + project_id: str, + web_url: str, + branch: str, + featured_order: str | int | None, +) -> tuple[str, str, str, str, str, int | None]: + try: + urls = derive_repository_urls(web_url) + except ProjectProviderError as exc: + raise ProjectEditorError(str(exc)) from exc + return _clean_stored_values( + project_id, + urls.repository, + urls.web_url, + urls.api_url, + branch, + featured_order, + ) + + +def _parse_registry(raw: bytes) -> tuple[str, configparser.ConfigParser]: + try: + text = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ProjectEditorError("Project source registry is not valid UTF-8.") from exc + parser = configparser.ConfigParser(interpolation=None) + try: + parser.read_string(text) + except configparser.Error as exc: + raise ProjectEditorError(f"Cannot parse project source registry: {exc}") from exc + return text, parser + + +def _source_from_section( + project_id: str, section: configparser.SectionProxy, raw: bytes +) -> ProjectSource: + required = {"repository", "web_url", "api_url", "branch"} + missing = required - set(section) + if missing: + raise ProjectEditorError( + f"{project_id}: missing registry fields {', '.join(sorted(missing))}." + ) + cleaned = _clean_stored_values( + project_id, + section["repository"], + section["web_url"], + section["api_url"], + section["branch"], + section.get("featured_order"), + ) + unknown = tuple((key, value) for key, value in section.items() if key not in KNOWN_OPTIONS) + return ProjectSource(*cleaned, unknown, raw) + + +def _render_option(name: str, value: str) -> str: + lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n") + return f"{name} = {lines[0]}" + "".join(f"\n\t{line}" for line in lines[1:]) + + +def _render_section(source: ProjectSource) -> str: + rows = [ + f"[{source.project_id}]", + _render_option("repository", source.repository), + _render_option("web_url", source.web_url), + _render_option("api_url", source.api_url), + _render_option("branch", source.branch), + ] + if source.featured_order is not None: + rows.append(_render_option("featured_order", str(source.featured_order))) + rows.extend(_render_option(key, value) for key, value in source.unknown_options) + return "\n".join(rows) + "\n" + + +def _section_bounds(text: str, project_id: str) -> tuple[int, int]: + matches = list(SECTION_RE.finditer(text)) + for index, match in enumerate(matches): + if match.group(1).strip() == project_id: + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + return match.start(), end + raise ProjectEditorError(f"Project source no longer exists: {project_id}.") + + +class ProjectSourceRegistry: + """Conservative editor for configs/project-sources.ini.""" + + def __init__(self, site_root: Path): + self.site_root = site_root.resolve() + registry_path = self.site_root / "configs" / "project-sources.ini" + if registry_path.is_symlink(): + raise ProjectEditorError("Refusing to manage a linked project source registry.") + self.path = registry_path.resolve() + projects_root = (self.site_root / "content" / "projects").resolve() + if len(list(self.site_root.glob("*.lektorproject"))) != 1: + raise ProjectEditorError( + f"{self.site_root}: expected exactly one .lektorproject file." + ) + if not self.path.is_file(): + raise ProjectEditorError(f"Project source registry is missing: {self.path}.") + if not self.path.is_relative_to(self.site_root): + raise ProjectEditorError("Refusing to manage an out-of-tree registry.") + if not projects_root.is_dir() or not (projects_root / "contents.lr").is_file(): + raise ProjectEditorError(f"Lektor project section is missing: {projects_root}.") + self.list_sources() + + def _read(self) -> bytes: + try: + return self.path.read_bytes() + except OSError as exc: + raise ProjectEditorError(f"Cannot read {self.path}: {exc}") from exc + + def list_sources(self) -> list[ProjectSource]: + raw = self._read() + _text, parser = _parse_registry(raw) + if not parser.sections(): + raise ProjectEditorError("Project source registry must contain at least one project.") + return [_source_from_section(project_id, parser[project_id], raw) for project_id in parser.sections()] + + def load_source(self, project_id: str) -> ProjectSource: + for source in self.list_sources(): + if source.project_id == project_id: + return source + raise ProjectEditorError(f"Project source does not exist: {project_id}.") + + @staticmethod + def _write_atomic(path: Path, payload: bytes) -> None: + temporary: Path | None = None + try: + descriptor, name = tempfile.mkstemp(prefix=".project-sources-", suffix=".ini", dir=path.parent) + os.close(descriptor) + temporary = Path(name) + temporary.write_bytes(payload) + os.replace(temporary, path) + except OSError as exc: + if temporary is not None: + temporary.unlink(missing_ok=True) + raise ProjectEditorError(f"Cannot write {path}: {exc}") from exc + + def _validate_proposed(self, payload: bytes) -> None: + _text, parser = _parse_registry(payload) + if not parser.sections(): + raise ProjectEditorError("The importer requires at least one configured project source.") + for project_id in parser.sections(): + _source_from_section(project_id, parser[project_id], payload) + + def create_source( + self, + project_id: str, + web_url: str, + branch: str, + featured_order: str | int | None, + ) -> ProjectSource: + cleaned = _clean_editor_values(project_id, web_url, branch, featured_order) + raw = self._read() + text, parser = _parse_registry(raw) + if cleaned[0] in parser: + raise ProjectEditorError(f"Project source already exists: {cleaned[0]}.") + source = ProjectSource(*cleaned, (), raw) + proposed = (text.rstrip() + "\n\n" + _render_section(source)).encode("utf-8") + self._validate_proposed(proposed) + self._write_atomic(self.path, proposed) + return self.load_source(source.project_id) + + def save_source( + self, + original: ProjectSource, + web_url: str, + branch: str, + featured_order: str | int | None, + ) -> ProjectSource: + raw = self._read() + if raw != original.registry_bytes: + raise ProjectEditorError("The project registry changed on disk; reload before saving.") + cleaned = _clean_editor_values( + original.project_id, web_url, branch, featured_order + ) + updated = ProjectSource(*cleaned, original.unknown_options, raw) + text, _parser = _parse_registry(raw) + start, end = _section_bounds(text, original.project_id) + proposed = (text[:start] + _render_section(updated) + text[end:].lstrip("\r\n")).encode("utf-8") + self._validate_proposed(proposed) + self._write_atomic(self.path, proposed) + return self.load_source(updated.project_id) + + def delete_source(self, source: ProjectSource) -> None: + raw = self._read() + if raw != source.registry_bytes: + raise ProjectEditorError("The project registry changed on disk; reload before deleting.") + text, parser = _parse_registry(raw) + if len(parser.sections()) == 1: + raise ProjectEditorError( + "The importer requires at least one configured source; the last project cannot be removed." + ) + start, end = _section_bounds(text, source.project_id) + proposed = (text[:start].rstrip() + "\n\n" + text[end:].lstrip("\r\n")).encode("utf-8") + self._validate_proposed(proposed) + self._write_atomic(self.path, proposed) + + +def corrective_guidance(source: ProjectSource) -> str: + return ( + "This project is configured to publish a devlog, but its required remote " + "Labyricorn structure is missing or incomplete.\n\n" + "To correct this:\n\n" + f"1. Clone the project repository:\n git clone {source.repository}\n\n" + "2. Copy the standalone devlog_editor.py into the cloned repository root.\n\n" + "3. From that repository root, run:\n python devlog_editor.py\n\n" + "4. Choose “Initialize Labyricorn Project & Devlog” if no publishing structure " + "exists, or “Initialize Devlog” if the project record already exists.\n\n" + "5. Create and save one or more devlog entries when appropriate. An empty " + "validated devlog index is accepted by the current importer.\n\n" + "6. Prefer the editor's “Publish Labyricorn Changes” action. It safely creates " + "the publishing commit and pushes it; alternatively, commit and push with Git.\n\n" + "7. Confirm the .labyricorn changes are visible in the remote repository.\n\n" + "8. Return to Project Editor and click “Check Devlog Status” again.\n\n" + "Project Editor only diagnoses the remote repository. It never clones it " + "persistently, edits it, commits, or pushes." + ) + + +def _incomplete(source: ProjectSource, diagnostic: str, *, not_initialized: bool = False) -> DevlogReadiness: + status = "Not initialized" if not_initialized else "Required devlog structure is incomplete" + return DevlogReadiness(status, diagnostic, diagnostic + "\n\n" + corrective_guidance(source)) + + +def check_devlog_readiness(source: ProjectSource, importer: Any | None = None) -> DevlogReadiness: + """Inspect a configured source using the importer's exact read-only validation.""" + if shutil.which("git") is None: + return DevlogReadiness( + "Repository inaccessible", + "Git is not installed or is not available on PATH.", + "Install Git, restart Project Editor, and run the check again. No remote repository was changed.", + ) + try: + importer = importer or _load_project_importer() + except ProjectEditorError as exc: + return DevlogReadiness( + "Unable to check", + "The site's authoritative project validator is unavailable in this Python environment.", + str(exc), + ) + importer_source = source.importer_source() + try: + repository_metadata = importer.fetch_json(source.api_url, require_public=True) + except importer.RepositoryNotPublicError: + return DevlogReadiness( + "Repository inaccessible", + "The repository API is not anonymously accessible.", + "The importer accepts only public repositories. Confirm that the configured repository is public and that the API URL is correct.", + ) + except importer.ProjectSourceError as exc: + return DevlogReadiness( + "Repository inaccessible", + "Unable to verify the remote repository because its public API could not be reached.", + f"Network or provider API error: {exc}\n\nCheck the network and configured API URL, then try again.", + ) + if not isinstance(repository_metadata, dict) or repository_metadata.get("private") is not False: + return DevlogReadiness( + "Repository inaccessible", + "The provider did not confirm that this repository is public.", + "The site importer requires an anonymous provider API response with private=false.", + ) + if repository_metadata.get("default_branch") != source.branch: + return _incomplete( + source, + f"The provider reports default branch {repository_metadata.get('default_branch')!r}, but the site is configured for {source.branch!r}.", + ) + + try: + with tempfile.TemporaryDirectory(prefix="labyricorn-project-check-") as temporary: + mirror, fetched = importer.ensure_mirror(importer_source, Path(temporary)) + if not fetched: + raise importer.ProjectSourceError("anonymous Git fetch failed") + commit = importer.resolve_branch(mirror, source.branch) + _files, records = importer.validate_snapshot(importer_source, mirror, commit) + except FileNotFoundError: + return DevlogReadiness( + "Repository inaccessible", + "Git is not installed or is not available on PATH.", + "Install Git, restart Project Editor, and run the check again.", + ) + except (OSError, subprocess.SubprocessError) as exc: + return DevlogReadiness( + "Repository inaccessible", + "Unable to fetch the repository anonymously.", + f"Network, authentication, or Git error: {exc}\n\nCheck repository access and try again.", + ) + except (importer.ProjectSourceError, ValueError) as exc: + diagnostic = str(exc) + if "initial repository fetch failed" in diagnostic: + return DevlogReadiness( + "Repository inaccessible", + "Unable to fetch the repository anonymously.", + "The repository may be unavailable, private, or blocked by a network/authentication problem.", + ) + missing_devlog = ( + "publishing tree is missing" in diagnostic + and ".labyricorn/devlog/contents.lr" in diagnostic + ) + return _incomplete(source, diagnostic, not_initialized=missing_devlog) + + project = records[".labyricorn/project/contents.lr"] + entry_count = sum( + 1 + for path in records + if path.startswith(".labyricorn/devlog/") + and path != ".labyricorn/devlog/contents.lr" + ) + details = ( + f"Repository: {source.web_url}\n" + f"Branch: {source.branch}\n" + f"Validated commit: {commit[:10]}\n" + f"Remote project: {project['title']} ({project['project_id']})\n" + f"Valid devlog entries: {entry_count}\n\n" + "The remote snapshot passed the same schema, path, attachment, and commit checks used by the site importer. The check was read-only and its temporary mirror was removed." + ) + return DevlogReadiness( + "Ready", + f"{project['title']} is ready for devlog import at {commit[:10]}.", + details, + True, + ) + + +def run_gui(registry: ProjectSourceRegistry) -> None: + import tkinter as tk + from tkinter import messagebox, ttk + from tkinter.scrolledtext import ScrolledText + + class ProjectEditorApp: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Labyricorn Project Editor") + self.root.minsize(900, 680) + self.current: ProjectSource | None = None + self.mode = "new" + self.snapshot: tuple[str, ...] | None = None + self.checking = False + + outer = ttk.Frame(self.root, padding=12) + outer.grid(row=0, column=0, sticky="nsew") + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + outer.columnconfigure(1, weight=1) + outer.rowconfigure(0, weight=1) + + browser = ttk.LabelFrame(outer, text="Configured projects", padding=8) + browser.grid(row=0, column=0, sticky="ns", padx=(0, 10)) + browser.rowconfigure(0, weight=1) + self.tree = ttk.Treeview(browser, columns=("branch",), show="tree headings", height=24) + self.tree.heading("#0", text="Project ID") + self.tree.heading("branch", text="Branch") + self.tree.column("#0", width=165) + self.tree.column("branch", width=95) + self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + self.tree.bind("", lambda _event: self.edit_selected()) + ttk.Button(browser, text="New", command=self.new_source).grid(row=1, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(browser, text="Edit", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0)) + self.delete_button = ttk.Button(browser, text="Delete", command=self.delete_current, state="disabled") + self.delete_button.grid(row=1, column=2, sticky="ew", pady=(8, 0)) + + form = ttk.LabelFrame(outer, text="Site-side project source", padding=10) + form.grid(row=0, column=1, sticky="nsew") + form.columnconfigure(1, weight=1) + form.rowconfigure(9, weight=1) + self.vars = { + name: tk.StringVar() + for name in ("project_id", "web_url", "branch", "featured_order") + } + labels = ( + ("Project ID", "project_id"), + ("Public repository URL", "web_url"), + ("Branch", "branch"), + ("Featured order (optional)", "featured_order"), + ) + for row, (label, name) in enumerate(labels): + ttk.Label(form, text=label).grid(row=row, column=0, sticky="w", padx=(0, 10), pady=3) + entry = ttk.Entry(form, textvariable=self.vars[name]) + entry.grid(row=row, column=1, sticky="ew", pady=3) + if name == "project_id": + self.id_entry = entry + + ttk.Label( + form, + text=( + "Project prose, technologies/tags, and images are owned by the external repository under " + ".labyricorn/project/. This editor does not copy or modify them. Every configured source is " + "expected by the current importer to provide a .labyricorn/devlog/ index. Git and API URLs " + "are derived for public GitHub and Gitea repositories." + ), + wraplength=590, + foreground="#6b5a32", + ).grid(row=6, column=0, columnspan=2, sticky="ew", pady=(8, 10)) + + actions = ttk.Frame(form) + actions.grid(row=7, column=0, columnspan=2, sticky="ew") + self.check_button = ttk.Button(actions, text="Check Devlog Status", command=self.check_status, state="disabled") + self.check_button.grid(row=0, column=0) + ttk.Button(actions, text="Save Project", command=self.save).grid(row=0, column=1, padx=(8, 0)) + + self.status_var = tk.StringVar(value="Devlog Status: Configuration does not require devlogs") + ttk.Label(form, textvariable=self.status_var, font=("TkDefaultFont", 10, "bold")).grid(row=8, column=0, columnspan=2, sticky="w", pady=(12, 5)) + self.details = ScrolledText(form, wrap=tk.WORD, height=14, state="disabled") + self.details.grid(row=9, column=0, columnspan=2, sticky="nsew") + + self.footer_var = tk.StringVar() + ttk.Label(outer, textvariable=self.footer_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0)) + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.refresh() + self.new_source(False) + + def values(self) -> tuple[str, ...]: + return tuple(self.vars[name].get() for name in self.vars) + + def set_details(self, value: str) -> None: + self.details.configure(state="normal") + self.details.delete("1.0", tk.END) + self.details.insert("1.0", value) + self.details.configure(state="disabled") + + def may_discard(self) -> bool: + return self.snapshot is None or self.values() == self.snapshot or messagebox.askyesno( + "Discard unsaved changes?", "Discard the unsaved project-source changes?", icon="warning", parent=self.root + ) + + def refresh(self, preserve: bool = True) -> None: + selected = self.tree.selection()[0] if preserve and self.tree.selection() else None + self.tree.delete(*self.tree.get_children()) + try: + sources = registry.list_sources() + except ProjectEditorError as exc: + messagebox.showerror("Cannot load projects", str(exc), parent=self.root) + return + for source in sources: + self.tree.insert("", tk.END, iid=source.project_id, text=source.project_id, values=(source.branch,)) + if selected and self.tree.exists(selected): + self.tree.selection_set(selected) + self.footer_var.set(f"{len(sources)} configured project source{'s' if len(sources) != 1 else ''}") + + def new_source(self, confirm: bool = True) -> None: + if confirm and not self.may_discard(): + return + self.mode = "new" + self.current = None + self.id_entry.configure(state="normal") + for variable in self.vars.values(): + variable.set("") + self.vars["branch"].set("main") + self.delete_button.configure(state="disabled") + self.check_button.configure(state="disabled") + self.status_var.set("Devlog Status: Configuration does not require devlogs") + self.set_details("Save this source to make it part of the site's project importer configuration.") + self.snapshot = self.values() + + def edit_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select a project", "Select a configured project to edit.", parent=self.root) + return + if not self.may_discard(): + return + try: + source = registry.load_source(selection[0]) + except ProjectEditorError as exc: + messagebox.showerror("Cannot load project", str(exc), parent=self.root) + return + self.mode = "edit" + self.current = source + self.vars["project_id"].set(source.project_id) + self.vars["web_url"].set(source.web_url) + self.vars["branch"].set(source.branch) + self.vars["featured_order"].set("" if source.featured_order is None else str(source.featured_order)) + self.id_entry.configure(state="readonly") + self.delete_button.configure(state="normal") + self.check_button.configure(state="normal") + self.status_var.set("Devlog Status: Not checked") + self.set_details(f"Configured repository: {source.web_url}\nClick “Check Devlog Status” for a read-only remote validation.") + self.snapshot = self.values() + + def save(self) -> None: + values = self.values() + try: + if self.mode == "new": + source = registry.create_source(*values) + else: + if self.current is None: + raise ProjectEditorError("No configured project is loaded.") + source = registry.save_source(self.current, *values[1:]) + except ProjectEditorError as exc: + messagebox.showerror("Cannot save project", str(exc), parent=self.root) + return + self.current = source + self.mode = "edit" + self.vars["project_id"].set(source.project_id) + self.vars["web_url"].set(source.web_url) + self.vars["branch"].set(source.branch) + self.vars["featured_order"].set( + "" if source.featured_order is None else str(source.featured_order) + ) + self.id_entry.configure(state="readonly") + self.delete_button.configure(state="normal") + self.check_button.configure(state="normal") + self.snapshot = self.values() + self.refresh(False) + self.tree.selection_set(source.project_id) + self.tree.see(source.project_id) + self.status_var.set("Devlog Status: Not checked") + self.set_details("Project source saved. Run the manual readiness check to inspect the remote repository.") + self.footer_var.set(f"Saved configs/project-sources.ini [{source.project_id}]") + + def delete_current(self) -> None: + if self.current is None: + return + source = self.current + if not messagebox.askyesno( + "Delete configured project?", + f"Remove {source.project_id!r} from the site project-source registry?\n\nRepository: {source.web_url}\n\nThis removes only the site-side configuration. It does not delete or modify the external repository.", + icon="warning", + parent=self.root, + ): + return + try: + registry.delete_source(source) + except ProjectEditorError as exc: + messagebox.showerror("Cannot delete project", str(exc), parent=self.root) + return + project_id = source.project_id + self.snapshot = None + self.refresh(False) + self.new_source(False) + self.footer_var.set(f"Removed [{project_id}] from configs/project-sources.ini; external repository unchanged") + + def check_status(self) -> None: + if self.current is None or self.checking: + return + if self.values() != self.snapshot: + messagebox.showinfo("Save before checking", "Save or discard the project-source changes before checking the configured remote.", parent=self.root) + return + source = self.current + self.checking = True + self.check_button.configure(state="disabled") + self.status_var.set("Devlog Status: Checking…") + self.set_details("Checking public provider metadata and validating a temporary read-only Git mirror…") + + def worker() -> None: + try: + result = check_devlog_readiness(source) + except Exception as exc: # Keep unexpected operational failures out of Tk's worker thread. + result = DevlogReadiness( + "Unable to check", + "An unexpected error stopped the read-only validation.", + str(exc), + ) + self.root.after(0, self.finish_check, result) + + threading.Thread(target=worker, daemon=True).start() + + def finish_check(self, result: DevlogReadiness) -> None: + self.checking = False + self.check_button.configure(state="normal" if self.current else "disabled") + self.status_var.set(f"Devlog Status: {result.status}") + self.set_details(result.summary + "\n\n" + result.details) + + def close(self) -> None: + if self.may_discard(): + self.root.destroy() + + try: + app = ProjectEditorApp() + except tk.TclError as exc: + raise ProjectEditorError(f"Cannot open the Tkinter window: {exc}") from exc + app.root.mainloop() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args() + try: + run_gui(ProjectSourceRegistry(args.site_root)) + except ProjectEditorError as exc: + parser.exit(1, f"project-editor: ERROR: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/project_providers.py b/scripts/project_providers.py new file mode 100644 index 0000000..835fc98 --- /dev/null +++ b/scripts/project_providers.py @@ -0,0 +1,104 @@ +"""Provider-specific URL derivation for public Labyricorn project repositories.""" + +from __future__ import annotations + +from dataclasses import dataclass +import ipaddress +import re +from urllib.parse import quote, urlsplit, urlunsplit + + +class ProjectProviderError(ValueError): + """Raised when a public repository URL cannot be derived safely.""" + + +@dataclass(frozen=True) +class RepositoryUrls: + provider: str + repository: str + web_url: str + api_url: str + + +def validate_public_https_url(value: str, field: str) -> str: + value = value.strip().rstrip("/") + parsed = urlsplit(value) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + ): + raise ProjectProviderError(f"{field} must be a credential-free HTTPS URL") + if parsed.hostname.lower() == "localhost": + raise ProjectProviderError(f"{field} must not target localhost") + try: + address = ipaddress.ip_address(parsed.hostname) + except ValueError: + pass + else: + if not address.is_global: + raise ProjectProviderError(f"{field} must not target a private or local address") + return value + + +def repository_provider(web_url: str) -> str: + hostname = (urlsplit(web_url).hostname or "").lower() + return "github" if hostname == "github.com" else "gitea" + + +def derive_repository_urls(value: str) -> RepositoryUrls: + """Derive Git and API URLs from one GitHub or Gitea repository web URL.""" + web_url = validate_public_https_url(value, "Repository URL") + parsed = urlsplit(web_url) + hostname = (parsed.hostname or "").lower() + if hostname == "gitlab.com": + raise ProjectProviderError( + "GitLab repositories are not supported; use a public GitHub or Gitea URL" + ) + if parsed.query or parsed.fragment: + raise ProjectProviderError("Repository URL must not contain a query or fragment") + + path = parsed.path.rstrip("/") + if path.lower().endswith(".git"): + path = path[:-4] + parts = [part for part in path.split("/") if part] + if len(parts) != 2: + raise ProjectProviderError( + "Repository URL must identify one owner and repository, such as " + "https://github.com/owner/repository" + ) + owner, repository_name = parts + if any( + part in {".", ".."} or not re.fullmatch(r"[A-Za-z0-9_.-]+", part) + for part in (owner, repository_name) + ): + raise ProjectProviderError( + "Repository owner and name contain unsupported URL characters" + ) + normalized_path = f"/{owner}/{repository_name}" + normalized_web = urlunsplit((parsed.scheme, parsed.netloc, normalized_path, "", "")) + provider = repository_provider(normalized_web) + if provider == "github": + api_url = ( + "https://api.github.com/repos/" + f"{quote(owner, safe='')}/{quote(repository_name, safe='')}" + ) + else: + api_url = urlunsplit( + ( + parsed.scheme, + parsed.netloc, + f"/api/v1/repos/{quote(owner, safe='')}/{quote(repository_name, safe='')}", + "", + "", + ) + ) + return RepositoryUrls(provider, f"{normalized_web}.git", normalized_web, api_url) + + +def repository_readme_url(web_url: str, branch: str) -> str: + encoded_branch = quote(branch, safe="") + if repository_provider(web_url) == "github": + return f"{web_url}/blob/{encoded_branch}/README.md" + return f"{web_url}/src/branch/{encoded_branch}/README.md" diff --git a/scripts/project_sources.py b/scripts/project_sources.py index 6186901..a81f3ba 100644 --- a/scripts/project_sources.py +++ b/scripts/project_sources.py @@ -6,7 +6,6 @@ from __future__ import annotations import argparse import configparser import hashlib -import ipaddress import io import json import os @@ -19,11 +18,18 @@ import tempfile from datetime import date, datetime, timezone from typing import Any from urllib.error import HTTPError, URLError -from urllib.parse import quote, urlparse +from urllib.parse import quote from urllib.request import Request, urlopen from lektor.metaformat import tokenize +from project_providers import ( + ProjectProviderError, + repository_provider, + repository_readme_url, + validate_public_https_url, +) + SCHEMA_VERSION = "1" MAX_FILES = 100 @@ -108,19 +114,10 @@ def run_git(git_dir: Path, *args: str, text: bool = True) -> str | bytes: def validate_url(value: str, field: str) -> str: - parsed = urlparse(value) - if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password: - raise ProjectSourceError(f"{field} must be a credential-free HTTPS URL") - if parsed.hostname.lower() == "localhost": - raise ProjectSourceError(f"{field} must not target localhost") try: - address = ipaddress.ip_address(parsed.hostname) - except ValueError: - pass - else: - if not address.is_global: - raise ProjectSourceError(f"{field} must not target a private or local address") - return value.rstrip("/") + return validate_public_https_url(value, field) + except ProjectProviderError as exc: + raise ProjectSourceError(str(exc)) from exc def load_registry(site_root: Path) -> list[dict[str, str | int]]: @@ -454,6 +451,14 @@ def fetch_json( except HTTPError as exc: if allow_not_found and exc.code == 404: return None + if ( + require_public + and exc.code == 403 + and exc.headers.get("X-RateLimit-Remaining") == "0" + ): + raise ProjectSourceError( + "anonymous provider API rate limit was reached" + ) from exc if require_public and exc.code in {401, 403, 404}: raise RepositoryNotPublicError( "repository API is not anonymously accessible" @@ -490,11 +495,14 @@ def detect_license(mirror: Path, commit: str) -> str: def collect_metadata( source: dict[str, str], mirror: Path, commit: str, previous: dict[str, Any] | None ) -> tuple[dict[str, Any], bool]: + provider = repository_provider(str(source["web_url"])) metadata: dict[str, Any] = commit_metadata(mirror, commit) metadata.update( { "repository_url": source["web_url"], - "readme_url": f"{source['web_url']}/src/branch/{quote(source['branch'], safe='')}/README.md", + "readme_url": repository_readme_url( + str(source["web_url"]), str(source["branch"]) + ), "commit_url": f"{source['web_url']}/commit/{commit}", "default_branch": source["branch"], "license": detect_license(mirror, commit), @@ -516,7 +524,8 @@ def collect_metadata( if repository.get("default_branch") != source["branch"]: raise ProjectSourceError(f"{source['project_id']}: API default branch differs from registry") metadata["open_issues"] = int(repository.get("open_issues_count") or 0) - metadata["stars"] = int(repository.get("stars_count") or 0) + stars_field = "stargazers_count" if provider == "github" else "stars_count" + metadata["stars"] = int(repository.get(stars_field) or 0) metadata["forks"] = int(repository.get("forks_count") or 0) languages = fetch_json(f"{source['api_url']}/languages") if isinstance(languages, dict): diff --git a/support/devlog_editor.py b/support/devlog_editor.py new file mode 100644 index 0000000..3b3bc2e --- /dev/null +++ b/support/devlog_editor.py @@ -0,0 +1,1769 @@ +#!/usr/bin/env python3 +"""Standalone editor for repository-owned Labyricorn project and devlog content.""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from datetime import date +import os +from pathlib import Path +import re +import subprocess +import tempfile +import unicodedata +from urllib.parse import urlsplit, urlunsplit + + +SCHEMA_VERSION = "1" +DEVLOG_RELATIVE = Path(".labyricorn") / "devlog" +PROJECT_RELATIVE = Path(".labyricorn") / "project" / "contents.lr" +PUBLISHING_PATHS = ( + ".labyricorn/AGENTS.md", + ".labyricorn/README.md", + ".labyricorn/project", + ".labyricorn/devlog", +) +SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +SEPARATOR_RE = re.compile(r"(?m)(^---[ \t]*(?:\r\n|\n|$))") +FIELD_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_-]*):(.*)") +RAW_HTML_RE = re.compile(r"<\s*(?:!|/?[A-Za-z])[\s\S]*?>") +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"} +IGNORED_DOCUMENTATION = {"AGENTS.md", "README.md"} +MAX_FILES = 100 +MAX_FILE_SIZE = 5 * 1024 * 1024 +MAX_TOTAL_SIZE = 20 * 1024 * 1024 + +ROOT_AGENTS_TEXT = """# Instructions for Labyricorn publishing content + +These instructions apply to the entire `.labyricorn/` directory. More specific +instructions in `project/AGENTS.md` and `devlog/AGENTS.md` also apply within +those directories. + +## Purpose and ownership + +- This directory is the repository-owned source for the project's exhibition + page and development log on Labyricorn. +- This project repository is authoritative for the content. The Labyricorn site + imports it as read-only content. +- Read `.labyricorn/README.md` and the nearest scoped `AGENTS.md` before editing + a publishing record. +- Keep publishing changes focused. Do not alter application code merely to + support an exhibition or devlog edit unless the user separately requests it. + +## Authorization and content contract + +- Preserve unrelated and uncommitted work. Never discard changes to obtain a + clean working tree. +- Do not commit or push unless the user explicitly requests publication. +- Use native Lektor records named `contents.lr` and preserve `schema_version: 1` + until a coordinated schema migration is approved. +- Keep attachments inside the record subtree that owns them. +- Use UTF-8 text, `YYYY-MM-DD` dates, and stable lowercase hyphenated slugs. +- Do not add templates, models, plugins, workflows, executable files, builds, + application state, credentials, raw HTML, scripts, or active third-party + content below `.labyricorn/`. +- Treat published URLs as durable. Ask before renaming or removing a published + record; redirects or archival behavior may be required first. + +## Security, synchronization, and review + +- Never store or print passwords, API tokens, refresh credentials, private keys, + `.netrc` contents, or other secrets in this directory. +- Do not claim content is public merely because a push succeeded. Report push + status and Labyricorn synchronization status separately. +- Parse changed records, confirm referenced source commits exist, run + `git diff --check`, and review the exact publishing diff before committing. +- At handoff, report changed publishing records, validation performed, commit + and push status, and synchronization status if known. +""" + +ROOT_README_TEMPLATE = """# Labyricorn publishing content + +This directory is the repository-owned source for {project_title}'s project +exhibition and development log on Labyricorn. The project repository remains +the source of truth; the Labyricorn site imports this content as read-only. + +The content uses native Lektor records: + +```text +.labyricorn/ +├── AGENTS.md +├── README.md +├── project/ +│ ├── AGENTS.md +│ ├── contents.lr +│ └── +└── devlog/ + ├── AGENTS.md + ├── contents.lr + └── / + ├── contents.lr + └── +``` + +Rules: + +- `project/contents.lr` uses the `project` model. +- `devlog/contents.lr` uses the `devlog` model. +- Each entry is a directory below `devlog/` containing a `contents.lr` record + using the `devlog-entry` model. +- Entry directory names are stable public slugs. Do not rename a published entry + without arranging a redirect on the Labyricorn site. +- Dates use `YYYY-MM-DD`; `source_commit` uses the full relevant commit ID. +- Publishing attachments stay inside the record subtree that owns them. +- Do not put credentials, builds, application state, or executable code here. + +Editing these records does not itself prove that the public site synchronized. +Report repository publication and Labyricorn synchronization separately. +""" + +PROJECT_AGENTS_TEXT = """# Instructions for the Labyricorn project record + +These instructions supplement `.labyricorn/AGENTS.md` and apply to the +`project/` directory. + +## Record structure + +- Maintain exactly one project record at `project/contents.lr` using + `_model: project` and `schema_version: 1`. +- Keep `project_id` stable. It is the durable identity used by the importer and + public URL. +- Ask before changing the repository URL, default branch, project status, + author, start date, or project identity. +- Store project-owned images in this directory and reference them by filename + from `contents.lr`. Only PNG, JPEG, and WebP images are accepted. + +## Editorial guidance + +- Write the exhibition page as a concise project narrative, not as a copy of + the repository README or as release documentation. +- Verify technical and status claims against the repository before editing. +- Distinguish completed work, formal design work, experiments, and future work. +- Do not manually add repository-derived commit, license, language, release, + issue, star, or fork metadata. The Labyricorn importer owns those values. +- Keep the summary suitable for listings and social previews. + +## Review requirements + +- Confirm all required project fields are present and any image reference stays + inside `project/`. +- Check Markdown links are intentional HTTPS links and prose makes no unsupported + claims. +- Validate the record and run `git diff --check` before committing. +""" + +DEVLOG_AGENTS_TEXT = """# Instructions for the Labyricorn development log + +These instructions apply to `.labyricorn/devlog/` and help coding assistants +maintain the project's public development narrative safely. + +## Format and ownership + +- Keep the devlog index at `devlog/contents.lr` using `_model: devlog` and + `schema_version: 1`. +- Store each entry at `devlog//contents.lr` using + `_model: devlog-entry` and `schema_version: 1`. +- Use lowercase hyphenated entry slugs. Published slugs are durable public URLs; + do not rename or delete them without explicit approval and a redirect or + archival decision. +- Every entry must contain `title`, `date`, `author`, `summary`, `tags`, + `source_commit`, and Markdown `body` fields. +- Keep entry attachments in that entry's directory. Only PNG, JPEG, and WebP + images are accepted by the Labyricorn importer. +- Do not add templates, models, plugins, executable code, builds, application + state, credentials, or active third-party content below this directory. + +## Content guidance + +- Base entries on verifiable repository history, project documentation, and + implemented behavior. Do not invent motivations, results, release status, + user feedback, or completion claims. +- Explain the milestone, why it mattered, and what changed. Keep one main + milestone per entry and write a summary that works in a chronological list. + Do not merely expand a commit message or enumerate every changed file. +- Distinguish design and schema contracts from completed runtime behavior. +- Link to the relevant canonical commit using the repository's HTTPS web URL. +- Use the referenced commit's actual `YYYY-MM-DD` calendar date for retrospective + entries, not the date on which the prose was drafted. +- Set `source_commit` to the full lowercase 40-character commit ID most directly + associated with the milestone. It must be an ancestor of the published branch. +- Topics are comma-separated display labels. Unknown topics are allowed: the + site keeps them visible but unlinked. Do not create or edit the site's tracked + tag registry from this project repository. +- Use Markdown without raw HTML, scripts, embedded credentials, or active + external content. +- Multiple entries may share a date. The site resolves ties from source history; + do not invent timestamps or manual next/previous links. +- Do not rewrite older entries merely because the implementation later changed. + Add a new entry or a clearly labelled correction when historical context is + needed. + +## Safety and review + +- Preserve unrelated project files and uncommitted work. The devlog owns only + `.labyricorn/devlog/`. +- Do not commit or push unless the user explicitly requests publication. +- Before publication, verify claims against the referenced commit, validate all + records, check that the source-commit link uses the same full commit ID, review + the exact devlog diff, and confirm unrelated files are not staged in the + publishing commit. +- Prefer the repository-root `devlog_editor.py` for routine validation and safe + publication when it is available. +""" + +PROJECT_REQUIRED_FIELDS = { + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "tags", "body", +} +DEVLOG_REQUIRED_FIELDS = {"_model", "schema_version", "title", "summary"} +ENTRY_REQUIRED_FIELDS = { + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +} +ENTRY_FIELD_ORDER = ( + "_model", "schema_version", "title", "date", "author", "summary", "tags", + "source_commit", "body", +) +PROJECT_FIELD_ORDER = ( + "_model", "schema_version", "project_id", "title", "summary", "status", + "started", "author", "repository_url", "default_branch", "logo", "tags", "body", +) + + +class DevlogError(Exception): + """An expected error suitable for display to a nontechnical user.""" + + +class GitError(DevlogError): + """A safe summary of a failed Git operation.""" + + +def is_link(path: Path) -> bool: + return path.is_symlink() or bool(getattr(path, "is_junction", lambda: False)()) + + +def suggest_slug(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + return re.sub(r"[^a-z0-9]+", "-", normalized.casefold()).strip("-") + + +def is_publishing_path(value: str) -> bool: + value = value.replace("\\", "/") + return any(value == root or value.startswith(root + "/") for root in PUBLISHING_PATHS) + + +def validate_iso_date(value: str, label: str) -> str: + value = value.strip() + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise DevlogError(f"{label} must use YYYY-MM-DD.") from exc + if parsed.isoformat() != value: + raise DevlogError(f"{label} must use YYYY-MM-DD.") + return value + + +def normalized_topics(value: str) -> str: + values: list[str] = [] + for item in re.split(r"[,\n]", value): + item = item.strip().replace("\t", " ") + if item and item not in values: + values.append(item) + return ", ".join(values) + + +@dataclass +class LrDocument: + blocks: list[str] + delimiters: list[str] + newline: str + source: str + + @classmethod + def parse(cls, text: str, source: str) -> "LrDocument": + parts = SEPARATOR_RE.split(text) + blocks = [parts[0]] + delimiters: list[str] = [] + for index in range(1, len(parts), 2): + delimiters.append(parts[index]) + blocks.append(parts[index + 1]) + if not blocks or any(not block.strip() for block in blocks): + raise DevlogError(f"{source}: empty or malformed record block.") + document = cls(blocks, delimiters, "\r\n" if "\r\n" in text else "\n", source) + document.field_indexes() + return document + + def render(self) -> str: + pieces = [self.blocks[0]] + for delimiter, block in zip(self.delimiters, self.blocks[1:]): + pieces.extend((delimiter, block)) + return "".join(pieces) + + def parse_block(self, block: str) -> tuple[str, str]: + line_end = block.find("\n") + if line_end < 0: + first_line, remainder = block, "" + else: + first_line = block[:line_end].rstrip("\r") + remainder = block[line_end + 1 :] + match = FIELD_RE.fullmatch(first_line) + if match is None: + raise DevlogError(f"{self.source}: malformed field header {first_line!r}.") + key, inline = match.groups() + if inline.startswith(" "): + inline = inline[1:] + if inline: + value = inline + if remainder.rstrip("\r\n"): + value += "\n" + remainder.rstrip("\r\n") + else: + value = remainder.rstrip("\r\n") + if value.startswith("\r\n"): + value = value[2:] + elif value.startswith("\n"): + value = value[1:] + return key, value + + def field_indexes(self) -> dict[str, int]: + indexes: dict[str, int] = {} + for index, block in enumerate(self.blocks): + key, _value = self.parse_block(block) + if key in indexes: + raise DevlogError(f"{self.source}: duplicate field {key!r}.") + indexes[key] = index + return indexes + + def values(self) -> dict[str, str]: + return {key: self.parse_block(self.blocks[index])[1] for key, index in self.field_indexes().items()} + + def get(self, key: str, default: str = "") -> str: + index = self.field_indexes().get(key) + return default if index is None else self.parse_block(self.blocks[index])[1] + + def set_field( + self, + key: str, + value: str, + *, + multiline: bool = False, + field_order: tuple[str, ...] = ENTRY_FIELD_ORDER, + ) -> None: + indexes = self.field_indexes() + current = indexes.get(key) + if current is not None and self.get(key) == value: + return + newline = self.newline + if multiline or "\n" in value: + clean = value.rstrip("\r\n") + rendered = f"{key}:{newline}{newline}{clean}{newline}" if clean else f"{key}:{newline}" + else: + rendered = f"{key}: {value}{newline}" + if current is not None: + self.blocks[current] = rendered + return + desired = field_order.index(key) if key in field_order else len(field_order) + insertion = len(self.blocks) + for candidate, index in indexes.items(): + candidate_order = field_order.index(candidate) if candidate in field_order else len(field_order) + if candidate_order > desired: + insertion = min(insertion, index) + self.blocks.insert(insertion, rendered) + delimiter = f"---{newline}" + self.delimiters.insert(0 if insertion == 0 else insertion - 1, delimiter) + + +@dataclass +class DevlogEntry: + slug: str + path: Path + document: LrDocument + original_bytes: bytes + + @property + def title(self) -> str: + return self.document.get("title") + + @property + def publication_date(self) -> str: + return self.document.get("date") + + +@dataclass +class RepositoryStatus: + name: str + branch: str + remote_name: str + remote_url: str + upstream: str + ahead: int + behind: int + changed_count: int + publishing_changed_count: int + conflicts: list[str] + operation: str + short_status: str + + @property + def state_text(self) -> str: + if self.operation: + return f"{self.operation} is in progress — manual Git assistance required" + if self.conflicts: + return "Repository has unresolved conflicts — manual Git assistance required" + if not self.branch: + return "Detached Git checkout — manual Git assistance required" + if not self.remote_name: + return "No remote is configured" + if not self.upstream: + return "No upstream branch is configured" + if self.ahead and self.behind: + return "Local and remote histories have diverged — manual Git assistance required" + if self.behind: + return f"Remote has {self.behind} newer commit(s)" + if self.ahead: + return f"Local branch has {self.ahead} unpublished commit(s)" + return "Up to date (as of the last fetch)" + + +class GitRepository: + def __init__(self, start: Path): + self.start = start.resolve() + try: + result = subprocess.run( + ["git", "-C", str(self.start), "rev-parse", "--show-toplevel"], + capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not respond while locating the repository.") from exc + if result.returncode != 0: + raise GitError("devlog_editor.py is not inside a Git repository.") + self.root = Path(result.stdout.strip()).resolve() + if self.start != self.root: + raise GitError( + "Place devlog_editor.py in the root of this project repository, then run it again.\n\n" + f"Detected repository root: {self.root}" + ) + self.git_dir = Path(self.run("rev-parse", "--absolute-git-dir").strip()).resolve() + + @staticmethod + def _safe_url(value: str) -> str: + value = value.strip() + try: + parsed = urlsplit(value) + except ValueError: + return "configured" + if parsed.scheme and parsed.hostname: + host = parsed.hostname + if parsed.port: + host += f":{parsed.port}" + return urlunsplit((parsed.scheme, host, parsed.path, "", "")) + return re.sub(r"//[^/@\s]+@", "//***@", value) + + def _sanitize(self, message: str) -> str: + message = re.sub(r"(https?://)[^/@\s]+@", r"\1***@", message) + return message.strip() + + def run(self, *args: str, timeout: int = 30, check: bool = True) -> str: + try: + result = subprocess.run( + ["git", "-C", str(self.root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except FileNotFoundError as exc: + raise GitError("Git is not installed or is not available on PATH.") from exc + except subprocess.TimeoutExpired as exc: + raise GitError("Git did not finish in time. Check the network and Git authentication.") from exc + if check and result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) or "Git returned an error." + raise GitError(detail) + return result.stdout + + def result(self, *args: str, timeout: int = 30) -> subprocess.CompletedProcess[str]: + try: + return subprocess.run( + ["git", "-C", str(self.root), *args], capture_output=True, text=True, + encoding="utf-8", errors="replace", timeout=timeout, + ) + except (FileNotFoundError, subprocess.TimeoutExpired) as exc: + raise GitError("Git could not complete the requested operation.") from exc + + @staticmethod + def count_porcelain(raw: str) -> int: + chunks = raw.split("\0") + count = 0 + index = 0 + while index < len(chunks): + record = chunks[index] + if not record: + index += 1 + continue + count += 1 + code = record[:2] + index += 2 if "R" in code or "C" in code else 1 + return count + + def branch(self) -> str: + return self.run("branch", "--show-current").strip() + + def upstream_configuration(self, branch: str) -> tuple[str, str, str, str]: + if not branch: + return "", "", "", "" + remote = self.run("config", "--get", f"branch.{branch}.remote", check=False).strip() + if not remote: + remotes = self.run("remote", check=False).splitlines() + if "origin" in remotes: + remote = "origin" + merge_ref = self.run("config", "--get", f"branch.{branch}.merge", check=False).strip() + upstream = self.run( + "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", check=False + ).strip() + url = self.run("remote", "get-url", remote, check=False).strip() if remote and remote != "." else "" + return remote, merge_ref, upstream, url + + def operation_in_progress(self) -> str: + states = ( + ("MERGE_HEAD", "A merge"), ("rebase-merge", "A rebase"), + ("rebase-apply", "A rebase"), ("CHERRY_PICK_HEAD", "A cherry-pick"), + ("REVERT_HEAD", "A revert"), ("BISECT_LOG", "A bisect"), + ) + for marker, label in states: + path_text = self.run("rev-parse", "--git-path", marker).strip() + marker_path = Path(path_text) + if path_text and not marker_path.is_absolute(): + marker_path = self.root / marker_path + if path_text and marker_path.exists(): + return label + return "" + + def status(self) -> RepositoryStatus: + branch = self.branch() + remote, _merge_ref, upstream, remote_url = self.upstream_configuration(branch) + ahead = behind = 0 + if upstream: + counts = self.run("rev-list", "--left-right", "--count", f"HEAD...{upstream}").split() + if len(counts) == 2: + ahead, behind = map(int, counts) + raw = self.run("status", "--porcelain=v1", "-z", "--untracked-files=all") + publishing_raw = self.run( + "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", + *PUBLISHING_PATHS, + ) + conflicts = [ + item for item in self.run("diff", "--name-only", "--diff-filter=U", "-z").split("\0") if item + ] + display_url = self._safe_url(remote_url) if remote_url else "" + name_source = display_url.rstrip("/").rsplit("/", 1)[-1] if display_url else self.root.name + name = name_source.removesuffix(".git") or self.root.name + short_status = self.run("status", "--short", "--untracked-files=all").strip() + return RepositoryStatus( + name, branch, remote, display_url, upstream, ahead, behind, + self.count_porcelain(raw), self.count_porcelain(publishing_raw), conflicts, + self.operation_in_progress(), short_status, + ) + + def fetch(self) -> RepositoryStatus: + status = self.status() + if not status.remote_name or status.remote_name == ".": + raise GitError("This branch has no network remote to fetch.") + self.run("fetch", "--prune", status.remote_name, timeout=120) + return self.status() + + def get_latest(self) -> RepositoryStatus: + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "Git could not fast-forward without risking local work. No files were discarded.\n\n" + + (detail or "Commit or move the overlapping changes, then try again.") + ) + return self.status() + + @staticmethod + def ensure_safe_base(status: RepositoryStatus) -> None: + if status.operation: + raise GitError(f"{status.operation} is already in progress. Manual Git assistance is required.") + if status.conflicts: + raise GitError("The repository has unresolved conflicts. Manual Git assistance is required.") + if not status.branch: + raise GitError("The repository is in a detached checkout. Manual Git assistance is required.") + if not status.remote_name or not status.upstream: + raise GitError("The current branch needs a configured remote and upstream before it can synchronize.") + + def validate_commit(self, commit: str, imported_head: str = "HEAD") -> None: + if not COMMIT_RE.fullmatch(commit): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + result = self.result("merge-base", "--is-ancestor", commit, imported_head) + if result.returncode != 0: + raise DevlogError( + f"Source commit {commit[:10]} is not an ancestor of the current branch." + ) + + def head_defaults(self) -> tuple[str, str, str]: + raw = self.run("show", "-s", "--format=%H%x00%cs%x00%an", "HEAD").rstrip("\n") + parts = raw.split("\0", 2) + if len(parts) != 3: + raise GitError("Could not read the current commit metadata.") + return parts[0], parts[1], parts[2] + + def project_defaults(self) -> dict[str, str]: + status = self.status() + _commit, current_date, author = self.head_defaults() + dates = [line.strip() for line in self.run("log", "--reverse", "--format=%cs").splitlines() if line.strip()] + started = dates[0] if dates else current_date + repository_url = status.remote_url.rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if parsed.scheme != "https" or not parsed.netloc: + repository_url = "" + title = re.sub(r"[-_]+", " ", status.name).strip().title() or self.root.name + return { + "project_id": suggest_slug(status.name), + "title": title, + "status": "active", + "started": started, + "author": author, + "repository_url": repository_url, + "default_branch": status.branch, + "tags": "", + "summary": "", + "body": "", + "devlog_title": f"{title} development log", + "devlog_summary": f"Milestones and design decisions from the development of {title}.", + } + + def owned_outgoing_paths(self, upstream: str) -> tuple[bool, list[str]]: + paths = [ + item for item in self.run("diff", "--name-only", "-z", f"{upstream}..HEAD").split("\0") if item + ] + unrelated = [path for path in paths if not is_publishing_path(path)] + return not unrelated, unrelated + + def publish(self, content: "DevlogRepository") -> str: + content.validate_all(validate_commits=True) + self.ensure_safe_base(self.status()) + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError(divergence_message()) + if status.behind: + result = self.result("merge", "--ff-only", "@{u}", timeout=60) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + raise GitError( + "The remote is newer, but Git could not fast-forward while preserving local files. " + "Publishing stopped and no files were discarded.\n\n" + detail + ) + content.validate_all(validate_commits=True) + status = self.status() + + self.run("add", "-A", "--", *PUBLISHING_PATHS) + staged = self.result("diff", "--cached", "--quiet", "--", *PUBLISHING_PATHS) + created_commit = staged.returncode == 1 + if staged.returncode not in {0, 1}: + raise GitError("Git could not inspect the staged Labyricorn changes.") + if created_commit: + result = self.result( + "commit", "-m", "Update Labyricorn project and devlog", "--", + *PUBLISHING_PATHS, timeout=60 + ) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + if "identity unknown" in detail.casefold() or "user.email" in detail.casefold(): + detail += "\n\nConfigure your Git name and email, then try publishing again." + raise GitError("Git could not create the Labyricorn publishing commit.\n\n" + detail) + + status = self.fetch() + self.ensure_safe_base(status) + if status.ahead and status.behind: + raise GitError( + "The remote changed while publishing. Your publishing commit is safe locally, but automatic " + "publishing stopped.\n\n" + divergence_message() + ) + if not status.ahead: + return "No unpublished Labyricorn changes were found." + owned, unrelated = self.owned_outgoing_paths(status.upstream) + if not owned: + examples = "\n".join(f" • {path}" for path in unrelated[:8]) + raise GitError( + "Pushing would also publish existing commits that change files outside the approved " + "Labyricorn project/devlog paths. " + "The editor stopped at its ownership boundary. Manual Git assistance is required.\n\n" + examples + ) + branch = status.branch + _remote, merge_ref, _upstream, _url = self.upstream_configuration(branch) + if not merge_ref.startswith("refs/heads/"): + raise GitError("The configured upstream branch is not a normal remote branch.") + result = self.result("push", status.remote_name, f"HEAD:{merge_ref}", timeout=120) + if result.returncode != 0: + detail = self._sanitize(result.stderr or result.stdout) + hint = ( + "\n\nGit authentication is not configured or was rejected. Configure access for this " + "repository and use Publish again; the publishing commit remains safe locally." + if any(word in detail.casefold() for word in ("authentication", "credential", "permission denied", "could not read username")) + else "\n\nThe publishing commit remains safe locally. Check the network or remote, then try Publish again." + ) + raise GitError("Git could not push the Labyricorn changes.\n\n" + detail + hint) + return "Labyricorn project and devlog changes were committed and pushed successfully." + + +def divergence_message() -> str: + return ( + "The local and remote repositories both contain changes that the other does not have.\n\n" + "Automatic publishing has stopped to protect the repository. Someone familiar with Git " + "needs to resolve the repository history before publishing can continue." + ) + + +class DevlogRepository: + def __init__(self, git: GitRepository): + self.git = git + self.root = git.root + self.labyricorn_root = (self.root / ".labyricorn").resolve() + self.devlog_root = (self.root / DEVLOG_RELATIVE).resolve() + self.project_path = (self.root / PROJECT_RELATIVE).resolve() + + def ensure_within(self, path: Path, parent: Path) -> Path: + resolved = path.resolve() + try: + resolved.relative_to(parent.resolve()) + except ValueError as exc: + raise DevlogError(f"Unsafe path outside {parent}: {path}") from exc + return resolved + + def read_document(self, path: Path) -> tuple[LrDocument, bytes]: + try: + raw = path.read_bytes() + if len(raw) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing file exceeds {MAX_FILE_SIZE} bytes: {path}") + text = raw.decode("utf-8") + except (OSError, UnicodeError) as exc: + raise DevlogError(f"Cannot read {path}: {exc}") from exc + document = LrDocument.parse(text, str(path)) + self.reject_raw_html(document) + return document, raw + + @staticmethod + def reject_raw_html(document: LrDocument) -> None: + for key, value in document.values().items(): + if key != "repository_url" and RAW_HTML_RE.search(value): + raise DevlogError(f"{document.source}: raw HTML is not allowed in {key}.") + + def validate_project(self) -> LrDocument: + if not self.project_path.is_file(): + raise DevlogError( + "This repository has no .labyricorn/project/contents.lr project identity record." + ) + self.ensure_within(self.project_path, self.labyricorn_root) + document, _raw = self.read_document(self.project_path) + values = document.values() + missing = PROJECT_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Project identity record is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "project" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Project identity record uses an unsupported model or schema version.") + self.clean_project_values(values) + logo = values.get("logo", "").strip() + if logo: + logo_path = self.project_path.parent / logo + if Path(logo).name != logo or logo_path.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project logo must be a local PNG, JPEG, or WebP filename.") + if not logo_path.is_file(): + raise DevlogError(f"Project logo is missing: .labyricorn/project/{logo}") + return document + + def clean_project_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = { + key: str(value).replace("\r\n", "\n").replace("\r", "\n") + for key, value in values.items() + } + for required in ( + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "body", + ): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"Project {required.replace('_', ' ')} is required.") + cleaned["project_id"] = cleaned["project_id"].strip() + if not SLUG_RE.fullmatch(cleaned["project_id"]): + raise DevlogError("Project ID must be a stable lowercase hyphenated slug.") + for scalar in ("title", "author", "status", "default_branch"): + cleaned[scalar] = " ".join(cleaned[scalar].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["started"] = validate_iso_date(cleaned["started"], "Project start date") + if cleaned["status"] not in {"active", "released", "maintained", "archived"}: + raise DevlogError("Project status must be active, released, maintained, or archived.") + branch = cleaned["default_branch"] + if not re.fullmatch(r"[A-Za-z0-9._/-]+", branch) or ".." in branch: + raise DevlogError("Project default branch is invalid.") + repository_url = cleaned["repository_url"].strip().rstrip("/") + if repository_url.endswith(".git"): + repository_url = repository_url[:-4] + parsed = urlsplit(repository_url) + if ( + parsed.scheme != "https" or not parsed.hostname or parsed.username + or parsed.password or parsed.query or parsed.fragment + ): + raise DevlogError("Project repository URL must be a credential-free HTTPS web URL.") + cleaned["repository_url"] = repository_url + for field in ("title", "summary", "author", "tags", "body"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in project {field}.") + return cleaned + + def initialization_state(self) -> tuple[str, str]: + if not (self.root / ".labyricorn").exists(): + return ( + "ready_project", + "This repository does not have Labyricorn project publishing content yet.", + ) + if is_link(self.root / ".labyricorn"): + return "malformed", ".labyricorn must not be a symbolic link or junction." + try: + project = self.validate_project() + except DevlogError as exc: + return "malformed", str(exc) + devlog_path = self.root / DEVLOG_RELATIVE + if not devlog_path.exists(): + return "ready_devlog", f"{project.get('title')} does not have a Labyricorn devlog yet." + if is_link(devlog_path) or not devlog_path.is_dir(): + return "malformed", ".labyricorn/devlog is not a normal directory." + index = devlog_path / "contents.lr" + if not index.is_file(): + return "malformed", ".labyricorn/devlog exists but has no contents.lr index. It was not overwritten." + try: + self.validate_all(validate_commits=False) + except DevlogError as exc: + return "malformed", str(exc) + return "initialized", "" + + def validate_image(self, path: Path) -> None: + try: + data = path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read image {path}: {exc}") from exc + suffix = path.suffix.lower() + signatures = { + ".png": (b"\x89PNG\r\n\x1a\n",), ".jpg": (b"\xff\xd8\xff",), + ".jpeg": (b"\xff\xd8\xff",), ".webp": (b"RIFF",), + } + if len(data) > MAX_FILE_SIZE: + raise DevlogError(f"Publishing image exceeds {MAX_FILE_SIZE} bytes: {path}") + if not any(data.startswith(prefix) for prefix in signatures[suffix]): + raise DevlogError(f"Image signature does not match its extension: {path}") + if suffix == ".webp" and data[8:12] != b"WEBP": + raise DevlogError(f"Image signature does not match its extension: {path}") + + def validate_tree(self) -> None: + laby = self.root / ".labyricorn" + if not laby.is_dir() or is_link(laby): + raise DevlogError(".labyricorn must be a normal directory.") + files = 0 + total = 0 + for path in laby.rglob("*"): + if is_link(path): + raise DevlogError(f"Linked publishing paths are not allowed: {path}") + relative = path.relative_to(laby) + parts = relative.parts + if path.is_dir(): + allowed = ( + parts in {("project",), ("devlog",)} + or (len(parts) == 2 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1])) + ) + if not allowed: + raise DevlogError(f"Unsupported publishing directory: .labyricorn/{relative.as_posix()}") + continue + if path.name in IGNORED_DOCUMENTATION: + continue + files += 1 + try: + size = path.stat().st_size + except OSError as exc: + raise DevlogError(f"Cannot inspect {path}: {exc}") from exc + total += size + is_project_record = parts == ("project", "contents.lr") + is_devlog_index = parts == ("devlog", "contents.lr") + is_entry_record = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and parts[2] == "contents.lr" + ) + is_project_image = len(parts) == 2 and parts[0] == "project" and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + is_entry_image = ( + len(parts) == 3 and parts[0] == "devlog" and SLUG_RE.fullmatch(parts[1]) + and path.suffix.lower() in ALLOWED_IMAGE_EXTENSIONS + ) + if not (is_project_record or is_devlog_index or is_entry_record or is_project_image or is_entry_image): + raise DevlogError(f"Unsupported publishing file: .labyricorn/{relative.as_posix()}") + if is_project_image or is_entry_image: + self.validate_image(path) + if files > MAX_FILES or total > MAX_TOTAL_SIZE: + raise DevlogError("The publishing tree exceeds the importer file or size limit.") + required_guidance = ( + laby / "AGENTS.md", + laby / "README.md", + laby / "project" / "AGENTS.md", + laby / "devlog" / "AGENTS.md", + ) + missing_guidance = [path.relative_to(self.root).as_posix() for path in required_guidance if not path.is_file()] + if missing_guidance: + raise DevlogError( + "Labyricorn assistant guidance is incomplete; missing: " + + ", ".join(missing_guidance) + ) + + def validate_index(self) -> LrDocument: + path = self.devlog_root / "contents.lr" + if not path.is_file(): + raise DevlogError("Devlog index is missing: .labyricorn/devlog/contents.lr") + document, _raw = self.read_document(path) + values = document.values() + missing = DEVLOG_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"Devlog index is missing: {', '.join(sorted(missing))}.") + if values["_model"] != "devlog" or values["schema_version"] != SCHEMA_VERSION: + raise DevlogError("Devlog index uses an unsupported model or schema version.") + if not values["title"].strip() or not values["summary"].strip(): + raise DevlogError("Devlog index title and summary must not be empty.") + return document + + def clean_entry_values(self, values: dict[str, str]) -> dict[str, str]: + cleaned = {key: value.replace("\r\n", "\n").replace("\r", "\n") for key, value in values.items()} + for required in ("title", "author", "summary", "body"): + if not cleaned.get(required, "").strip(): + raise DevlogError(f"{required.replace('_', ' ').title()} is required.") + cleaned["title"] = " ".join(cleaned["title"].splitlines()).strip() + cleaned["author"] = " ".join(cleaned["author"].splitlines()).strip() + cleaned["summary"] = cleaned["summary"].strip() + cleaned["body"] = cleaned["body"].strip() + cleaned["date"] = validate_iso_date(cleaned.get("date", ""), "Publication date") + cleaned["tags"] = normalized_topics(cleaned.get("tags", "")) + cleaned["source_commit"] = cleaned.get("source_commit", "").strip() + if not COMMIT_RE.fullmatch(cleaned["source_commit"]): + raise DevlogError("Source commit must be a full 40-character lowercase commit ID.") + for field in ("title", "author", "summary", "body", "tags"): + if RAW_HTML_RE.search(cleaned[field]): + raise DevlogError(f"Raw HTML is not allowed in {field}.") + self.git.validate_commit(cleaned["source_commit"]) + return cleaned + + def validate_loaded_entry(self, document: LrDocument, slug: str, *, validate_commit: bool) -> None: + values = document.values() + missing = ENTRY_REQUIRED_FIELDS - set(values) + if missing: + raise DevlogError(f"devlog/{slug}/contents.lr is missing: {', '.join(sorted(missing))}.") + if document.get("_model") != "devlog-entry" or document.get("schema_version") != SCHEMA_VERSION: + raise DevlogError(f"devlog/{slug}/contents.lr uses an unsupported model or schema version.") + validate_iso_date(values["date"], "Publication date") + if not COMMIT_RE.fullmatch(values["source_commit"]): + raise DevlogError(f"devlog/{slug}: source_commit must be a full lowercase commit ID.") + for required in ("title", "author", "summary", "body"): + if not values[required].strip(): + raise DevlogError(f"devlog/{slug}: {required} must not be empty.") + if validate_commit: + self.git.validate_commit(values["source_commit"]) + + def list_entries(self, *, validate_commits: bool = False) -> list[DevlogEntry]: + if not self.devlog_root.is_dir(): + return [] + entries: list[DevlogEntry] = [] + for directory in self.devlog_root.iterdir(): + if not directory.is_dir(): + continue + if not SLUG_RE.fullmatch(directory.name): + raise DevlogError(f"Invalid devlog entry slug: {directory.name}") + entries.append(self.load_entry(directory.name, validate_commit=validate_commits)) + entries.sort(key=lambda entry: (entry.publication_date, entry.title, entry.slug), reverse=True) + return entries + + def load_entry(self, slug: str, *, validate_commit: bool = False) -> DevlogEntry: + self.validate_slug(slug) + path = self.ensure_within(self.devlog_root / slug / "contents.lr", self.devlog_root) + if not path.is_file(): + raise DevlogError(f"Devlog entry does not exist: {slug}") + document, raw = self.read_document(path) + self.validate_loaded_entry(document, slug, validate_commit=validate_commit) + return DevlogEntry(slug, path, document, raw) + + def validate_all(self, *, validate_commits: bool) -> None: + self.validate_tree() + self.validate_project() + self.validate_index() + self.list_entries(validate_commits=validate_commits) + + @staticmethod + def validate_slug(slug: str) -> None: + if not SLUG_RE.fullmatch(slug): + raise DevlogError("Slug must use lowercase letters, numbers, and single hyphens only.") + + def initialize( + self, + title: str, + summary: str, + project_values: dict[str, str] | None = None, + ) -> None: + state, message = self.initialization_state() + if state not in {"ready_project", "ready_devlog"}: + raise DevlogError(message or "Labyricorn publishing cannot be initialized in its current state.") + title = " ".join(title.splitlines()).strip() + summary = summary.replace("\r", " ").replace("\n", " ").strip() + if not title or not summary: + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(title) or RAW_HTML_RE.search(summary): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + devlog_index = self.root / DEVLOG_RELATIVE / "contents.lr" + devlog_payload = ( + f"_model: devlog\n---\nschema_version: {SCHEMA_VERSION}\n---\n" + f"title: {title}\n---\nsummary: {summary}\n" + ).encode("utf-8") + + files: dict[Path, bytes] = { + devlog_index: devlog_payload, + devlog_index.parent / "AGENTS.md": DEVLOG_AGENTS_TEXT.encode("utf-8"), + } + directories: list[Path] = [] + labyricorn = self.root / ".labyricorn" + project_directory = labyricorn / "project" + devlog_directory = labyricorn / "devlog" + + if state == "ready_project": + if project_values is None: + raise DevlogError("Project information is required for first-time initialization.") + cleaned = self.clean_project_values(project_values) + project_record = project_directory / "contents.lr" + logo_source_text = project_values.get("logo_source", "").strip() + logo_name = "" + logo_data = b"" + if logo_source_text: + logo_source = Path(logo_source_text).expanduser().resolve() + if is_link(logo_source) or not logo_source.is_file(): + raise DevlogError("The selected project image is not a normal file.") + if logo_source.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError("Project image must be PNG, JPEG, or WebP.") + self.validate_image(logo_source) + try: + logo_data = logo_source.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot read the selected project image: {exc}") from exc + logo_name = logo_source.name + document = LrDocument.parse( + f"_model: project\n---\nschema_version: {SCHEMA_VERSION}\n", + str(project_record), + ) + project_keys = [ + "project_id", "title", "summary", "status", "started", "author", + "repository_url", "default_branch", "tags", "body", + ] + if logo_name: + cleaned["logo"] = logo_name + project_keys.insert(project_keys.index("tags"), "logo") + for key in project_keys: + document.set_field( + key, + cleaned[key], + multiline=key in {"summary", "body"}, + field_order=PROJECT_FIELD_ORDER, + ) + files.update( + { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=cleaned["title"] + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + project_record: document.render().encode("utf-8"), + } + ) + if logo_name: + files[project_directory / logo_name] = logo_data + directories.extend((labyricorn, project_directory, devlog_directory)) + else: + directories.append(devlog_directory) + optional_guidance = { + labyricorn / "AGENTS.md": ROOT_AGENTS_TEXT.encode("utf-8"), + labyricorn / "README.md": ROOT_README_TEMPLATE.format( + project_title=self.validate_project().get("title") + ).encode("utf-8"), + project_directory / "AGENTS.md": PROJECT_AGENTS_TEXT.encode("utf-8"), + } + files.update({path: payload for path, payload in optional_guidance.items() if not path.exists()}) + + created: list[Path] = [] + created_directories: list[Path] = [] + try: + for directory in directories: + directory.mkdir() + created_directories.append(directory) + for destination, payload in files.items(): + with destination.open("xb") as output: + output.write(payload) + output.flush() + os.fsync(output.fileno()) + created.append(destination) + self.validate_all(validate_commits=False) + except (OSError, DevlogError) as exc: + try: + for path in reversed(created): + path.unlink(missing_ok=True) + for directory in reversed(created_directories): + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + if isinstance(exc, DevlogError): + raise + raise DevlogError(f"Cannot initialize Labyricorn publishing: {exc}") from exc + + def apply_values(self, document: LrDocument, values: dict[str, str]) -> None: + for key in ("title", "date", "author", "summary", "tags", "source_commit", "body"): + document.set_field(key, values[key], multiline=key in {"summary", "body"}) + + def create_entry(self, slug: str, values: dict[str, str]) -> DevlogEntry: + self.validate_slug(slug) + cleaned = self.clean_entry_values(values) + directory = self.ensure_within(self.devlog_root / slug, self.devlog_root) + destination = directory / "contents.lr" + if directory.exists(): + raise DevlogError(f"Devlog entry already exists: {slug}") + document = LrDocument.parse( + f"_model: devlog-entry\n---\nschema_version: {SCHEMA_VERSION}\n---\ntitle: placeholder\n", + str(destination), + ) + self.apply_values(document, cleaned) + try: + directory.mkdir() + with destination.open("xb") as output: + output.write(document.render().encode("utf-8")) + output.flush() + os.fsync(output.fileno()) + except OSError as exc: + try: + if directory.is_dir() and not any(directory.iterdir()): + directory.rmdir() + except OSError: + pass + raise DevlogError(f"Cannot create {destination}: {exc}") from exc + return self.load_entry(slug, validate_commit=True) + + def save_entry(self, entry: DevlogEntry, values: dict[str, str]) -> DevlogEntry: + cleaned = self.clean_entry_values(values) + destination = self.ensure_within(entry.path, self.devlog_root) + try: + current = destination.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {destination}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before saving.") + self.apply_values(entry.document, cleaned) + payload = entry.document.render().encode("utf-8") + if payload != current: + self.atomic_replace(destination, payload) + return self.load_entry(entry.slug, validate_commit=True) + + @staticmethod + def atomic_replace(destination: Path, payload: bytes) -> None: + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile(mode="wb", prefix=".contents.lr.", dir=destination.parent, delete=False) as output: + temporary = Path(output.name) + output.write(payload) + output.flush() + os.fsync(output.fileno()) + os.chmod(temporary, destination.stat().st_mode) + os.replace(temporary, destination) + except OSError as exc: + if temporary is not None: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + raise DevlogError(f"Cannot save {destination}: {exc}") from exc + + def delete_entry(self, entry: DevlogEntry) -> None: + self.validate_slug(entry.slug) + directory = self.ensure_within(entry.path.parent, self.devlog_root) + if directory.parent != self.devlog_root or directory.name != entry.slug or is_link(directory): + raise DevlogError(f"Unsafe devlog entry directory: {directory}") + try: + current = entry.path.read_bytes() + except OSError as exc: + raise DevlogError(f"Cannot re-read {entry.path}: {exc}") from exc + if current != entry.original_bytes: + raise DevlogError("The entry changed on disk after it was loaded. Refresh before deleting.") + children = list(directory.iterdir()) + for child in children: + if is_link(child) or not child.is_file(): + raise DevlogError(f"Refusing to delete an entry containing an unsafe path: {child}") + if child.name != "contents.lr" and child.suffix.lower() not in ALLOWED_IMAGE_EXTENSIONS: + raise DevlogError(f"Refusing to delete an unsupported entry file: {child}") + try: + for child in children: + child.unlink() + directory.rmdir() + except OSError as exc: + raise DevlogError(f"Cannot delete {directory}: {exc}") from exc + + +def run_gui(content: DevlogRepository) -> None: + import tkinter as tk + from tkinter import filedialog, messagebox, simpledialog, ttk + + class DevlogEditorApp: + def __init__(self) -> None: + self.root = tk.Tk() + self.root.title("Labyricorn Devlog") + self.root.minsize(900, 640) + self.current_entry: DevlogEntry | None = None + self.snapshot: dict[str, str] | None = None + self.mode = "new" + + outer = ttk.Frame(self.root, padding=12) + outer.pack(fill=tk.BOTH, expand=True) + outer.columnconfigure(0, weight=1) + outer.rowconfigure(1, weight=1) + + project = ttk.LabelFrame(outer, text="Project", padding=10) + project.grid(row=0, column=0, sticky="ew", pady=(0, 10)) + project.columnconfigure(0, weight=1) + self.repo_var = tk.StringVar() + self.state_var = tk.StringVar() + self.changes_var = tk.StringVar() + ttk.Label(project, textvariable=self.repo_var).grid(row=0, column=0, sticky="w") + ttk.Label(project, textvariable=self.state_var).grid(row=1, column=0, sticky="w", pady=(3, 0)) + ttk.Label(project, textvariable=self.changes_var).grid(row=2, column=0, sticky="w", pady=(3, 0)) + controls = ttk.Frame(project) + controls.grid(row=0, column=1, rowspan=3, sticky="e") + ttk.Button(controls, text="Refresh", command=self.refresh_remote).grid(row=0, column=0, padx=3) + ttk.Button(controls, text="Get Latest Version", command=self.get_latest).grid(row=0, column=1, padx=3) + ttk.Button(controls, text="Details", command=self.show_details).grid(row=0, column=2, padx=3) + + self.devlog = ttk.LabelFrame(outer, text="Devlog", padding=10) + self.devlog.grid(row=1, column=0, sticky="nsew") + self.devlog.columnconfigure(0, weight=1) + self.devlog.rowconfigure(0, weight=1) + + self.status_var = tk.StringVar() + footer = ttk.Frame(outer) + footer.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + footer.columnconfigure(0, weight=1) + ttk.Label(footer, textvariable=self.status_var).grid(row=0, column=0, sticky="w") + ttk.Button(footer, text="Publish Labyricorn Changes", command=self.publish).grid(row=0, column=1, sticky="e") + + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.bind("", lambda _event: self.save()) + self.build_content_area() + self.refresh_status() + + def clear_devlog(self) -> None: + for child in self.devlog.winfo_children(): + child.destroy() + + def build_content_area(self) -> None: + self.clear_devlog() + state, message = content.initialization_state() + if state != "initialized": + panel = ttk.Frame(self.devlog, padding=30) + panel.grid(row=0, column=0, sticky="nsew") + panel.columnconfigure(0, weight=1) + ttk.Label(panel, text=message, wraplength=700, justify="center").grid(row=0, column=0, pady=(80, 15)) + button_text = ( + "Initialize Labyricorn Project & Devlog" + if state == "ready_project" + else "Initialize Devlog" + ) + button = ttk.Button(panel, text=button_text, command=self.initialize) + button.grid(row=1, column=0) + if state not in {"ready_project", "ready_devlog"}: + button.configure(state="disabled") + self.status_var.set( + "Labyricorn publishing is not initialized" + if state in {"ready_project", "ready_devlog"} + else "Labyricorn publishing needs attention" + ) + return + + pane = ttk.Panedwindow(self.devlog, orient=tk.HORIZONTAL) + pane.grid(row=0, column=0, sticky="nsew") + browser = ttk.Frame(pane, padding=5) + form = ttk.Frame(pane, padding=5) + pane.add(browser, weight=1) + pane.add(form, weight=3) + + columns = ("date", "title", "slug") + self.tree = ttk.Treeview(browser, columns=columns, show="headings") + for column, label, width in (("date", "Date", 90), ("title", "Title", 220), ("slug", "Slug", 170)): + self.tree.heading(column, text=label) + self.tree.column(column, width=width, stretch=column != "date") + scroll = ttk.Scrollbar(browser, orient=tk.VERTICAL, command=self.tree.yview) + self.tree.configure(yscrollcommand=scroll.set) + self.tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + scroll.grid(row=0, column=3, sticky="ns") + browser.rowconfigure(0, weight=1) + browser.columnconfigure(0, weight=1) + ttk.Button(browser, text="New", command=self.new_entry).grid(row=1, column=0, sticky="ew", pady=(8, 0)) + ttk.Button(browser, text="Edit", command=self.edit_selected).grid(row=1, column=1, sticky="ew", padx=5, pady=(8, 0)) + ttk.Button(browser, text="Delete", command=self.delete_selected).grid(row=1, column=2, sticky="ew", pady=(8, 0)) + self.tree.bind("", lambda _event: self.edit_selected()) + + self.title_var = tk.StringVar() + self.slug_var = tk.StringVar() + self.date_var = tk.StringVar() + self.author_var = tk.StringVar() + self.commit_var = tk.StringVar() + self.topics_var = tk.StringVar() + form.columnconfigure(1, weight=1) + form.columnconfigure(3, weight=1) + form.rowconfigure(7, weight=1) + ttk.Label(form, text="Title").grid(row=0, column=0, sticky="w") + ttk.Entry(form, textvariable=self.title_var).grid(row=0, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Slug").grid(row=1, column=0, sticky="w") + self.slug_entry = ttk.Entry(form, textvariable=self.slug_var) + self.slug_entry.grid(row=1, column=1, columnspan=2, sticky="ew", pady=3) + ttk.Button(form, text="Suggest", command=self.fill_slug).grid(row=1, column=3, sticky="e", padx=(5, 0)) + ttk.Label(form, text="Publication date").grid(row=2, column=0, sticky="w") + ttk.Entry(form, textvariable=self.date_var).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(form, text="Author").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(form, textvariable=self.author_var).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(form, text="Source commit").grid(row=3, column=0, sticky="w") + ttk.Entry(form, textvariable=self.commit_var).grid(row=3, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Topics (comma-separated)").grid(row=4, column=0, sticky="w") + ttk.Entry(form, textvariable=self.topics_var).grid(row=4, column=1, columnspan=3, sticky="ew", pady=3) + ttk.Label(form, text="Summary").grid(row=5, column=0, sticky="nw", pady=(5, 0)) + self.summary_text = tk.Text(form, height=4, wrap="word", undo=True) + self.summary_text.grid(row=5, column=1, columnspan=3, sticky="nsew", pady=3) + ttk.Label(form, text="Body (Markdown)").grid(row=7, column=0, sticky="nw", pady=(5, 0)) + body_frame = ttk.Frame(form) + body_frame.grid(row=7, column=1, columnspan=3, sticky="nsew", pady=3) + body_frame.rowconfigure(0, weight=1) + body_frame.columnconfigure(0, weight=1) + self.body_text = tk.Text(body_frame, wrap="word", undo=True) + body_scroll = ttk.Scrollbar(body_frame, orient=tk.VERTICAL, command=self.body_text.yview) + self.body_text.configure(yscrollcommand=body_scroll.set) + self.body_text.grid(row=0, column=0, sticky="nsew") + body_scroll.grid(row=0, column=1, sticky="ns") + ttk.Button(form, text="Save Entry", command=self.save).grid(row=8, column=3, sticky="e", pady=(8, 0)) + self.reload_entries() + self.new_entry(confirm=False) + + def initialize(self) -> None: + state, _message = content.initialization_state() + project_values: dict[str, str] | None = None + if state == "ready_project": + try: + result = self.ask_project_initialization(content.git.project_defaults()) + except DevlogError as exc: + messagebox.showerror("Cannot prepare initialization", str(exc)) + return + if result is None: + return + title = result.pop("devlog_title") + summary = result.pop("devlog_summary") + project_values = result + elif state == "ready_devlog": + try: + project = content.validate_project() + except DevlogError as exc: + messagebox.showerror("Cannot initialize devlog", str(exc)) + return + title = simpledialog.askstring( + "Initialize Devlog", "Devlog title:", + initialvalue=f"{project.get('title')} development log", parent=self.root + ) + if title is None: + return + summary = simpledialog.askstring( + "Initialize Devlog", "Short description of this development log:", parent=self.root + ) + if summary is None: + return + else: + messagebox.showerror("Cannot initialize", "The publishing structure is incomplete or malformed.") + return + try: + content.initialize(title, summary, project_values) + except DevlogError as exc: + messagebox.showerror("Cannot initialize Labyricorn publishing", str(exc)) + return + self.build_content_area() + self.refresh_status() + self.status_var.set("Initialized the Labyricorn project record and devlog") + + def ask_project_initialization( + self, defaults: dict[str, str] + ) -> dict[str, str] | None: + dialog = tk.Toplevel(self.root) + dialog.title("Initialize Labyricorn Project & Devlog") + dialog.transient(self.root) + dialog.grab_set() + dialog.minsize(760, 680) + dialog.geometry("820x760") + frame = ttk.Frame(dialog, padding=14) + frame.pack(fill=tk.BOTH, expand=True) + frame.columnconfigure(1, weight=1) + frame.columnconfigure(3, weight=1) + frame.rowconfigure(8, weight=1) + + variables = { + key: tk.StringVar(value=defaults.get(key, "")) + for key in ( + "project_id", "title", "status", "started", "author", + "repository_url", "default_branch", "tags", "logo_source", + "devlog_title", + ) + } + ttk.Label(frame, text="Project exhibition record").grid( + row=0, column=0, columnspan=4, sticky="w", pady=(0, 8) + ) + ttk.Label(frame, text="Project title").grid(row=1, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["title"]).grid(row=1, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Stable project ID").grid(row=1, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["project_id"]).grid(row=1, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Status").grid(row=2, column=0, sticky="w") + ttk.Combobox( + frame, textvariable=variables["status"], state="readonly", + values=("active", "released", "maintained", "archived"), + ).grid(row=2, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Started (YYYY-MM-DD)").grid(row=2, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["started"]).grid(row=2, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Author").grid(row=3, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["author"]).grid(row=3, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Default branch").grid(row=3, column=2, sticky="w", padx=(12, 0)) + ttk.Entry(frame, textvariable=variables["default_branch"]).grid(row=3, column=3, sticky="ew", pady=3) + ttk.Label(frame, text="Repository web URL").grid(row=4, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["repository_url"]).grid( + row=4, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Technologies/topics").grid(row=5, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["tags"]).grid(row=5, column=1, sticky="ew", pady=3) + ttk.Label(frame, text="Project image (optional)").grid(row=5, column=2, sticky="w", padx=(12, 0)) + image_picker = ttk.Frame(frame) + image_picker.grid(row=5, column=3, sticky="ew", pady=3) + image_picker.columnconfigure(0, weight=1) + ttk.Entry(image_picker, textvariable=variables["logo_source"]).grid(row=0, column=0, sticky="ew") + + def choose_logo() -> None: + selected = filedialog.askopenfilename( + title="Choose project image", + filetypes=( + ("Supported images", "*.png *.jpg *.jpeg *.webp"), + ("All files", "*.*"), + ), + parent=dialog, + ) + if selected: + variables["logo_source"].set(selected) + + ttk.Button(image_picker, text="Browse…", command=choose_logo).grid( + row=0, column=1, padx=(5, 0) + ) + ttk.Label(frame, text="Project summary").grid(row=6, column=0, sticky="nw", pady=(5, 0)) + project_summary = tk.Text(frame, height=3, wrap="word", undo=True) + project_summary.grid(row=6, column=1, columnspan=3, sticky="nsew", pady=3) + project_summary.insert("1.0", defaults.get("summary", "")) + ttk.Label(frame, text="Project narrative\n(Markdown)").grid(row=8, column=0, sticky="nw", pady=(5, 0)) + project_body = tk.Text(frame, height=8, wrap="word", undo=True) + project_body.grid(row=8, column=1, columnspan=3, sticky="nsew", pady=3) + project_body.insert("1.0", defaults.get("body", "")) + + ttk.Separator(frame).grid(row=9, column=0, columnspan=4, sticky="ew", pady=10) + ttk.Label(frame, text="Development log").grid(row=10, column=0, columnspan=4, sticky="w") + ttk.Label(frame, text="Devlog title").grid(row=11, column=0, sticky="w") + ttk.Entry(frame, textvariable=variables["devlog_title"]).grid( + row=11, column=1, columnspan=3, sticky="ew", pady=3 + ) + ttk.Label(frame, text="Devlog summary").grid(row=12, column=0, sticky="nw", pady=(5, 0)) + devlog_summary = tk.Text(frame, height=3, wrap="word", undo=True) + devlog_summary.grid(row=12, column=1, columnspan=3, sticky="nsew", pady=3) + devlog_summary.insert("1.0", defaults.get("devlog_summary", "")) + + result: dict[str, str] | None = None + + def accept() -> None: + nonlocal result + candidate = {key: variable.get() for key, variable in variables.items()} + candidate["summary"] = project_summary.get("1.0", "end-1c") + candidate["body"] = project_body.get("1.0", "end-1c") + candidate["devlog_summary"] = devlog_summary.get("1.0", "end-1c") + try: + content.clean_project_values(candidate) + if not candidate["devlog_title"].strip() or not candidate["devlog_summary"].strip(): + raise DevlogError("Devlog title and summary are required.") + if RAW_HTML_RE.search(candidate["devlog_title"]) or RAW_HTML_RE.search(candidate["devlog_summary"]): + raise DevlogError("Raw HTML is not allowed in the devlog title or summary.") + except DevlogError as exc: + messagebox.showerror("Check project information", str(exc), parent=dialog) + return + result = candidate + dialog.destroy() + + buttons = ttk.Frame(frame) + buttons.grid(row=13, column=0, columnspan=4, sticky="e", pady=(12, 0)) + ttk.Button(buttons, text="Cancel", command=dialog.destroy).grid(row=0, column=0, padx=(0, 8)) + ttk.Button(buttons, text="Initialize", command=accept).grid(row=0, column=1) + dialog.protocol("WM_DELETE_WINDOW", dialog.destroy) + dialog.wait_window() + return result + + def collect_values(self) -> dict[str, str]: + return { + "title": self.title_var.get(), "date": self.date_var.get(), + "author": self.author_var.get(), "source_commit": self.commit_var.get(), + "tags": self.topics_var.get(), + "summary": self.summary_text.get("1.0", "end-1c"), + "body": self.body_text.get("1.0", "end-1c"), + } + + def set_values(self, values: dict[str, str]) -> None: + self.title_var.set(values.get("title", "")) + self.date_var.set(values.get("date", "")) + self.author_var.set(values.get("author", "")) + self.commit_var.set(values.get("source_commit", "")) + self.topics_var.set(normalized_topics(values.get("tags", ""))) + for widget, key in ((self.summary_text, "summary"), (self.body_text, "body")): + widget.delete("1.0", tk.END) + widget.insert("1.0", values.get(key, "")) + + def has_unsaved(self) -> bool: + return self.snapshot is not None and self.collect_values() != self.snapshot + + def may_discard(self) -> bool: + return not self.has_unsaved() or messagebox.askyesno("Discard changes?", "Discard unsaved changes in the current form?") + + def reload_entries(self, selected: str | None = None) -> None: + self.tree.delete(*self.tree.get_children()) + try: + entries = content.list_entries() + except DevlogError as exc: + messagebox.showerror("Cannot load devlog", str(exc)) + return + for entry in entries: + self.tree.insert("", tk.END, iid=entry.slug, values=(entry.publication_date, entry.title, entry.slug)) + if selected and self.tree.exists(selected): + self.tree.selection_set(selected) + self.tree.see(selected) + + def new_entry(self, confirm: bool = True) -> None: + if confirm and not self.may_discard(): + return + try: + commit, commit_date, author = content.git.head_defaults() + except DevlogError as exc: + messagebox.showerror("Cannot read Git history", str(exc)) + return + self.mode = "new" + self.current_entry = None + self.slug_entry.configure(state="normal") + self.slug_var.set("") + self.set_values({"date": commit_date, "author": author, "source_commit": commit}) + self.snapshot = self.collect_values() + self.status_var.set("Creating a new devlog entry") + + def edit_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to edit.") + return + if not self.may_discard(): + return + try: + entry = content.load_entry(selection[0], validate_commit=True) + except DevlogError as exc: + messagebox.showerror("Cannot load entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.status_var.set(f"Editing .labyricorn/devlog/{entry.slug}/contents.lr") + + def fill_slug(self) -> None: + if self.mode == "new": + self.slug_var.set(suggest_slug(self.title_var.get())) + + def save(self) -> None: + if not hasattr(self, "title_var"): + return + try: + if self.mode == "new": + entry = content.create_entry(self.slug_var.get().strip(), self.collect_values()) + else: + if self.current_entry is None: + raise DevlogError("No entry is loaded.") + entry = content.save_entry(self.current_entry, self.collect_values()) + except DevlogError as exc: + messagebox.showerror("Cannot save entry", str(exc)) + return + self.mode = "edit" + self.current_entry = entry + self.slug_var.set(entry.slug) + self.slug_entry.configure(state="readonly") + self.set_values(entry.document.values()) + self.snapshot = self.collect_values() + self.reload_entries(entry.slug) + self.refresh_status() + self.status_var.set(f"Saved .labyricorn/devlog/{entry.slug}/contents.lr") + + def delete_selected(self) -> None: + selection = self.tree.selection() + if not selection: + messagebox.showinfo("Select an entry", "Select a devlog entry to delete.") + return + try: + entry = content.load_entry(selection[0]) + attachments = sum(1 for path in entry.path.parent.iterdir() if path.is_file() and path != entry.path) + except (DevlogError, OSError) as exc: + messagebox.showerror("Cannot inspect entry", str(exc)) + return + note = f"\n\nThis also removes {attachments} entry attachment(s)." if attachments else "" + if not messagebox.askyesno( + "Delete devlog entry?", + f"Delete {entry.title!r} ({entry.slug}) from the working tree?{note}\n\n" + "Git can recover the deletion until it is published.", icon="warning", + ): + return + try: + content.delete_entry(entry) + except DevlogError as exc: + messagebox.showerror("Cannot delete entry", str(exc)) + return + self.snapshot = None + self.reload_entries() + self.new_entry(confirm=False) + self.refresh_status() + self.status_var.set(f"Deleted .labyricorn/devlog/{entry.slug}") + + def refresh_status(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + self.state_var.set(f"Status: {exc}") + return + remote = status.remote_name or "None" + self.repo_var.set(f"Repository: {status.name} Branch: {status.branch or 'detached'} Remote: {remote}") + self.state_var.set(f"Status: {status.state_text}") + word = "file" if status.publishing_changed_count == 1 else "files" + self.changes_var.set( + f"Labyricorn changes: {status.publishing_changed_count} unpublished {word}" + ) + + def refresh_remote(self) -> None: + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + content.git.fetch() + if content.initialization_state()[0] == "initialized": + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot refresh repository", str(exc)) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def get_latest(self) -> None: + if self.has_unsaved() and not messagebox.askyesno( + "Unsaved form changes", "The form has unsaved text. Continue only if it is also saved on disk?" + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + before = content.git.status() + after = content.git.get_latest() + content.validate_all(validate_commits=False) + except DevlogError as exc: + messagebox.showerror("Cannot get latest version", str(exc)) + else: + self.build_content_area() + messagebox.showinfo( + "Repository updated", + "The repository is already current." if not before.behind else f"Fast-forwarded by {before.behind} commit(s). Local files were preserved.", + ) + self.state_var.set(f"Status: {after.state_text}") + finally: + self.root.configure(cursor="") + self.refresh_status() + + def publish(self) -> None: + if self.has_unsaved(): + messagebox.showinfo("Save the entry first", "Save or discard the current form changes before publishing.") + return + if not messagebox.askyesno( + "Publish Labyricorn changes?", + "Validate, commit, and push only the project record, publishing guidance, " + "approved project images, and devlog inside .labyricorn/?\n\n" + "All files outside that boundary will be left out of the publishing commit.", + ): + return + self.root.configure(cursor="watch") + self.root.update_idletasks() + try: + result = content.git.publish(content) + except DevlogError as exc: + messagebox.showerror("Publishing stopped safely", str(exc)) + else: + messagebox.showinfo("Publish Labyricorn Changes", result) + finally: + self.root.configure(cursor="") + self.refresh_status() + + def show_details(self) -> None: + try: + status = content.git.status() + except DevlogError as exc: + messagebox.showerror("Cannot read details", str(exc)) + return + details = [ + f"Repository root: {content.git.root}", f"Branch: {status.branch or 'detached'}", + f"Remote: {status.remote_name or 'None'}", f"Remote URL: {status.remote_url or 'None'}", + f"Upstream: {status.upstream or 'None'}", f"Ahead / behind: {status.ahead} / {status.behind}", + f"Changed files: {status.changed_count}", + f"Labyricorn changed files: {status.publishing_changed_count}", + ] + if status.short_status: + details.extend(("", "Working-tree details:", status.short_status)) + messagebox.showinfo("Repository details", "\n".join(details)) + + def close(self) -> None: + if not hasattr(self, "title_var") or self.may_discard(): + self.root.destroy() + + try: + app = DevlogEditorApp() + except tk.TclError as exc: + raise DevlogError(f"Cannot open the Tkinter window: {exc}") from exc + app.root.mainloop() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--validate", action="store_true", + help="validate the local Labyricorn devlog without opening the GUI", + ) + args = parser.parse_args() + try: + git = GitRepository(Path(__file__).resolve().parent) + content = DevlogRepository(git) + if args.validate: + content.validate_all(validate_commits=True) + print("devlog-editor: devlog is valid") + else: + run_gui(content) + except DevlogError as exc: + parser.exit(1, f"devlog-editor: ERROR: {exc}\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_git_repo.py b/tests/test_git_repo.py new file mode 100644 index 0000000..3a9e34d --- /dev/null +++ b/tests/test_git_repo.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path +import os +import shutil +import stat +import subprocess +import sys +import unittest +import uuid + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from git_repo import ( # noqa: E402 + DIVERGED, + LOCAL_AHEAD, + REMOTE_AHEAD, + SYNCED, + UNAVAILABLE, + GitRepository, + GitRepositoryError, + _display_url, +) + + +class GitRepositoryTests(unittest.TestCase): + def setUp(self) -> None: + cache_root = Path(__file__).resolve().parents[1] / ".cache" + cache_root.mkdir(exist_ok=True) + self.base = cache_root / f"git-repo-test-{uuid.uuid4().hex}" + self.base.mkdir() + self.remote = self.base / "labyricorn-site.git" + self.seed = self.base / "seed" + self.work = self.base / "work" + self.other = self.base / "other" + + self.git(self.base, "init", "--bare", "--initial-branch=main", str(self.remote)) + self.git(self.base, "init", "--initial-branch=main", str(self.seed)) + self.configure_identity(self.seed) + (self.seed / "README.md").write_text("initial\n", encoding="utf-8") + self.git(self.seed, "add", "README.md") + self.git(self.seed, "commit", "-m", "Initial") + self.git(self.seed, "remote", "add", "origin", str(self.remote)) + self.git(self.seed, "push", "-u", "origin", "main") + + self.git(self.base, "clone", str(self.remote), str(self.work)) + self.git(self.base, "clone", str(self.remote), str(self.other)) + self.configure_identity(self.work) + self.configure_identity(self.other) + self.repository = GitRepository.locate(self.work / "README.md") + + def tearDown(self) -> None: + shutil.rmtree(self.base, onexc=self.make_writable) + + @staticmethod + def make_writable(function: object, path: str, _error: BaseException) -> None: + os.chmod(path, stat.S_IWRITE) + function(path) + + @staticmethod + def git(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=False, + capture_output=True, + text=True, + encoding="utf-8", + ) + if result.returncode != 0: + raise AssertionError(result.stderr.strip() or result.stdout.strip()) + return result + + def configure_identity(self, repository: Path) -> None: + self.git(repository, "config", "user.name", "Launcher Test") + self.git(repository, "config", "user.email", "launcher@example.invalid") + + def commit(self, repository: Path, filename: str, content: str) -> None: + (repository / filename).write_text(content, encoding="utf-8") + self.git(repository, "add", filename) + self.git(repository, "commit", "-m", f"Update {filename}") + + def push_other_commit(self) -> None: + self.commit(self.other, "remote.txt", "remote\n") + self.git(self.other, "push", "origin", "main") + + def test_synchronized_and_dirty_states(self) -> None: + state = self.repository.refresh() + self.assertEqual(SYNCED, state.sync_state) + self.assertEqual("labyricorn-site", state.repository_name) + self.assertEqual("main", state.branch) + self.assertEqual("origin/main", state.upstream) + self.assertEqual(0, state.dirty_count) + + (self.work / "local.txt").write_text("uncommitted\n", encoding="utf-8") + dirty = self.repository.refresh() + self.assertEqual(SYNCED, dirty.sync_state) + self.assertEqual(1, dirty.dirty_count) + + def test_local_ahead_and_push(self) -> None: + self.commit(self.work, "local.txt", "local\n") + state = self.repository.refresh() + self.assertEqual(LOCAL_AHEAD, state.sync_state) + self.assertEqual(1, state.ahead) + self.assertTrue(state.can_push) + + result = self.repository.push(state) + self.assertEqual(SYNCED, result.sync_state) + + def test_remote_ahead_and_fast_forward_pull(self) -> None: + self.push_other_commit() + state = self.repository.refresh() + self.assertEqual(REMOTE_AHEAD, state.sync_state) + self.assertEqual(1, state.behind) + self.assertTrue(state.can_pull) + + result = self.repository.pull(state) + self.assertEqual(SYNCED, result.sync_state) + self.assertTrue((self.work / "remote.txt").is_file()) + + def test_dirty_worktree_blocks_pull(self) -> None: + self.push_other_commit() + (self.work / "local.txt").write_text("uncommitted\n", encoding="utf-8") + state = self.repository.refresh() + self.assertEqual(REMOTE_AHEAD, state.sync_state) + self.assertFalse(state.can_pull) + with self.assertRaisesRegex(GitRepositoryError, "uncommitted changes"): + self.repository.pull(state) + + def test_diverged_history_has_no_sync_action(self) -> None: + self.commit(self.work, "local.txt", "local\n") + self.push_other_commit() + state = self.repository.refresh() + self.assertEqual(DIVERGED, state.sync_state) + self.assertEqual(1, state.ahead) + self.assertEqual(1, state.behind) + self.assertFalse(state.can_pull) + self.assertFalse(state.can_push) + with self.assertRaisesRegex(GitRepositoryError, "diverged"): + self.repository.push(state) + + def test_missing_origin_and_upstream_are_reported(self) -> None: + self.git(self.work, "remote", "remove", "origin") + missing_origin = self.repository.refresh() + self.assertEqual(UNAVAILABLE, missing_origin.sync_state) + self.assertIn("No usable origin", missing_origin.problem or "") + + self.git(self.work, "remote", "add", "origin", str(self.remote)) + missing_upstream = self.repository.refresh() + self.assertEqual(UNAVAILABLE, missing_upstream.sync_state) + self.assertIn("no usable upstream", missing_upstream.problem or "") + + def test_http_credentials_are_not_displayed(self) -> None: + self.assertEqual( + "https://git.example.test/owner/repository.git", + _display_url("https://user:secret@git.example.test/owner/repository.git"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project_editor.py b/tests/test_project_editor.py new file mode 100644 index 0000000..d23e05e --- /dev/null +++ b/tests/test_project_editor.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from pathlib import Path +import shutil +import sys +import unittest +from unittest.mock import patch +import uuid +from types import SimpleNamespace + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +from project_editor import ( # noqa: E402 + ProjectEditorError, + ProjectSourceRegistry, + check_devlog_readiness, + corrective_guidance, +) +from management_launcher import EDITOR_SCRIPTS # noqa: E402 +from project_providers import ProjectProviderError, derive_repository_urls # noqa: E402 + + +class FakeProjectSourceError(RuntimeError): + pass + + +class FakeRepositoryNotPublicError(FakeProjectSourceError): + pass + + +def fake_importer(**overrides: object) -> SimpleNamespace: + values: dict[str, object] = { + "ProjectSourceError": FakeProjectSourceError, + "RepositoryNotPublicError": FakeRepositoryNotPublicError, + "fetch_json": lambda *_args, **_kwargs: {"private": False, "default_branch": "main"}, + "ensure_mirror": lambda *_args, **_kwargs: (Path("mirror.git"), True), + "resolve_branch": lambda *_args, **_kwargs: "a" * 40, + "validate_snapshot": lambda *_args, **_kwargs: ({}, {}), + } + values.update(overrides) + return SimpleNamespace(**values) + + +class ProjectEditorTests(unittest.TestCase): + def setUp(self) -> None: + cache_root = Path(__file__).resolve().parents[1] / ".cache" + cache_root.mkdir(exist_ok=True) + self.site_root = cache_root / f"project-editor-test-{uuid.uuid4().hex}" + (self.site_root / "configs").mkdir(parents=True) + (self.site_root / "content" / "projects").mkdir(parents=True) + (self.site_root / "Test.lektorproject").write_text("[project]\nname = Test\n", encoding="utf-8") + (self.site_root / "content" / "projects" / "contents.lr").write_text( + "_model: section\n---\ntitle: Projects\n", encoding="utf-8" + ) + (self.site_root / "configs" / "project-sources.ini").write_text( + "# Preserve this header.\n\n" + "[alpha]\n" + "repository = https://git.example.test/team/alpha.git\n" + "web_url = https://git.example.test/team/alpha\n" + "api_url = https://git.example.test/api/v1/repos/team/alpha\n" + "branch = main\n" + "featured_order = 10\n" + "future_option = preserve me\n\n" + "[beta]\n" + "repository = https://git.example.test/team/beta.git\n" + "web_url = https://git.example.test/team/beta\n" + "api_url = https://git.example.test/api/v1/repos/team/beta\n" + "branch = stable\n", + encoding="utf-8", + ) + self.registry = ProjectSourceRegistry(self.site_root) + + def tearDown(self) -> None: + shutil.rmtree(self.site_root) + + def test_current_registry_loads(self) -> None: + registry = ProjectSourceRegistry(Path(__file__).resolve().parents[1]) + sources = registry.list_sources() + self.assertGreaterEqual(len(sources), 1) + self.assertTrue(all(source.repository.startswith("https://") for source in sources)) + + def test_management_launcher_exposes_project_editor_as_required_script(self) -> None: + self.assertIn(("Project Editor", "project_editor.py", True), EDITOR_SCRIPTS) + + def test_create_edit_and_delete_preserve_other_configuration(self) -> None: + created = self.registry.create_source( + "gamma", + "https://git.example.test/team/gamma", + "main", + "", + ) + alpha_before = self.registry.load_source("alpha") + saved = self.registry.save_source( + created, + created.web_url, + "release", + "30", + ) + self.registry.delete_source(saved) + + text = (self.site_root / "configs" / "project-sources.ini").read_text(encoding="utf-8") + self.assertIn("# Preserve this header.", text) + self.assertIn("future_option = preserve me", text) + self.assertIn("[beta]", text) + self.assertNotIn("[gamma]", text) + self.assertEqual(self.registry.load_source("alpha").repository, alpha_before.repository) + + def test_edit_preserves_unknown_options_and_detects_external_change(self) -> None: + source = self.registry.load_source("alpha") + saved = self.registry.save_source( + source, source.web_url, "release", "11" + ) + self.assertEqual(dict(saved.unknown_options)["future_option"], "preserve me") + with (self.site_root / "configs" / "project-sources.ini").open("a", encoding="utf-8") as output: + output.write("\n# external change\n") + with self.assertRaisesRegex(ProjectEditorError, "changed on disk"): + self.registry.save_source( + saved, saved.web_url, saved.branch, "11" + ) + + def test_delete_never_touches_external_repository_and_refuses_last_source(self) -> None: + beta = self.registry.load_source("beta") + self.registry.delete_source(beta) + alpha = self.registry.load_source("alpha") + with self.assertRaisesRegex(ProjectEditorError, "at least one"): + self.registry.delete_source(alpha) + + def test_invalid_urls_and_ids_are_rejected(self) -> None: + with self.assertRaises(ProjectEditorError): + self.registry.create_source( + "Not Valid", "http://example.test/team/repo", "main", "" + ) + + def test_one_public_url_derives_github_and_gitea_configuration(self) -> None: + github = derive_repository_urls("https://github.com/example/public-project.git") + self.assertEqual(github.provider, "github") + self.assertEqual(github.web_url, "https://github.com/example/public-project") + self.assertEqual(github.repository, f"{github.web_url}.git") + self.assertEqual( + github.api_url, "https://api.github.com/repos/example/public-project" + ) + + gitea = derive_repository_urls("https://git.example.test/team/project") + self.assertEqual(gitea.provider, "gitea") + self.assertEqual(gitea.repository, "https://git.example.test/team/project.git") + self.assertEqual( + gitea.api_url, "https://git.example.test/api/v1/repos/team/project" + ) + + saved = self.registry.create_source( + "github-project", + "https://github.com/example/public-project", + "main", + "20", + ) + self.assertEqual(saved.repository, github.repository) + self.assertEqual(saved.web_url, github.web_url) + self.assertEqual(saved.api_url, github.api_url) + + def test_gitlab_url_is_rejected_clearly(self) -> None: + with self.assertRaisesRegex(ProjectProviderError, "GitLab.*not supported"): + derive_repository_urls("https://gitlab.com/example/project") + + @patch("project_editor.shutil.which", return_value="git") + @patch("project_editor.tempfile.TemporaryDirectory") + def test_inaccessible_repository_is_not_reported_as_missing_devlog( + self, temporary: object, _which: object + ) -> None: + temporary.return_value.__enter__.return_value = str(self.site_root / "temporary") + def fail_fetch(*_args: object, **_kwargs: object) -> object: + raise FakeProjectSourceError("initial repository fetch failed") + + importer = fake_importer(ensure_mirror=fail_fetch) + result = check_devlog_readiness(self.registry.load_source("alpha"), importer) + self.assertEqual(result.status, "Repository inaccessible") + self.assertNotIn("Initialize Devlog", result.details) + + @patch("project_editor.shutil.which", return_value="git") + @patch("project_editor.tempfile.TemporaryDirectory") + def test_missing_devlog_has_corrective_standalone_editor_guidance( + self, temporary: object, _which: object + ) -> None: + temporary.return_value.__enter__.return_value = str(self.site_root / "temporary") + def fail_validation(*_args: object, **_kwargs: object) -> object: + raise FakeProjectSourceError( + "publishing tree is missing ['.labyricorn/devlog/contents.lr']" + ) + + importer = fake_importer(validate_snapshot=fail_validation) + source = self.registry.load_source("alpha") + result = check_devlog_readiness(source, importer) + self.assertEqual(result.status, "Not initialized") + guidance = corrective_guidance(source) + for phrase in ( + "git clone", + "Copy the standalone devlog_editor.py", + "python devlog_editor.py", + "Initialize Devlog", + "Create and save", + "Publish Labyricorn Changes", + "commit and push", + "Check Devlog Status", + ): + self.assertIn(phrase, guidance) + + @patch("project_editor.shutil.which", return_value="git") + @patch("project_editor.tempfile.TemporaryDirectory") + def test_valid_remote_snapshot_reports_ready( + self, temporary: object, _which: object + ) -> None: + temporary.return_value.__enter__.return_value = str(self.site_root / "temporary") + records = { + ".labyricorn/project/contents.lr": {"title": "Alpha", "project_id": "alpha"}, + ".labyricorn/devlog/contents.lr": {"title": "Alpha devlog"}, + } + importer = fake_importer( + resolve_branch=lambda *_args: "b" * 40, + validate_snapshot=lambda *_args: ({}, records), + ) + result = check_devlog_readiness(self.registry.load_source("alpha"), importer) + self.assertTrue(result.ready) + self.assertEqual(result.status, "Ready") + self.assertIn("Valid devlog entries: 0", result.details) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_project_sources.py b/tests/test_project_sources.py index 48b0c3e..7685b7f 100644 --- a/tests/test_project_sources.py +++ b/tests/test_project_sources.py @@ -13,6 +13,7 @@ from project_sources import ( # noqa: E402 ProjectSourceError, RepositoryNotPublicError, append_fields, + collect_metadata, fetch_json, load_registry, metadata_digest, @@ -68,6 +69,21 @@ class ProjectSourceTests(unittest.TestCase): with self.assertRaises(RepositoryNotPublicError): fetch_json("https://git.example.test/api/repo", require_public=True) + def test_github_anonymous_rate_limit_is_not_reported_as_private(self) -> None: + error = HTTPError( + "https://api.github.com/repos/example/project", + 403, + "Forbidden", + {"X-RateLimit-Remaining": "0"}, + None, + ) + with patch("project_sources.urlopen", side_effect=error): + with self.assertRaisesRegex(ProjectSourceError, "rate limit"): + fetch_json( + "https://api.github.com/repos/example/project", + require_public=True, + ) + def test_unapproved_labels_remain_visible_without_becoming_tags(self) -> None: items = taxonomy_label_items( ["TypeScript", "Unlisted Tool"], ["typescript"] @@ -78,6 +94,41 @@ class ProjectSourceTests(unittest.TestCase): self.assertEqual(normalize_tag_value("v0.4.0"), "v0-4-0") self.assertEqual(normalize_tag_value("OpenAI Build Week"), "openai-build-week") + def test_github_public_metadata_is_normalized_for_project_templates(self) -> None: + source = { + "project_id": "example", + "repository": "https://github.com/example/project.git", + "web_url": "https://github.com/example/project", + "api_url": "https://api.github.com/repos/example/project", + "branch": "main", + } + responses = ( + { + "private": False, + "default_branch": "main", + "open_issues_count": 7, + "stargazers_count": 42, + "forks_count": 5, + }, + {"Python": 100, "JavaScript": 25}, + {"tag_name": "v1.0.0", "html_url": "https://github.com/example/project/releases/tag/v1.0.0"}, + ) + with ( + patch("project_sources.commit_metadata", return_value={"commit": "a" * 40}), + patch("project_sources.detect_license", return_value="MIT"), + patch("project_sources.fetch_json", side_effect=responses), + ): + metadata, current = collect_metadata(source, Path("unused.git"), "a" * 40, None) + + self.assertTrue(current) + self.assertEqual(metadata["stars"], 42) + self.assertEqual(metadata["forks"], 5) + self.assertEqual(metadata["languages"], ["Python", "JavaScript"]) + self.assertEqual( + metadata["readme_url"], + "https://github.com/example/project/blob/main/README.md", + ) + if __name__ == "__main__": unittest.main()