gabriel / muse public
status.py python
264 lines 8.5 KB
Raw
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 13 days ago
1 """``muse.core.bridge.status`` — Bridge status reporting.
2
3 Decomposed from ``muse.cli.commands.bridge`` (issue #14).
4
5 Exports
6 -------
7 run_git_status Entry point for ``muse bridge git-status``.
8 _compute_drift Compute git/Muse commit drift since last sync.
9 _count_muse_commits_since Walk Muse DAG to count commits since a ref.
10 _print_bridge_status_text Human-readable status output.
11 """
12
13 from __future__ import annotations
14
15 import argparse
16 import json
17 import pathlib
18 import sys
19 from typing import Any
20
21 from muse.core.bridge.git_primitives import _git, _validate_git_sha
22 from muse.core.bridge.state import BridgeState, DriftInfo, read_bridge_state
23 from muse.core.errors import ExitCode
24 from muse.core.paths import head_path, ref_path
25
26
27 def run_git_status(args: argparse.Namespace) -> None:
28 """Entry point for ``muse bridge git-status``.
29
30 Reads ``.muse/git-bridge.toml`` and reports the last import and export
31 sync points, plus drift counts (commits in git since last import, commits
32 in Muse since last export).
33
34 Args:
35 args: Parsed argparse namespace; see :func:`register` for flag definitions.
36
37 Raises:
38 SystemExit(ExitCode.USER_ERROR): No Muse repository found.
39 """
40 from muse.core.repo import find_repo_root
41
42 root = find_repo_root()
43 json_out: bool = args.json_out
44
45 if root is None:
46 msg = "No Muse repository found. Run 'muse init' first."
47 if json_out:
48 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
49 else:
50 print(f"❌ {msg}", file=sys.stderr)
51 raise SystemExit(ExitCode.USER_ERROR)
52
53 state = read_bridge_state(root)
54
55 git_dir_arg = getattr(args, "git_dir", None)
56 drift: DriftInfo | None = None
57 if git_dir_arg:
58 git_dir = pathlib.Path(git_dir_arg).resolve()
59 if (git_dir / ".git").exists():
60 drift = _compute_drift(root, git_dir, state)
61
62 if json_out:
63 payload = {
64 "last_import": dict(state["last_import"]),
65 "last_export": dict(state["last_export"]),
66 }
67 if drift is not None:
68 payload["drift"] = drift
69 print(json.dumps(payload))
70 else:
71 _print_bridge_status_text(state, drift)
72
73
74 def _compute_drift(
75 root: pathlib.Path,
76 git_dir: pathlib.Path,
77 state: "BridgeState",
78 ) -> DriftInfo:
79 """Compute commit drift between git and Muse since the last bridge sync.
80
81 Returns a dict with two keys:
82
83 * ``git_commits_since_import``: Number of git commits on HEAD that come
84 after ``last_import.git_sha``. ``None`` when no import state exists.
85 * ``muse_commits_since_export``: Number of Muse commits on the current
86 HEAD branch that come after ``last_export.muse_commit_id``. ``None``
87 when no export state exists.
88
89 Args:
90 root: Muse repository root.
91 git_dir: Path to the git repository for git-side drift counting.
92 state: Current :class:`BridgeState`.
93
94 Returns:
95 Dict with ``git_commits_since_import`` and ``muse_commits_since_export``
96 integer (or ``None``) values.
97 """
98 result: DriftInfo = {
99 "git_commits_since_import": None,
100 "muse_commits_since_export": None,
101 }
102
103 # --- Git side: count commits since last import SHA ---
104 li = state.get("last_import") or {}
105 last_git_sha = li.get("git_sha", "")
106 if last_git_sha and _validate_git_sha(last_git_sha):
107 try:
108 raw = _git(
109 git_dir,
110 "rev-list",
111 f"{last_git_sha}..HEAD",
112 "--count",
113 check=True,
114 ).strip()
115 result["git_commits_since_import"] = int(raw)
116 except Exception: # noqa: BLE001
117 result["git_commits_since_import"] = None
118
119 # --- Muse side: walk commit chain since last export muse_commit_id ---
120 le = state.get("last_export") or {}
121 last_muse_cid = le.get("muse_commit_id", "")
122 if last_muse_cid:
123 result["muse_commits_since_export"] = _count_muse_commits_since(
124 root, last_muse_cid
125 )
126
127 return result
128
129
130 def _count_muse_commits_since(root: pathlib.Path, since_commit_id: str) -> int | None:
131 """Count Muse commits on HEAD since *since_commit_id*.
132
133 Walks the Muse commit DAG backwards from HEAD through ``parent_commit_id``
134 until it reaches *since_commit_id* or a commit with no parent. Uses the
135 store's ``read_commit`` so the path and JSON format are always correct.
136
137 Args:
138 root: Muse repository root.
139 since_commit_id: ``sha256:``-prefixed commit ID to stop counting at.
140
141 Returns:
142 Number of commits after *since_commit_id*, or ``None`` on any error.
143 """
144 from muse.core.commits import read_commit as _read_commit
145
146 try:
147 _head_file = head_path(root)
148 if not _head_file.exists():
149 return None
150 head_content = _head_file.read_text().strip()
151 if head_content.startswith("ref: refs/heads/"):
152 branch = head_content[len("ref: refs/heads/"):]
153 _branch_ref = ref_path(root, branch)
154 if not _branch_ref.exists():
155 return None
156 current_id = _branch_ref.read_text().strip()
157 else:
158 current_id = head_content
159
160 if not current_id:
161 return None
162
163 count = 0
164 visited: set[str] = set()
165
166 while current_id and current_id != since_commit_id:
167 if current_id in visited:
168 break # cycle guard
169 visited.add(current_id)
170
171 record = _read_commit(root, current_id)
172 if record is None:
173 break
174
175 count += 1
176 current_id = record.parent_commit_id or ""
177
178 return count
179
180 except Exception: # noqa: BLE001
181 return None
182
183
184 def _print_bridge_status_text(
185 state: "BridgeState",
186 drift: "dict[str, Any] | None" = None,
187 ) -> None:
188 """Print a human-readable bridge status summary to stdout."""
189 SEP = "─" * 50
190 print("Muse Bridge Status")
191 print(SEP)
192
193 li = state["last_import"]
194 git_behind = drift.get("git_commits_since_import") if drift else None
195 if li:
196 print("Last git-import:")
197 print(f" git SHA: {li.get('git_sha', '(none)')[:16]}…")
198 print(f" git ref: {li.get('git_ref', '(none)')}")
199 print(f" muse commit: {li.get('muse_commit_id', '(none)')[:23]}…")
200 print(f" imported: {li.get('imported_at', '(none)')}")
201 if git_behind is not None:
202 print(f" commits behind: {git_behind}")
203 else:
204 print("Last git-import: (none)")
205
206 print()
207 le = state["last_export"]
208 muse_ahead = drift.get("muse_commits_since_export") if drift else None
209 if le:
210 print("Last git-export:")
211 print(f" muse commit: {le.get('muse_commit_id', '(none)')[:23]}…")
212 print(f" git ref: {le.get('git_ref', '(none)')}")
213 print(f" exported: {le.get('exported_at', '(none)')}")
214 if muse_ahead is not None:
215 print(f" commits ahead: {muse_ahead}")
216 else:
217 print("Last git-export: (none)")
218
219 if drift is not None:
220 print()
221 print("Drift:")
222 m_ahead = drift.get("muse_commits_since_export")
223 g_behind = drift.get("git_commits_since_import")
224 if m_ahead is not None:
225 suffix = "commit" if m_ahead == 1 else "commits"
226 print(f" Muse→Git: {m_ahead} {suffix} to export")
227 else:
228 print(" Muse→Git: (unknown)")
229 if g_behind is not None:
230 suffix = "commit" if g_behind == 1 else "commits"
231 print(f" Git→Muse: {g_behind} {suffix} to import")
232 else:
233 print(" Git→Muse: (unknown)")
234
235
236 def _register_git_status_parser(
237 subs: "argparse._SubParsersAction[argparse.ArgumentParser]",
238 ) -> None:
239 """Register the ``git-status`` subcommand onto *subs*."""
240 import argparse as _ap
241
242 p = subs.add_parser(
243 "git-status",
244 help="Show bridge state: last import/export and drift counts.",
245 formatter_class=_ap.RawDescriptionHelpFormatter,
246 description=(
247 "Display the current Muse bridge state — what was last imported "
248 "and exported, and how far ahead/behind each repo is."
249 ),
250 )
251 p.add_argument(
252 "--git-dir",
253 default=None,
254 metavar="PATH",
255 help="Path to the git repository for drift calculation.",
256 )
257 p.add_argument(
258 "--json", "-j",
259 dest="json_out",
260 action="store_true",
261 default=False,
262 help="Emit machine-readable JSON.",
263 )
264 p.set_defaults(func=run_git_status)
File History 2 commits
sha256:ac29b8ba9021514a03ab2782d92bf67671f0efa5b3b70d46f7598c5d4e923378 docs: record muse#74 Phase 4 live verification (SCR_13, SCR_14) Sonnet 5 13 days ago
sha256:2562dffa0a0822ac1bdea854f9b267843c6ce95b497a9dc5c55837c80a3ebd0a feat: domain_command_registry — Phase 1 of muse#74 (SCR_01-03) Sonnet 4.6 patch 13 days ago