Files
labyricorn-site/scripts/devlog_editor.py
T
2026-08-13 18:53:18 -07:00

1798 lines
83 KiB
Python

#!/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 shutil
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
│ └── <optional image>
└── devlog/
├── AGENTS.md
├── contents.lr
└── <entry-slug>/
├── contents.lr
└── <optional images>
```
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/<stable-slug>/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()
self.git_executable, self.uses_bundled_git = self._find_git(self.start)
try:
result = subprocess.run(
self._git_command("-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()
try:
self.start.relative_to(self.root)
except ValueError as exc:
raise GitError(
"devlog_editor.py is not inside the detected repository root.\n\n"
f"Detected repository root: {self.root}"
) from exc
self.git_dir = Path(self.run("rev-parse", "--absolute-git-dir").strip()).resolve()
@staticmethod
def _find_git(start: Path) -> tuple[Path | str, bool]:
system_git = shutil.which("git")
if system_git:
return system_git, False
bundled_candidates = (
start / "support" / "PortableGit" / "cmd" / "git.exe",
start / "PortableGit" / "cmd" / "git.exe",
start.parent / "support" / "PortableGit" / "cmd" / "git.exe",
)
for bundled in bundled_candidates:
if bundled.is_file():
return bundled, True
raise GitError(
"Git is unavailable. Place PortableGit under support\\PortableGit or install Git."
)
def _git_command(self, *args: str) -> list[str]:
command = [str(self.git_executable)]
if self.uses_bundled_git:
command.extend(("-c", "credential.helper=manager"))
command.extend(args)
return command
@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(
self._git_command("-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(
self._git_command("-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("<Control-s>", 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("<Double-1>", 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())