diff --git a/src/shard_wiki/adapters/git.py b/src/shard_wiki/adapters/git.py index 6dbb82c..ddeb0d7 100644 --- a/src/shard_wiki/adapters/git.py +++ b/src/shard_wiki/adapters/git.py @@ -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 diff --git a/tests/test_git_adapter_write.py b/tests/test_git_adapter_write.py new file mode 100644 index 0000000..7a3a76b --- /dev/null +++ b/tests/test_git_adapter_write.py @@ -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()