feat(slice4): metadata sidecar git read/write helpers — dual-read + lazy-migrate ops (§22.4a)
apply_values, EntryGitState, read_entry_from_git, write_entry_files, sidecar_path_for. Plus the SLICE-4 implementation plan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ values — it only moves metadata between git and in-memory `Entry` records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
@@ -46,6 +47,12 @@ def slug_of_sidecar(name: str) -> str:
|
||||
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
|
||||
|
||||
|
||||
def sidecar_path_for(md_path: str) -> str:
|
||||
"""The sidecar path sibling to a `<dir>/<slug>.md` entry file."""
|
||||
assert md_path.endswith(".md"), md_path
|
||||
return md_path[: -len(".md")] + SIDECAR_SUFFIX
|
||||
|
||||
|
||||
# ----- metadata <-> sidecar -----
|
||||
|
||||
def metadata_dict(entry: Entry) -> dict[str, Any]:
|
||||
@@ -136,6 +143,93 @@ def read_entry(
|
||||
return _with_slug(entry_mod.from_frontmatter(values, body)), False
|
||||
|
||||
|
||||
# ----- value editing (SLICE-4) -----
|
||||
|
||||
def apply_values(entry: Entry, values: dict[str, Any]) -> Entry:
|
||||
"""Return a new Entry with `values` merged over the entry's metadata.
|
||||
|
||||
Known keys (`tags`, `state`, `reviewed_by`, …) land on their typed fields;
|
||||
unknown keys land on `extra` (INV-7). The body is carried through unchanged
|
||||
— this mutates metadata only. Unspecified keys are preserved.
|
||||
"""
|
||||
merged = metadata_dict(entry)
|
||||
merged.update(values)
|
||||
return entry_mod.from_frontmatter(merged, entry.body)
|
||||
|
||||
|
||||
# ----- git-aware read/write (SLICE-4) -----
|
||||
|
||||
@dataclass
|
||||
class EntryGitState:
|
||||
"""An entry's on-disk state across its `.md` and optional sidecar.
|
||||
|
||||
Captured by `read_entry_from_git` and consumed by `write_entry_files` to
|
||||
decide create-vs-update for the sidecar and whether the `.md` still needs
|
||||
its frontmatter stripped (lazy migration).
|
||||
"""
|
||||
entry: Entry
|
||||
md_text: str
|
||||
md_sha: str
|
||||
sidecar_text: str | None
|
||||
sidecar_sha: str | None
|
||||
malformed: bool
|
||||
|
||||
|
||||
async def read_entry_from_git(
|
||||
gitea: Any, org: str, repo: str, md_path: str, *, ref: str = "main"
|
||||
) -> "EntryGitState | None":
|
||||
"""Dual-read an entry from git → `EntryGitState`, or None if the `.md` is
|
||||
missing. Reads the `.md` and its sibling sidecar (if any); never raises on
|
||||
bad metadata (INV-3)."""
|
||||
md = await gitea.read_file(org, repo, md_path, ref=ref)
|
||||
if md is None:
|
||||
return None
|
||||
md_text, md_sha = md
|
||||
sc_path = sidecar_path_for(md_path)
|
||||
sc = await gitea.read_file(org, repo, sc_path, ref=ref)
|
||||
sidecar_text, sidecar_sha = (sc[0], sc[1]) if sc else (None, None)
|
||||
stem = md_path.rsplit("/", 1)[-1][: -len(".md")]
|
||||
entry, malformed = read_entry(md_text, sidecar_text, fallback_slug=stem)
|
||||
return EntryGitState(
|
||||
entry=entry, md_text=md_text, md_sha=md_sha,
|
||||
sidecar_text=sidecar_text, sidecar_sha=sidecar_sha, malformed=malformed,
|
||||
)
|
||||
|
||||
|
||||
def _md_has_frontmatter(md_text: str) -> bool:
|
||||
return entry_mod.FRONTMATTER_RE.match(md_text) is not None
|
||||
|
||||
|
||||
def write_entry_files(
|
||||
md_path: str, entry: Entry, state: "EntryGitState"
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Produce `change_files` ops that persist `entry`'s metadata to its sidecar
|
||||
and keep the `.md` as pure prose (INV-1/INV-2).
|
||||
|
||||
- Sidecar: `create` when none existed, else `update` at its prior sha.
|
||||
- `.md`: rewritten body-only **only when it still carries frontmatter**
|
||||
(lazy migration, INV-6); an already-clean body is left untouched.
|
||||
"""
|
||||
sc_path = sidecar_path_for(md_path)
|
||||
ops: list[dict[str, Any]] = []
|
||||
sc_op: dict[str, Any] = {
|
||||
"operation": "update" if state.sidecar_sha else "create",
|
||||
"path": sc_path,
|
||||
"content": sidecar_yaml(entry),
|
||||
}
|
||||
if state.sidecar_sha:
|
||||
sc_op["sha"] = state.sidecar_sha
|
||||
ops.append(sc_op)
|
||||
if _md_has_frontmatter(state.md_text):
|
||||
body = strip_frontmatter(state.md_text)
|
||||
new_md = body if (body == "" or body.endswith("\n")) else body + "\n"
|
||||
ops.append({
|
||||
"operation": "update", "path": md_path,
|
||||
"content": new_md, "sha": state.md_sha,
|
||||
})
|
||||
return ops
|
||||
|
||||
|
||||
# ----- frontmatter -> sidecar migration (PUC-5) -----
|
||||
|
||||
async def migrate_collection(
|
||||
|
||||
Reference in New Issue
Block a user