generated from coulomb/repo-seed
feat(adapters): GitShardAdapter write=commit + current_rev drift (WP-0012 T2)
Writable mode: write(key, body) stages and commits the file (skipping a no-op so no empty commit is created), returning the page at the new commit sha. The writable profile declares WRITE + VERSION with PER_PAGE granularity. current_rev is the per-path commit sha, so a write — or an external commit to the same path — moves it, driving apply-under-drift. Passes the conformance positive-write probe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,7 @@ from shard_wiki.model import (
|
||||
Identity,
|
||||
MergeModel,
|
||||
NativeQuery,
|
||||
NotSupported,
|
||||
OperationalEnvelope,
|
||||
Page,
|
||||
Placement,
|
||||
@@ -52,9 +53,10 @@ _GIT_IDENTITY = {
|
||||
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:
|
||||
def __init__(self, shard_id: str, repo_path: str | Path, writable: bool = False) -> None:
|
||||
self._shard_id = shard_id
|
||||
self._repo = Path(repo_path)
|
||||
self._writable = writable
|
||||
self._repo.mkdir(parents=True, exist_ok=True)
|
||||
if not (self._repo / ".git").exists():
|
||||
self._git("init", "--quiet")
|
||||
@@ -64,10 +66,16 @@ class GitShardAdapter(ShardAdapter):
|
||||
return self._shard_id
|
||||
|
||||
def profile(self) -> CapabilityProfile:
|
||||
# Write is a commit; VERSION is git-native history (adopt, §A.5). Read-only drops both.
|
||||
verbs = {Verb.READ}
|
||||
granularity = WriteGranularity.NONE
|
||||
if self._writable:
|
||||
verbs |= {Verb.WRITE, Verb.VERSION}
|
||||
granularity = WriteGranularity.PER_PAGE
|
||||
return CapabilityProfile(
|
||||
substrate=Substrate.GIT,
|
||||
attachment_mode=AttachmentMode.GIT_IS_STORE,
|
||||
write_granularity=WriteGranularity.NONE,
|
||||
write_granularity=granularity,
|
||||
content_opacity=ContentOpacity.TRANSPARENT,
|
||||
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||
access_grant=AccessGrant.OPEN,
|
||||
@@ -77,9 +85,23 @@ class GitShardAdapter(ShardAdapter):
|
||||
addressing=Addressing.PATH,
|
||||
native_query=NativeQuery.NONE,
|
||||
translation=Translation.NATIVE,
|
||||
supported_verbs=frozenset({Verb.READ}),
|
||||
supported_verbs=frozenset(verbs),
|
||||
).validate()
|
||||
|
||||
def write(self, key: str, body: str) -> Page:
|
||||
"""Write = **commit**: stage the file and commit it (skip a no-op so no empty commit),
|
||||
returning the page at the new sha. Drift detection rides on ``current_rev`` = that sha."""
|
||||
if not self._writable:
|
||||
raise NotSupported(f"{type(self).__name__} is read-only")
|
||||
rel = f"{key}.md"
|
||||
path = self._path_for(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
self._git("add", "--", rel)
|
||||
if self._run("diff", "--cached", "--quiet").returncode != 0: # staged changes present
|
||||
self._git("commit", "-m", f"write {rel}", env=_GIT_IDENTITY)
|
||||
return self.read(key)
|
||||
|
||||
def keys(self) -> Iterable[str]:
|
||||
out = self._git("ls-files", "*.md").decode()
|
||||
for line in out.splitlines():
|
||||
@@ -117,11 +139,15 @@ class GitShardAdapter(ShardAdapter):
|
||||
return self._repo / f"{key}.md"
|
||||
|
||||
def _git(self, *args: str, stdin: bytes | None = None, env: dict | None = None) -> bytes:
|
||||
result = subprocess.run(
|
||||
return self._run(*args, stdin=stdin, env=env, check=True).stdout
|
||||
|
||||
def _run(
|
||||
self, *args: str, stdin: bytes | None = None, env: dict | None = None, check: bool = False
|
||||
) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(self._repo), *args],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
env={**os.environ, **(env or {})},
|
||||
check=True,
|
||||
check=check,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
89
tests/test_git_adapter_write.py
Normal file
89
tests/test_git_adapter_write.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for GitShardAdapter write=commit + current_rev drift (SHARD-WP-0012 T2)."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from shard_wiki.adapters import GitShardAdapter, run_conformance
|
||||
from shard_wiki.model import Verb
|
||||
|
||||
_ENV = {
|
||||
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
||||
"PATH": os.environ.get("PATH", ""),
|
||||
}
|
||||
|
||||
|
||||
def _git(repo, *args, capture=False):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *args], check=True, capture_output=True, text=True, env=_ENV
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _repo(tmp_path, files):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "--quiet")
|
||||
for rel, text in files.items():
|
||||
(repo / rel).write_text(text, encoding="utf-8")
|
||||
_git(repo, "add", rel)
|
||||
_git(repo, "commit", "-m", "seed")
|
||||
return repo
|
||||
|
||||
|
||||
def test_writable_profile_declares_write_and_version(tmp_path):
|
||||
profile = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "h"}), writable=True).profile()
|
||||
assert profile.supports(Verb.WRITE)
|
||||
assert profile.supports(Verb.VERSION)
|
||||
profile.validate() # PER_PAGE + WRITE is a consistent combination
|
||||
|
||||
|
||||
def test_write_creates_a_commit(tmp_path):
|
||||
repo = _repo(tmp_path, {"Home.md": "old"})
|
||||
adapter = GitShardAdapter("git", repo, writable=True)
|
||||
before = _git(repo, "rev-list", "--count", "HEAD")
|
||||
page = adapter.write("Home", "new body")
|
||||
after = _git(repo, "rev-list", "--count", "HEAD")
|
||||
assert int(after) == int(before) + 1 # one new commit
|
||||
assert page.body == "new body"
|
||||
assert page.envelope.source_rev == _git(repo, "rev-parse", "HEAD") # page is at the new sha
|
||||
|
||||
|
||||
def test_write_advances_current_rev(tmp_path):
|
||||
repo = _repo(tmp_path, {"Home.md": "old"})
|
||||
adapter = GitShardAdapter("git", repo, writable=True)
|
||||
rev_before = adapter.current_rev("Home")
|
||||
adapter.write("Home", "changed")
|
||||
assert adapter.current_rev("Home") != rev_before # sha moved → drift detectable
|
||||
|
||||
|
||||
def test_write_new_key_tracks_it(tmp_path):
|
||||
repo = _repo(tmp_path, {"Home.md": "h"})
|
||||
adapter = GitShardAdapter("git", repo, writable=True)
|
||||
adapter.write("docs/New", "fresh page")
|
||||
assert "docs/New" in set(adapter.keys())
|
||||
assert adapter.read("docs/New").body == "fresh page"
|
||||
|
||||
|
||||
def test_noop_write_creates_no_empty_commit(tmp_path):
|
||||
repo = _repo(tmp_path, {"Home.md": "same"})
|
||||
adapter = GitShardAdapter("git", repo, writable=True)
|
||||
before = _git(repo, "rev-list", "--count", "HEAD")
|
||||
adapter.write("Home", "same") # identical body → nothing to commit
|
||||
assert _git(repo, "rev-list", "--count", "HEAD") == before
|
||||
|
||||
|
||||
def test_current_rev_reflects_external_commit(tmp_path):
|
||||
repo = _repo(tmp_path, {"Home.md": "h"})
|
||||
adapter = GitShardAdapter("git", repo, writable=True)
|
||||
rev = adapter.current_rev("Home")
|
||||
# An out-of-band commit to the same path (another writer) moves the per-path sha.
|
||||
(repo / "Home.md").write_text("externally edited", encoding="utf-8")
|
||||
_git(repo, "add", "Home.md")
|
||||
_git(repo, "commit", "-m", "external")
|
||||
assert adapter.current_rev("Home") != rev
|
||||
|
||||
|
||||
def test_conformance_positive_write_probe_passes(tmp_path):
|
||||
adapter = GitShardAdapter("git", _repo(tmp_path, {"Home.md": "body"}), writable=True)
|
||||
report = run_conformance(adapter)
|
||||
assert report.ok, report.diff()
|
||||
Reference in New Issue
Block a user