diff --git a/src/shard_wiki/adapters/__init__.py b/src/shard_wiki/adapters/__init__.py index 915678e..167da46 100644 --- a/src/shard_wiki/adapters/__init__.py +++ b/src/shard_wiki/adapters/__init__.py @@ -9,10 +9,12 @@ from shard_wiki.adapters.conformance import ( ) from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter from shard_wiki.adapters.folder import FolderAdapter +from shard_wiki.adapters.git import GitShardAdapter __all__ = [ "ShardAdapter", "FolderAdapter", + "GitShardAdapter", "CONTRACT_VERSION", "Check", "ConformanceReport", diff --git a/src/shard_wiki/adapters/git.py b/src/shard_wiki/adapters/git.py new file mode 100644 index 0000000..6dbb82c --- /dev/null +++ b/src/shard_wiki/adapters/git.py @@ -0,0 +1,127 @@ +"""GitShardAdapter — a second substrate: git-as-store (SHARD-WP-0012; TSD §A.3 git-IS-store). + +The home case where **git is the store *and* the journal**. Tracked ``*.md`` paths are the page +keys; the working-tree file is the body; a page's ``source_rev`` is the **commit sha of the last +commit touching its path** (per-path, so an edit to one page never drifts another). The declared +profile is *git-IS-store ⟹ substrate=git ∧ history=git-native* — the implication rule the +capability model enforces (§6.5), validated at registration like any other binding. + +This adapter adds **no core changes**: it implements the same :class:`ShardAdapter` contract the +folder adapter does, proving "write an adapter + declare a verified profile" is the whole cost of a +new substrate (capability-as-data, I-3). Built on the ``git`` CLI via subprocess — zero new deps. +""" + +from __future__ import annotations + +import os +import subprocess +from collections.abc import Iterable +from pathlib import Path + +from shard_wiki.adapters.contract import ShardAdapter +from shard_wiki.model import ( + AccessGrant, + Addressing, + AttachmentMode, + CapabilityProfile, + ContentOpacity, + History, + Identity, + MergeModel, + NativeQuery, + OperationalEnvelope, + Page, + Placement, + Substrate, + Translation, + Verb, + WriteGranularity, +) +from shard_wiki.provenance import Liveness, ProvenanceEnvelope, Staleness + +__all__ = ["GitShardAdapter"] + +_GIT_IDENTITY = { + "GIT_AUTHOR_NAME": "shard-wiki", + "GIT_AUTHOR_EMAIL": "shard@shard-wiki", + "GIT_COMMITTER_NAME": "shard-wiki", + "GIT_COMMITTER_EMAIL": "shard@shard-wiki", +} + + +class GitShardAdapter(ShardAdapter): + """A shard whose store is a git repo: keys are tracked ``*.md`` paths, revs are commit shas.""" + + def __init__(self, shard_id: str, repo_path: str | Path) -> None: + self._shard_id = shard_id + self._repo = Path(repo_path) + self._repo.mkdir(parents=True, exist_ok=True) + if not (self._repo / ".git").exists(): + self._git("init", "--quiet") + + @property + def shard_id(self) -> str: + return self._shard_id + + def profile(self) -> CapabilityProfile: + return CapabilityProfile( + substrate=Substrate.GIT, + attachment_mode=AttachmentMode.GIT_IS_STORE, + write_granularity=WriteGranularity.NONE, + content_opacity=ContentOpacity.TRANSPARENT, + operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED, + access_grant=AccessGrant.OPEN, + liveness=Liveness.STATIC, + history=History.GIT_NATIVE, # git-is-store ⟹ git-native (§6.5) + merge_model=MergeModel.GIT_TEXT, + addressing=Addressing.PATH, + native_query=NativeQuery.NONE, + translation=Translation.NATIVE, + supported_verbs=frozenset({Verb.READ}), + ).validate() + + def keys(self) -> Iterable[str]: + out = self._git("ls-files", "*.md").decode() + for line in out.splitlines(): + yield line[: -len(".md")] if line.endswith(".md") else line + + def read(self, key: str) -> Page: + path = self._path_for(key) + if not path.is_file(): + raise KeyError(key) + rev = self.current_rev(key) + return Page( + identity=Identity(self._shard_id, key), + body=path.read_text(encoding="utf-8"), + envelope=ProvenanceEnvelope( + source_shard=self._shard_id, + liveness=Liveness.STATIC, + staleness=Staleness.FRESH, + source_rev=rev, + lineage="git-native", + ), + placements=(Placement(self._shard_id, f"{key}.md"),), + ) + + def current_rev(self, key: str) -> str | None: + """The sha of the last commit touching ``key``'s path (per-path drift token), or None.""" + rel = f"{key}.md" + if not self._path_for(key).is_file(): + return None + sha = self._git("log", "-1", "--format=%H", "--", rel).decode().strip() + return sha or None + + # -- git plumbing -------------------------------------------------------- + + def _path_for(self, key: str) -> Path: + return self._repo / f"{key}.md" + + def _git(self, *args: str, stdin: bytes | None = None, env: dict | None = None) -> bytes: + result = subprocess.run( + ["git", "-C", str(self._repo), *args], + input=stdin, + capture_output=True, + env={**os.environ, **(env or {})}, + check=True, + ) + return result.stdout diff --git a/tests/test_git_adapter.py b/tests/test_git_adapter.py new file mode 100644 index 0000000..a8e2e96 --- /dev/null +++ b/tests/test_git_adapter.py @@ -0,0 +1,131 @@ +"""Tests for the GitShardAdapter read path + profile (SHARD-WP-0012 T1).""" + +import subprocess + +import pytest + +from shard_wiki.adapters import GitShardAdapter, run_conformance +from shard_wiki.model import ( + AttachmentMode, + History, + NotSupported, + ProfileError, + Substrate, + Verb, +) + + +def _git(repo, *args): + subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t", + "PATH": __import__("os").environ.get("PATH", "")}, + ) + + +def _repo(tmp_path, files, name="repo"): + repo = tmp_path / name + repo.mkdir() + _git(repo, "init", "--quiet") + for rel, text in files.items(): + p = repo / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + _git(repo, "add", rel) + _git(repo, "commit", "-m", "seed") + return repo + + +def test_keys_are_tracked_md_paths(tmp_path): + repo = _repo(tmp_path, {"Home.md": "h", "docs/Guide.md": "g", "ignore.txt": "x"}) + adapter = GitShardAdapter("git", repo) + assert set(adapter.keys()) == {"Home", "docs/Guide"} # only tracked *.md + + +def test_read_returns_page_with_commit_sha_rev(tmp_path): + repo = _repo(tmp_path, {"Home.md": "welcome"}) + adapter = GitShardAdapter("git", repo) + page = adapter.read("Home") + assert page.identity.shard == "git" + assert page.body == "welcome" + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], capture_output=True, text=True, check=True + ).stdout.strip() + assert page.envelope.source_rev == head # source_rev is the commit sha + assert page.envelope.lineage == "git-native" + + +def test_read_missing_key_raises(tmp_path): + adapter = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "h"})) + with pytest.raises(KeyError): + adapter.read("Nope") + + +def test_profile_validates_implication_rules(tmp_path): + profile = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "h"})).profile() + assert profile.substrate is Substrate.GIT + assert profile.attachment_mode is AttachmentMode.GIT_IS_STORE + assert profile.history is History.GIT_NATIVE # git-is-store ⟹ git-native + profile.validate() # raises if the implication rule were violated + + +def test_profile_is_read_only_in_t1(tmp_path): + profile = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "h"})).profile() + assert profile.supports(Verb.READ) + assert not profile.supports(Verb.WRITE) + + +def test_conformance_read_path_passes(tmp_path): + adapter = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "h", "Other.md": "o"})) + report = run_conformance(adapter) + assert report.ok, report.diff() + + +def test_unclaimed_write_raises_not_supported(tmp_path): + adapter = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "h"})) + with pytest.raises(NotSupported): + adapter.write("Home", "new") # read-only: honest absence + + +def test_empty_repo_has_no_keys(tmp_path): + repo = tmp_path / "empty" + repo.mkdir() + _git(repo, "init", "--quiet") + adapter = GitShardAdapter("git", repo) + assert list(adapter.keys()) == [] + + +def test_bad_profile_combo_is_rejected(): + # Sanity: the implication rule that backs the git profile actually bites when violated. + from shard_wiki.model import ( + AccessGrant, + Addressing, + CapabilityProfile, + ContentOpacity, + MergeModel, + NativeQuery, + OperationalEnvelope, + Translation, + WriteGranularity, + ) + from shard_wiki.provenance import Liveness + + with pytest.raises(ProfileError): + CapabilityProfile( + substrate=Substrate.FILES, # not git, but claims git-is-store + attachment_mode=AttachmentMode.GIT_IS_STORE, + write_granularity=WriteGranularity.NONE, + content_opacity=ContentOpacity.TRANSPARENT, + operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED, + access_grant=AccessGrant.OPEN, + liveness=Liveness.STATIC, + history=History.NONE, + merge_model=MergeModel.NONE, + addressing=Addressing.PATH, + native_query=NativeQuery.NONE, + translation=Translation.NATIVE, + supported_verbs=frozenset({Verb.READ}), + ).validate()