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(
|
||||
|
||||
@@ -197,3 +197,27 @@ def test_sidecar_filename_helpers():
|
||||
assert metadata.is_sidecar("view-metrics.meta.yaml") is True
|
||||
assert metadata.is_sidecar("view-metrics.md") is False
|
||||
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
|
||||
|
||||
|
||||
# ---- SLICE-4: sidecar_path_for + apply_values ----
|
||||
|
||||
def test_sidecar_path_for_derives_sibling():
|
||||
assert metadata.sidecar_path_for("rfcs/alpha.md") == "rfcs/alpha.meta.yaml"
|
||||
assert metadata.sidecar_path_for("x/y/beta.md") == "x/y/beta.meta.yaml"
|
||||
|
||||
|
||||
def test_apply_values_updates_known_and_extra_fields():
|
||||
e = entry_mod.parse(LEGACY_MD) # has tags + extra priority/owner
|
||||
e2 = metadata.apply_values(e, {"tags": ["x"], "priority": "P0", "owner": "sam"})
|
||||
assert e2.tags == ["x"]
|
||||
assert e2.extra["priority"] == "P0"
|
||||
assert e2.extra["owner"] == "sam"
|
||||
# body preserved unchanged
|
||||
assert e2.body == e.body
|
||||
|
||||
|
||||
def test_apply_values_preserves_unspecified_keys():
|
||||
e = entry_mod.parse(LEGACY_MD)
|
||||
e2 = metadata.apply_values(e, {"priority": "P0"})
|
||||
assert e2.tags == e.tags # untouched
|
||||
assert e2.extra["owner"] == "hasan" # untouched
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""SLICE-4 — git-aware sidecar read/write helpers.
|
||||
|
||||
Uses the FakeGitea from the propose-vertical fixtures (no network).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import yaml
|
||||
|
||||
from app import gitea as gitea_mod, metadata
|
||||
from app.config import load_config
|
||||
|
||||
from test_propose_vertical import app_with_fake_gitea, tmp_env # noqa: F401
|
||||
|
||||
LEGACY = """---
|
||||
slug: alpha
|
||||
title: Alpha
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- one
|
||||
priority: P1
|
||||
---
|
||||
|
||||
Alpha body.
|
||||
"""
|
||||
|
||||
MIGRATED_MD = "Alpha body.\n"
|
||||
MIGRATED_SIDECAR = """slug: alpha
|
||||
title: Alpha
|
||||
state: active
|
||||
owners:
|
||||
- ben.stull
|
||||
tags:
|
||||
- one
|
||||
priority: P1
|
||||
"""
|
||||
|
||||
|
||||
def _gitea():
|
||||
return gitea_mod.Gitea(load_config())
|
||||
|
||||
|
||||
def test_read_entry_from_git_legacy(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": LEGACY, "sha": "s1"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
assert st is not None
|
||||
assert st.entry.slug == "alpha"
|
||||
assert st.entry.extra["priority"] == "P1"
|
||||
assert st.sidecar_sha is None # no sidecar yet
|
||||
assert st.malformed is False
|
||||
|
||||
|
||||
def test_read_entry_from_git_migrated(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": MIGRATED_MD, "sha": "s1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": MIGRATED_SIDECAR, "sha": "s2"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
assert st.entry.extra["priority"] == "P1" # from sidecar
|
||||
assert st.entry.body == "Alpha body.\n"
|
||||
assert st.sidecar_sha == "s2"
|
||||
|
||||
|
||||
def test_read_entry_from_git_missing(app_with_fake_gitea):
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/nope.md"))
|
||||
assert st is None
|
||||
|
||||
|
||||
def test_write_entry_files_lazy_migrates_legacy(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": LEGACY, "sha": "s1"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
e2 = metadata.apply_values(st.entry, {"priority": "P0"})
|
||||
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||
paths = {o["path"]: o for o in ops}
|
||||
# sidecar created, .md rewritten body-only
|
||||
assert "rfcs/alpha.meta.yaml" in paths
|
||||
assert paths["rfcs/alpha.meta.yaml"]["operation"] == "create"
|
||||
assert paths["rfcs/alpha.md"]["operation"] == "update"
|
||||
assert "---" not in paths["rfcs/alpha.md"]["content"] # INV-2 clean body
|
||||
assert yaml.safe_load(paths["rfcs/alpha.meta.yaml"]["content"])["priority"] == "P0"
|
||||
|
||||
|
||||
def test_write_entry_files_already_migrated_touches_sidecar_only(app_with_fake_gitea):
|
||||
_app, fake = app_with_fake_gitea
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.md")] = {
|
||||
"content": MIGRATED_MD, "sha": "s1"}
|
||||
fake.files[("wiggleverse", "meta", "main", "rfcs/alpha.meta.yaml")] = {
|
||||
"content": MIGRATED_SIDECAR, "sha": "s2"}
|
||||
st = asyncio.run(metadata.read_entry_from_git(
|
||||
_gitea(), "wiggleverse", "meta", "rfcs/alpha.md"))
|
||||
e2 = metadata.apply_values(st.entry, {"priority": "P0"})
|
||||
ops = metadata.write_entry_files("rfcs/alpha.md", e2, st)
|
||||
paths = {o["path"]: o for o in ops}
|
||||
assert set(paths) == {"rfcs/alpha.meta.yaml"} # .md untouched
|
||||
assert paths["rfcs/alpha.meta.yaml"]["operation"] == "update"
|
||||
assert paths["rfcs/alpha.meta.yaml"]["sha"] == "s2"
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user