setup / install-leaves-tree-clean (push) Waiting to run
Selectively port source coverage and redirect lint, ignore-aware provenance, staging and trust checks from upstream 055d720. Preserve local archive metadata and adapt source-date and defuddle skill guidance. Keep the existing skills directory, file index and MCP interfaces. Record per-module upstream baselines and exclusions without migrating vault data or changing services. Validation: 625 tests passed; git diff --check passed.
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""Staged-write promotion — the mechanical half of `wiki-stage-commit`.
|
|
|
|
When ``WIKI_STAGED_WRITES=true``, skills write pages into ``_staging/`` instead of
|
|
the live vault, and a human approves them before they land. This module owns the
|
|
parts of that workflow that have one correct answer: what is queued, moving a
|
|
file to its live path, moving a rejection back to ``_raw/``, and refusing to do
|
|
either when the files changed since they were reviewed.
|
|
|
|
Judgment stays with the skill — whether a page *should* land, how to summarise
|
|
it, and how to merge a ``.patch.md`` whose surrounding text has moved on.
|
|
Promotion never rewrites a page: it is a rename, so frontmatter, body and links
|
|
arrive exactly as they were reviewed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from obsidian_wiki.vault import iter_md_under, okignore_patterns, okignored
|
|
|
|
STAGING_DIR = "_staging"
|
|
RAW_DIR = "_raw"
|
|
PATCH_SUFFIX = ".patch.md"
|
|
|
|
|
|
class StagingError(Exception):
|
|
"""A staged operation could not be performed."""
|
|
|
|
|
|
class StagingConflict(StagingError):
|
|
"""The staged or live file changed since the caller reviewed it.
|
|
|
|
Raised instead of overwriting, so a caller can re-read the pair and show the
|
|
reviewer what actually changed. Agents write to the vault while a human is
|
|
still looking at a diff; this is the case that makes promotion unsafe.
|
|
"""
|
|
|
|
|
|
def resolve_in_vault(vault: Path, rel: str) -> Path:
|
|
"""Resolve a caller-supplied relative path inside *vault*, or refuse.
|
|
|
|
The single path check for staged operations. Symlinks are resolved before
|
|
the comparison, so a link inside the vault pointing out of it is caught too.
|
|
"""
|
|
root = Path(vault).resolve()
|
|
target = (root / rel).resolve()
|
|
if target != root and root not in target.parents:
|
|
raise StagingError(f"path escapes the vault: {rel}")
|
|
return target
|
|
|
|
|
|
def revision(path: Path) -> str | None:
|
|
"""Content hash of *path*, or ``None`` when it does not exist.
|
|
|
|
``None`` is meaningful: it is the revision of a live page that does not exist
|
|
yet, so a caller can pin "there was no live page when I reviewed this" and
|
|
have promotion fail if one appeared in the meantime.
|
|
"""
|
|
if not path.is_file():
|
|
return None
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
@dataclass
|
|
class StagedEntry:
|
|
"""One file waiting in ``_staging/``."""
|
|
|
|
staged_path: str
|
|
live_path: str
|
|
kind: str # "new" | "update" | "patch"
|
|
staged_revision: str | None = None
|
|
live_revision: str | None = None
|
|
staged_mtime: str = ""
|
|
|
|
def as_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"staged_path": self.staged_path,
|
|
"live_path": self.live_path,
|
|
"kind": self.kind,
|
|
"staged_revision": self.staged_revision,
|
|
"live_revision": self.live_revision,
|
|
"staged_mtime": self.staged_mtime,
|
|
}
|
|
|
|
|
|
def _live_relative(staged_rel: Path) -> str:
|
|
"""Map a path under ``_staging/`` to the live path it targets."""
|
|
name = staged_rel.name
|
|
if name.endswith(PATCH_SUFFIX):
|
|
# `concepts/foo.patch.md` describes an edit to `concepts/foo.md`.
|
|
name = name[: -len(PATCH_SUFFIX)] + ".md"
|
|
return staged_rel.with_name(name).as_posix()
|
|
|
|
|
|
def list_staged(vault: Path) -> list[StagedEntry]:
|
|
"""Inventory ``_staging/``, newest-staged last, with both revisions pinned."""
|
|
vault = Path(vault)
|
|
root = vault / STAGING_DIR
|
|
if not root.is_dir():
|
|
return []
|
|
|
|
patterns = okignore_patterns(vault)
|
|
entries: list[StagedEntry] = []
|
|
for staged in iter_md_under(vault, root):
|
|
staged_rel = staged.relative_to(root)
|
|
live_rel = _live_relative(staged_rel)
|
|
# The server diffs against the live page, which would print an ignored
|
|
# page's text for anything staged over it.
|
|
if okignored(Path(live_rel), patterns):
|
|
continue
|
|
live = vault / live_rel
|
|
if staged.name.endswith(PATCH_SUFFIX):
|
|
kind = "patch"
|
|
else:
|
|
kind = "update" if live.is_file() else "new"
|
|
stat = staged.stat()
|
|
entries.append(
|
|
StagedEntry(
|
|
staged_path=staged.relative_to(vault).as_posix(),
|
|
live_path=live_rel,
|
|
kind=kind,
|
|
staged_revision=revision(staged),
|
|
live_revision=revision(live),
|
|
staged_mtime=date.fromtimestamp(stat.st_mtime).isoformat(),
|
|
)
|
|
)
|
|
return entries
|
|
|
|
|
|
def _staged_file(vault: Path, rel: str) -> Path:
|
|
"""Resolve *rel* to a file under ``_staging/``, accepting either spelling.
|
|
|
|
Callers hold either the vault-relative path from `list_staged`
|
|
(``_staging/concepts/a.md``) or the staging-relative one (``concepts/a.md``).
|
|
Both name the same file; accepting one and not the other is a trap.
|
|
"""
|
|
rel = rel.replace(os.sep, "/").lstrip("/")
|
|
if not rel:
|
|
raise StagingError("no staged path given")
|
|
candidate = rel if rel.startswith(f"{STAGING_DIR}/") else f"{STAGING_DIR}/{rel}"
|
|
staged = resolve_in_vault(vault, candidate)
|
|
# Being inside the vault is not enough: `../concepts/a.md` resolves to a live
|
|
# page, which would otherwise be promoted or discarded as if it were staged.
|
|
root = resolve_in_vault(vault, STAGING_DIR)
|
|
if root not in staged.parents:
|
|
raise StagingError(f"path is not under {STAGING_DIR}/: {rel}")
|
|
if not staged.is_file():
|
|
raise StagingError(f"no staged file: {candidate}")
|
|
return staged
|
|
|
|
|
|
def _check_revision(path: Path, expected: str | None, label: str) -> None:
|
|
"""Refuse when *path* no longer matches the revision the caller reviewed.
|
|
|
|
``expected`` of ``None`` means the caller did not pin this side and accepts
|
|
whatever is there — distinct from a pinned ``None``, which asserts absence.
|
|
"""
|
|
if expected is None:
|
|
return
|
|
actual = revision(path)
|
|
if actual == expected:
|
|
return
|
|
raise StagingConflict(
|
|
f"{label} changed since it was reviewed "
|
|
f"(expected {expected or 'no file'}, found {actual or 'no file'})"
|
|
)
|
|
|
|
|
|
def promote(
|
|
vault: Path,
|
|
rel: str,
|
|
*,
|
|
expected_staged_revision: str | None = None,
|
|
expected_live_revision: str | None = None,
|
|
pin_live_absent: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""Move one staged page to its live path.
|
|
|
|
The move is a rename, so the promoted page is byte-for-byte what was
|
|
reviewed — arbitrary frontmatter, body and wikilinks all survive untouched.
|
|
|
|
Pass ``expected_staged_revision`` / ``expected_live_revision`` from the
|
|
listing the reviewer saw to make this fail rather than clobber a concurrent
|
|
write. ``pin_live_absent=True`` asserts there was no live page at review
|
|
time, which a bare ``expected_live_revision=None`` cannot express.
|
|
|
|
``.patch.md`` files are refused: merging a human-readable diff into a page
|
|
whose surrounding text may have moved is judgment, and belongs in the skill.
|
|
"""
|
|
vault = Path(vault)
|
|
staged = _staged_file(vault, rel)
|
|
if staged.name.endswith(PATCH_SUFFIX):
|
|
raise StagingError(
|
|
f"{staged.relative_to(vault).as_posix()} is a patch, not a whole page — "
|
|
"merge it with the wiki-stage-commit skill, then promote the result"
|
|
)
|
|
|
|
live_rel = _live_relative(staged.relative_to(vault / STAGING_DIR))
|
|
live = resolve_in_vault(vault, live_rel)
|
|
|
|
_check_revision(staged, expected_staged_revision, "staged file")
|
|
if pin_live_absent and live.is_file():
|
|
raise StagingConflict(f"live page {live_rel} was created since it was reviewed")
|
|
_check_revision(live, expected_live_revision, f"live page {live_rel}")
|
|
|
|
replaced = live.is_file()
|
|
live.parent.mkdir(parents=True, exist_ok=True)
|
|
# os.replace is atomic within a filesystem: no reader ever sees a partial page.
|
|
os.replace(staged, live)
|
|
_prune_empty_dirs(vault / STAGING_DIR, staged.parent)
|
|
|
|
return {
|
|
"action": "promote",
|
|
"staged_path": staged.relative_to(vault).as_posix(),
|
|
"live_path": live_rel,
|
|
"kind": "update" if replaced else "new",
|
|
"revision": revision(live),
|
|
}
|
|
|
|
|
|
def discard(vault: Path, rel: str) -> dict[str, Any]:
|
|
"""Move one staged file back to ``_raw/`` for manual editing.
|
|
|
|
Named ``rejected-<category>-<page>.md`` so the origin stays readable once the
|
|
directory structure is flattened away.
|
|
"""
|
|
vault = Path(vault)
|
|
staged = _staged_file(vault, rel)
|
|
staged_rel = staged.relative_to(vault / STAGING_DIR)
|
|
|
|
stem = "-".join(staged_rel.with_suffix("").parts)
|
|
prefix = "rejected-patch-" if staged.name.endswith(PATCH_SUFFIX) else "rejected-"
|
|
if stem.endswith(".patch"):
|
|
stem = stem[: -len(".patch")]
|
|
raw = resolve_in_vault(vault, f"{RAW_DIR}/{prefix}{stem}.md")
|
|
raw.parent.mkdir(parents=True, exist_ok=True)
|
|
# Never silently overwrite an earlier rejection of the same page.
|
|
counter = 2
|
|
while raw.exists():
|
|
raw = raw.with_name(f"{prefix}{stem}-{counter}.md")
|
|
counter += 1
|
|
|
|
os.replace(staged, raw)
|
|
_prune_empty_dirs(vault / STAGING_DIR, staged.parent)
|
|
|
|
return {
|
|
"action": "discard",
|
|
"staged_path": staged.relative_to(vault).as_posix(),
|
|
"raw_path": raw.relative_to(vault).as_posix(),
|
|
}
|
|
|
|
|
|
def _prune_empty_dirs(root: Path, start: Path) -> None:
|
|
"""Remove directories left empty under ``_staging/`` after a move."""
|
|
current = start.resolve()
|
|
root = root.resolve()
|
|
while current != root and root in current.parents:
|
|
try:
|
|
current.rmdir()
|
|
except OSError:
|
|
return
|
|
current = current.parent
|
|
|
|
|
|
def log_decisions(vault: Path, results: list[dict[str, Any]]) -> None:
|
|
"""Append one STAGE_COMMIT line to ``log.md``, matching the skill's format."""
|
|
if not results:
|
|
return
|
|
promoted = sum(1 for r in results if r["action"] == "promote")
|
|
discarded = sum(1 for r in results if r["action"] == "discard")
|
|
from obsidian_wiki.memory import append_log
|
|
|
|
append_log(Path(vault), "STAGE_COMMIT", {"accepted": promoted, "rejected": discarded})
|