generated from coulomb/repo-seed
feat(adapters): GitShardAdapter read path + git-IS-store profile (WP-0012 T1)
A second substrate validating the contract beyond plain folders: a git-IS-store shard reading Markdown from a git repo. Keys are tracked *.md paths; read returns a Page whose source_rev is the per-path last-commit sha (so an edit to one page never drifts another); profile is git-IS-store / substrate=git / history=git-native / addressing=path, validated against the §6.5 implication rules. Passes the conformance read path with honest absence of unclaimed verbs. Zero new deps (git CLI via subprocess). No core changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,12 @@ from shard_wiki.adapters.conformance import (
|
|||||||
)
|
)
|
||||||
from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter
|
from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter
|
||||||
from shard_wiki.adapters.folder import FolderAdapter
|
from shard_wiki.adapters.folder import FolderAdapter
|
||||||
|
from shard_wiki.adapters.git import GitShardAdapter
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ShardAdapter",
|
"ShardAdapter",
|
||||||
"FolderAdapter",
|
"FolderAdapter",
|
||||||
|
"GitShardAdapter",
|
||||||
"CONTRACT_VERSION",
|
"CONTRACT_VERSION",
|
||||||
"Check",
|
"Check",
|
||||||
"ConformanceReport",
|
"ConformanceReport",
|
||||||
|
|||||||
127
src/shard_wiki/adapters/git.py
Normal file
127
src/shard_wiki/adapters/git.py
Normal file
@@ -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
|
||||||
131
tests/test_git_adapter.py
Normal file
131
tests/test_git_adapter.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user