adapter.py
python
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
9 hours ago
| 1 | """Muse+git-mirror VCS adapter backend (§4, SD-14).""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from adapters.base import ( |
| 6 | BaseAdapter, |
| 7 | bridge_section_present, |
| 8 | read_bridge_git_sha, |
| 9 | read_bridge_muse_commit_id, |
| 10 | ) |
| 11 | from adapters.errors import ReadError, WriteError |
| 12 | from adapters.types import ( |
| 13 | AnchorResult, |
| 14 | CommitResult, |
| 15 | HeadResult, |
| 16 | MirrorResult, |
| 17 | RealignResult, |
| 18 | StatusResult, |
| 19 | ) |
| 20 | |
| 21 | |
| 22 | class MuseGitMirrorAdapter(BaseAdapter): |
| 23 | """Scooling/Knowtation backend — Muse canonical with GitHub mirror.""" |
| 24 | |
| 25 | def status(self) -> StatusResult | ReadError: |
| 26 | muse_branch = self._muse("rev-parse", "--abbrev-ref", "HEAD") |
| 27 | if isinstance(muse_branch, ReadError): |
| 28 | return muse_branch |
| 29 | muse_dirty = self._muse_dirty() |
| 30 | if isinstance(muse_dirty, ReadError): |
| 31 | return muse_dirty |
| 32 | git_branch = self._git("rev-parse", "--abbrev-ref", "HEAD") |
| 33 | if isinstance(git_branch, ReadError): |
| 34 | return git_branch |
| 35 | git_dirty = self._git("status", "--porcelain") |
| 36 | if isinstance(git_dirty, ReadError): |
| 37 | return git_dirty |
| 38 | |
| 39 | notes = [ |
| 40 | "canonical=muse", |
| 41 | f"git-branch={git_branch.stdout}", |
| 42 | "sd-14: never git push origin main", |
| 43 | ] |
| 44 | git_is_dirty = bool(git_dirty.stdout.strip()) |
| 45 | dirty = muse_dirty or git_is_dirty |
| 46 | return StatusResult( |
| 47 | regime=self.regime, |
| 48 | dirty=dirty, |
| 49 | branch=muse_branch.stdout, |
| 50 | notes=notes, |
| 51 | muse_dirty=muse_dirty, |
| 52 | git_dirty=git_is_dirty, |
| 53 | ) |
| 54 | |
| 55 | def read_head(self, ref: str) -> HeadResult | ReadError: |
| 56 | if ref.startswith("muse:") or ref.startswith("sha256:"): |
| 57 | muse_ref = ref.removeprefix("muse:") |
| 58 | return self._muse_rev_parse_sha(muse_ref) |
| 59 | |
| 60 | result = self._git("rev-parse", ref) |
| 61 | if isinstance(result, ReadError): |
| 62 | return result |
| 63 | sha = result.stdout.strip() |
| 64 | if not sha: |
| 65 | return ReadError(f"git rev-parse {ref}", "empty sha") |
| 66 | return HeadResult(sha=sha, kind="git") |
| 67 | |
| 68 | def read_canonical_anchor(self) -> AnchorResult | ReadError: |
| 69 | """Return the Muse-space bridge tip for D2 (§D2F.4.2). |
| 70 | |
| 71 | When ``[last_export]`` exists, R2 is ``muse_commit_id`` (same ID space as Muse |
| 72 | tips). ``git_sha`` is intentionally not returned here — realign uses |
| 73 | ``read_bridge_git_sha`` for ``from_ref`` / ancestry only. |
| 74 | """ |
| 75 | if bridge_section_present(self.repo_root, "last_export"): |
| 76 | muse_id = read_bridge_muse_commit_id(self.repo_root, "last_export") |
| 77 | if muse_id: |
| 78 | return AnchorResult( |
| 79 | anchor_sha=muse_id, |
| 80 | source=".muse/git-bridge.toml:last_export.muse_commit_id", |
| 81 | ) |
| 82 | return ReadError( |
| 83 | "read_canonical_anchor", |
| 84 | "missing last_export.muse_commit_id", |
| 85 | ) |
| 86 | |
| 87 | mirror = self.config.vcs.git.mirror_branch |
| 88 | remote = self.config.vcs.git.remote |
| 89 | if mirror: |
| 90 | ref = f"{remote}/{mirror}" |
| 91 | head = self.read_head(ref) |
| 92 | if not isinstance(head, ReadError): |
| 93 | return AnchorResult(anchor_sha=head.sha, source=ref) |
| 94 | |
| 95 | return ReadError( |
| 96 | "read_canonical_anchor", |
| 97 | "no bridge anchor in .muse/git-bridge.toml or mirror ref", |
| 98 | ) |
| 99 | |
| 100 | def realign(self, *, dry_run: bool, max_commits: int) -> RealignResult | ReadError: |
| 101 | from_ref = read_bridge_git_sha(self.repo_root, "last_import") |
| 102 | if not from_ref: |
| 103 | from_ref = read_bridge_git_sha(self.repo_root, "last_export") |
| 104 | if not from_ref: |
| 105 | return ReadError( |
| 106 | "realign", |
| 107 | "cannot determine from_ref (missing .muse/git-bridge.toml anchor)", |
| 108 | ) |
| 109 | |
| 110 | remote = self.config.vcs.git.remote |
| 111 | main = self.config.vcs.git.main_branch |
| 112 | to_ref = f"{remote}/{main}" |
| 113 | to_head = self.read_head(to_ref) |
| 114 | if isinstance(to_head, ReadError): |
| 115 | return to_head |
| 116 | |
| 117 | count_cmd = self._git( |
| 118 | "rev-list", |
| 119 | "--count", |
| 120 | f"{from_ref}..{to_head.sha}", |
| 121 | ) |
| 122 | if isinstance(count_cmd, ReadError): |
| 123 | return count_cmd |
| 124 | try: |
| 125 | would_import = int(count_cmd.stdout.strip()) |
| 126 | except ValueError: |
| 127 | return ReadError(count_cmd.stdout, "invalid rev-list count") |
| 128 | |
| 129 | if would_import > max_commits: |
| 130 | return RealignResult( |
| 131 | would_import=would_import, |
| 132 | applied=False, |
| 133 | from_ref=from_ref, |
| 134 | to_ref=to_head.sha, |
| 135 | reason=f"exceeds max_commits ({max_commits})", |
| 136 | ) |
| 137 | |
| 138 | if dry_run: |
| 139 | return RealignResult( |
| 140 | would_import=would_import, |
| 141 | applied=False, |
| 142 | from_ref=from_ref, |
| 143 | to_ref=to_head.sha, |
| 144 | reason="dry-run", |
| 145 | ) |
| 146 | |
| 147 | muse_main = self.config.vcs.muse.main_branch or "main" |
| 148 | import_cmd = self._muse( |
| 149 | "bridge", |
| 150 | "git-import", |
| 151 | ".", |
| 152 | "--branch", |
| 153 | muse_main, |
| 154 | "--from-ref", |
| 155 | from_ref, |
| 156 | "--incremental", |
| 157 | "--preserve-merge-commits", |
| 158 | ) |
| 159 | if isinstance(import_cmd, ReadError): |
| 160 | return import_cmd |
| 161 | |
| 162 | return RealignResult( |
| 163 | would_import=would_import, |
| 164 | applied=True, |
| 165 | from_ref=from_ref, |
| 166 | to_ref=to_head.sha, |
| 167 | ) |
| 168 | |
| 169 | def commit_feature( |
| 170 | self, |
| 171 | *, |
| 172 | branch: str, |
| 173 | message: str, |
| 174 | paths: list[str], |
| 175 | ) -> CommitResult | ReadError | WriteError: |
| 176 | unsafe = self._validate_paths(paths) |
| 177 | if unsafe: |
| 178 | return unsafe |
| 179 | if self._is_protected_branch(branch): |
| 180 | return WriteError( |
| 181 | "commit_feature", |
| 182 | f"refused protected branch {branch!r}", |
| 183 | ) |
| 184 | |
| 185 | # §GSW.6.1: skip checkout when Muse HEAD is already on the branch — |
| 186 | # the engine's dual-HEAD ensure (§GSW.5.1) ran before doc writes, so |
| 187 | # a dirty tree here must not fail the commit. |
| 188 | current = self._muse("rev-parse", "--abbrev-ref", "HEAD") |
| 189 | if isinstance(current, ReadError): |
| 190 | return current |
| 191 | if current.stdout.strip() != branch: |
| 192 | # §GSW.6.2: bare checkout refuses dirty tracked files (Muse 0.2.x); |
| 193 | # retry with --autoshelf to carry them. --force is forbidden. |
| 194 | checkout = self._muse("checkout", branch) |
| 195 | if isinstance(checkout, ReadError): |
| 196 | checkout = self._muse("checkout", "--autoshelf", branch) |
| 197 | if isinstance(checkout, ReadError): |
| 198 | return checkout |
| 199 | |
| 200 | current = self._muse("rev-parse", "--abbrev-ref", "HEAD") |
| 201 | if isinstance(current, ReadError): |
| 202 | return current |
| 203 | if current.stdout.strip() != branch: |
| 204 | return WriteError( |
| 205 | "commit_feature", |
| 206 | f"branch mismatch after checkout: {current.stdout!r}", |
| 207 | ) |
| 208 | |
| 209 | if paths: |
| 210 | for path in paths: |
| 211 | # Muse 0.2.x has no top-level `add`; staging is `muse code add` |
| 212 | # (matches the KH2 remediation string). Found live in GSW land-b. |
| 213 | add = self._muse("code", "add", path) |
| 214 | if isinstance(add, ReadError): |
| 215 | return add |
| 216 | |
| 217 | commit = self._muse("commit", "-m", message) |
| 218 | if isinstance(commit, ReadError): |
| 219 | return commit |
| 220 | |
| 221 | head = self._muse_rev_parse_sha("HEAD") |
| 222 | if isinstance(head, ReadError): |
| 223 | return head |
| 224 | return CommitResult(committed=True, sha=head.sha) |
| 225 | |
| 226 | def mirror(self, *, dry_run: bool) -> MirrorResult | ReadError: |
| 227 | status = self._muse("bridge", "git-status") |
| 228 | if isinstance(status, ReadError): |
| 229 | return status |
| 230 | diff_summary = status.stdout or status.stderr |
| 231 | if dry_run: |
| 232 | return MirrorResult( |
| 233 | diff_summary=diff_summary, |
| 234 | pushed=False, |
| 235 | reason="dry-run", |
| 236 | ) |
| 237 | return MirrorResult( |
| 238 | diff_summary=diff_summary, |
| 239 | pushed=False, |
| 240 | reason="operator-authorization-required", |
| 241 | ) |
File History
2 commits
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1
docs: MuseHub-first before ISR #74 — staging solidify NEXT
Human
9 hours ago
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439
feat: K1-P1 complete — agent provenance, build-verification…
Sonnet 4.6
patch
52 days ago