status.py
python
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