adapter.py python
241 lines 8.2 KB
Raw
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