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 __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
@@ -46,6 +47,12 @@ def slug_of_sidecar(name: str) -> str:
|
|||||||
return name[: -len(SIDECAR_SUFFIX)] if is_sidecar(name) else name
|
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 -----
|
# ----- metadata <-> sidecar -----
|
||||||
|
|
||||||
def metadata_dict(entry: Entry) -> dict[str, Any]:
|
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
|
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) -----
|
# ----- frontmatter -> sidecar migration (PUC-5) -----
|
||||||
|
|
||||||
async def migrate_collection(
|
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.meta.yaml") is True
|
||||||
assert metadata.is_sidecar("view-metrics.md") is False
|
assert metadata.is_sidecar("view-metrics.md") is False
|
||||||
assert metadata.slug_of_sidecar("view-metrics.meta.yaml") == "view-metrics"
|
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