base.py python
189 lines 6.4 KB
Raw
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439 feat: K1-P1 complete — agent provenance, build-verification… Sonnet 4.6 patch 53 days ago
1 """Shared adapter helpers and base protocol."""
2
3 from __future__ import annotations
4
5 import json
6 import re
7 from pathlib import Path
8 from typing import Protocol, Union
9
10 from adapters.config import OverseerConfig
11 from adapters.errors import ReadError, WriteError
12 from adapters.runner import CommandResult, CommandRunner, SubprocessRunner, quote_arg
13 from adapters.types import (
14 AnchorResult,
15 CommitResult,
16 HeadResult,
17 MirrorResult,
18 RealignResult,
19 StatusResult,
20 )
21
22 AdapterResult = Union[
23 StatusResult,
24 HeadResult,
25 AnchorResult,
26 RealignResult,
27 CommitResult,
28 MirrorResult,
29 ReadError,
30 WriteError,
31 ]
32
33
34 class VcsAdapter(Protocol):
35 """Frozen §4 adapter interface."""
36
37 def status(self) -> StatusResult | ReadError: ...
38
39 def read_head(self, ref: str) -> HeadResult | ReadError: ...
40
41 def read_canonical_anchor(self) -> AnchorResult | ReadError: ...
42
43 def realign(self, *, dry_run: bool, max_commits: int) -> RealignResult | ReadError: ...
44
45 def commit_feature(
46 self,
47 *,
48 branch: str,
49 message: str,
50 paths: list[str],
51 ) -> CommitResult | ReadError | WriteError: ...
52
53 def mirror(self, *, dry_run: bool) -> MirrorResult | ReadError: ...
54
55
56 class BaseAdapter:
57 """Common helpers for all backends."""
58
59 def __init__(
60 self,
61 config: OverseerConfig,
62 repo_root: Path,
63 runner: CommandRunner | None = None,
64 ) -> None:
65 self.config = config
66 self.repo_root = repo_root.resolve()
67 self.runner = runner or SubprocessRunner()
68 self._muse_cwd = self._resolve_muse_cwd()
69
70 def _resolve_muse_cwd(self) -> Path:
71 """Install root, or ``install_root / vcs.muse.working_dir`` (§K6.5.1)."""
72 from cli.docs_paths import validate_muse_working_dir
73
74 working = validate_muse_working_dir(self.repo_root, self.config.vcs.muse.working_dir)
75 return working if working is not None else self.repo_root
76
77 @property
78 def regime(self) -> str:
79 return self.config.vcs.regime
80
81 @property
82 def muse_cwd(self) -> Path:
83 """Absolute directory passed to ``muse -C``."""
84 return self._muse_cwd
85
86 def _git(self, *args: str) -> CommandResult | ReadError:
87 cmd = "git " + " ".join(quote_arg(a) for a in args)
88 result = self.runner.run(cmd, cwd=str(self.repo_root))
89 if not result.ok:
90 return ReadError(cmd, result.stderr or result.stdout, result.exit_code)
91 return result
92
93 def _muse(self, *args: str) -> CommandResult | ReadError:
94 cmd = (
95 "muse -C "
96 + quote_arg(str(self._muse_cwd))
97 + " "
98 + " ".join(quote_arg(a) for a in args)
99 )
100 result = self.runner.run(cmd, cwd=str(self.repo_root))
101 if not result.ok:
102 return ReadError(cmd, result.stderr or result.stdout, result.exit_code)
103 return result
104
105 def _muse_rev_parse_sha(self, ref: str) -> "HeadResult | ReadError":
106 """Resolve a muse ref to its commit id via ``muse rev-parse`` (Muse 0.2+ JSON).
107
108 Muse 0.2.x emits ``{"commit_id": "<sha>", ...}`` on stdout (exit 0) and sets
109 exit_code=1 when the ref is unknown, so ``_muse`` already fail-closes on
110 unknown refs before we even reach the JSON parser.
111 """
112 result = self._muse("rev-parse", ref)
113 if isinstance(result, ReadError):
114 return result
115 try:
116 payload = json.loads(result.stdout)
117 except json.JSONDecodeError:
118 return ReadError(f"muse rev-parse {ref}", "invalid JSON in rev-parse output")
119 if not isinstance(payload, dict):
120 return ReadError(f"muse rev-parse {ref}", "unexpected JSON shape from rev-parse")
121 commit_id = payload.get("commit_id")
122 if not isinstance(commit_id, str) or not commit_id.strip():
123 reason = payload.get("error") or "empty commit_id"
124 return ReadError(f"muse rev-parse {ref}", str(reason))
125 return HeadResult(sha=commit_id.strip(), kind="muse")
126
127 def _muse_dirty(self) -> bool | ReadError:
128 """Return Muse working-tree dirty flag (Muse 0.2+ ``status --json``; legacy ``--porcelain``)."""
129 json_result = self._muse("status", "--json")
130 if not isinstance(json_result, ReadError):
131 try:
132 payload = json.loads(json_result.stdout)
133 except json.JSONDecodeError:
134 return ReadError(
135 "muse status --json",
136 "invalid JSON from muse status",
137 )
138 if isinstance(payload, dict):
139 if "dirty" in payload:
140 return bool(payload["dirty"])
141 total = payload.get("total_changes")
142 if isinstance(total, int):
143 return total > 0
144 return ReadError("muse status --json", "missing dirty/total_changes field")
145
146 porcelain = self._muse("status", "--porcelain")
147 if isinstance(porcelain, ReadError):
148 return porcelain
149 return bool(porcelain.stdout.strip())
150
151 def _is_protected_branch(self, branch: str) -> bool:
152 git_main = self.config.vcs.git.main_branch
153 muse_main = self.config.vcs.muse.main_branch
154 normalized = branch.removeprefix("muse:").removeprefix("refs/heads/")
155 protected = {git_main}
156 if muse_main:
157 protected.add(muse_main)
158 return normalized in protected
159
160 def _validate_paths(self, paths: list[str]) -> ReadError | None:
161 for path in paths:
162 if not path or path.startswith("-") or ".." in Path(path).parts:
163 return ReadError(
164 "commit_feature",
165 f"refused unsafe path: {path!r}",
166 )
167 return None
168
169
170 BRIDGE_SHA_RE = re.compile(
171 r'^\s*git_sha\s*=\s*"([0-9a-fA-F]+)"\s*$',
172 re.MULTILINE,
173 )
174
175
176 def read_bridge_git_sha(repo_root: Path, section: str) -> str | None:
177 """Read ``git_sha`` from ``.muse/git-bridge.toml`` for ``last_export`` or ``last_import``."""
178 bridge_path = repo_root / ".muse" / "git-bridge.toml"
179 if not bridge_path.is_file():
180 return None
181 text = bridge_path.read_text(encoding="utf-8")
182 marker = f"[{section}]"
183 if marker not in text:
184 return None
185 section_text = text.split(marker, 1)[1]
186 if "\n[" in section_text:
187 section_text = section_text.split("\n[", 1)[0]
188 match = BRIDGE_SHA_RE.search(section_text)
189 return match.group(1) if match else None
File History 1 commit
sha256:4671b7f787ddbe63ced31c895b688c77ab495653b65a730b423329f26b3c1439 feat: K1-P1 complete — agent provenance, build-verification… Sonnet 4.6 patch 53 days ago