gabriel / muse public
status.py python
376 lines 12.8 KB
45033f17 feat: CLI polish — diff model, log colours, commit summary, push fix Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse status \033[1m—\033[0m show working-tree drift against HEAD.
2
3 Output modes
4 ------------
5
6 Default (color when stdout is a TTY)::
7
8 On branch main
9 Your branch is up to date with 'origin/main'.
10
11 Changes since last commit:
12 (use "muse commit -m <msg>" to record changes)
13
14 \033[1m\033[33m modified:\033[0m tracks/drums.mid
15 \033[1m\033[32m new file:\033[0m tracks/lead.mp3
16 \033[1m\033[31m deleted:\033[0m tracks/scratch.mid
17 \033[1m\033[36m renamed:\033[0m tracks/old.mid → tracks/new.mid
18
19 --short (color letter prefix when stdout is a TTY)::
20
21 \033[1m\033[33mM\033[0m tracks/drums.mid
22 \033[1m\033[32mA\033[0m tracks/lead.mp3
23 \033[1m\033[31mD\033[0m tracks/scratch.mid
24 \033[1m\033[36mR\033[0m tracks/old.mid → tracks/new.mid
25
26 --porcelain (machine-readable, stable for scripting — no color ever)::
27
28 ## main
29 M tracks/drums.mid
30 A tracks/lead.mp3
31 D tracks/scratch.mid
32 R tracks/old.mid → tracks/new.mid
33
34 Color convention
35 ----------------
36 \033[1m\033[33myellow\033[0m modified — file exists in both old and new snapshot, content changed
37 \033[1m\033[32mgreen\033[0m new file — file is new, not present in last commit
38 \033[1m\033[31mred\033[0m deleted — file was removed since last commit
39 \033[1m\033[36mcyan\033[0m renamed — file was moved or renamed since last commit
40 """
41
42 from __future__ import annotations
43
44 import argparse
45 import json
46 import logging
47 import pathlib
48 import sys
49
50 from muse.cli.config import get_remote_head, get_upstream
51 from muse.core.errors import ExitCode
52 from muse.core.repo import require_repo
53 from muse.core.store import (
54 get_head_commit_id,
55 get_head_snapshot_manifest,
56 read_current_branch,
57 walk_commits_between,
58 )
59 from muse.domain import SnapshotManifest, StagePlugin
60 from muse.plugins.registry import resolve_plugin
61
62 logger = logging.getLogger(__name__)
63
64 _YELLOW = "\033[33m"
65 _GREEN = "\033[32m"
66 _RED = "\033[31m"
67 _CYAN = "\033[36m"
68 _BOLD = "\033[1m"
69 _RESET = "\033[0m"
70
71
72 def _color(text: str, ansi: str, is_tty: bool) -> str:
73 return f"{_BOLD}{ansi}{text}{_RESET}" if is_tty else text
74
75
76 def _tracking_line(
77 root: pathlib.Path,
78 branch: str,
79 upstream: str,
80 ) -> str:
81 """Return the upstream tracking status line, e.g. 'Your branch is up to date with origin/branch'.
82
83 Compares local HEAD commit against the last-known remote HEAD commit (written
84 after every push / pull / fetch). Returns a plain string; the caller decides
85 whether to print it. Returns an empty string when there is no recorded remote
86 HEAD yet (first push not yet done).
87
88 The tracking ref is shown as ``remote/branch`` (e.g. ``origin/feat/my-feature``)
89 so it is unambiguous when multiple remotes are configured.
90 """
91 remote_head = get_remote_head(upstream, branch, root)
92 tracking_ref = f"{upstream}/{branch}"
93
94 if not remote_head:
95 return f"Tracking: {tracking_ref} (not yet pushed)"
96
97 local_head = get_head_commit_id(root, branch)
98 if not local_head:
99 return f"Tracking: {tracking_ref}"
100
101 if local_head == remote_head:
102 return f"Your branch is up to date with '{tracking_ref}'."
103
104 ahead = len(walk_commits_between(root, local_head, remote_head))
105 behind = len(walk_commits_between(root, remote_head, local_head))
106
107 if ahead and behind:
108 return (
109 f"Your branch and '{tracking_ref}' have diverged, "
110 f"and have {ahead} and {behind} different commits each."
111 )
112 if ahead:
113 suffix = "commit" if ahead == 1 else "commits"
114 return f"Your branch is ahead of '{tracking_ref}' by {ahead} {suffix}."
115 if behind:
116 suffix = "commit" if behind == 1 else "commits"
117 return f"Your branch is behind '{tracking_ref}' by {behind} {suffix}."
118 return f"Your branch is up to date with '{tracking_ref}'."
119
120
121 def _read_repo_meta(root: pathlib.Path) -> tuple[str, str]:
122 """Read ``.muse/repo.json`` once and return ``(repo_id, domain)``.
123
124 Returns sensible defaults on any read or parse failure rather than
125 propagating an unhandled exception to the user. The caller never needs
126 to guard against a missing or corrupt ``repo.json`` — status degrades
127 gracefully to an empty diff in the worst case.
128
129 """
130 repo_json = root / ".muse" / "repo.json"
131 try:
132 data = json.loads(repo_json.read_text(encoding="utf-8"))
133 repo_id_raw = data.get("repo_id", "")
134 repo_id = str(repo_id_raw) if isinstance(repo_id_raw, str) and repo_id_raw else ""
135 domain_raw = data.get("domain", "")
136 domain = str(domain_raw) if isinstance(domain_raw, str) and domain_raw else "midi"
137 return repo_id, domain
138 except (OSError, json.JSONDecodeError):
139 return "", "midi"
140
141
142 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
143 """Register the status subcommand."""
144 parser = subparsers.add_parser(
145 "status",
146 help="Show working-tree drift against HEAD.",
147 description=__doc__,
148 formatter_class=argparse.RawDescriptionHelpFormatter,
149 )
150 parser.add_argument("--short", "-s", action="store_true", help="Condensed output.")
151 parser.add_argument("--porcelain", action="store_true", help="Machine-readable output (no color).")
152 parser.add_argument("--branch", "-b", action="store_true", dest="branch_only", help="Show branch info only.")
153 parser.add_argument("--format", "-f", dest="fmt", default="text", metavar="FORMAT", help="Output format: text or json.")
154 parser.set_defaults(func=run)
155
156
157 def run(args: argparse.Namespace) -> None:
158 """Show working-tree drift against HEAD."""
159 from muse.core.validation import sanitize_display as _sd
160
161 fmt: str = args.fmt
162 short: bool = args.short
163 porcelain: bool = args.porcelain
164 branch_only: bool = args.branch_only
165
166 if fmt not in ("text", "json"):
167 print(f"❌ Unknown --format '{_sd(fmt)}'. Choose text or json.", file=sys.stderr)
168 raise SystemExit(ExitCode.USER_ERROR)
169
170 root = require_repo()
171 try:
172 branch = read_current_branch(root)
173 except ValueError as exc:
174 print(f"fatal: {exc}", file=sys.stderr)
175 raise SystemExit(ExitCode.USER_ERROR)
176
177 repo_id, domain = _read_repo_meta(root)
178
179 upstream = get_upstream(branch, root)
180
181 if fmt != "json":
182 if porcelain:
183 print(f"## {branch}")
184 elif not short:
185 print(f"On branch {branch}")
186 if upstream:
187 print(_tracking_line(root, branch, upstream))
188
189 if branch_only:
190 if fmt == "json":
191 print(json.dumps({"branch": branch, "upstream": upstream}))
192 return
193
194 is_tty = sys.stdout.isatty() and not porcelain and fmt != "json"
195
196 plugin = resolve_plugin(root)
197
198 # If the active plugin supports staging and a stage is active, show the
199 # three-bucket Git-style view instead of the simple drift report.
200 # Load head_manifest only after this check — stage_status() loads it
201 # internally, so loading it here too would be a redundant disk read.
202 if isinstance(plugin, StagePlugin) and plugin.stage_index_path(root).exists():
203 _render_staged_status(root, plugin, branch, fmt, short, porcelain, is_tty)
204 return
205
206 head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
207 committed_snap = SnapshotManifest(files=head_manifest, domain=domain)
208 report = plugin.drift(committed_snap, root)
209 delta = report.delta
210
211 added: set[str] = set()
212 modified: set[str] = set()
213 deleted: set[str] = set()
214 renamed: dict[str, str] = {} # old_path -> new_path
215
216 for op in delta["ops"]:
217 op_type = op["op"]
218 addr = op["address"]
219 if op_type == "insert":
220 added.add(addr)
221 elif op_type == "delete":
222 deleted.add(addr)
223 elif op_type == "replace":
224 modified.add(addr)
225 elif op_type == "patch":
226 from_addr = op.get("from_address")
227 if from_addr:
228 renamed[str(from_addr)] = addr
229 else:
230 modified.add(addr)
231
232 clean = not (added or modified or deleted or renamed)
233
234 if fmt == "json":
235 print(json.dumps({
236 "branch": branch,
237 "upstream": upstream,
238 "clean": clean,
239 "added": sorted(added),
240 "modified": sorted(modified),
241 "deleted": sorted(deleted),
242 "renamed": renamed,
243 }))
244 return
245
246 if clean:
247 if not short and not porcelain:
248 print("\nNothing to commit, working tree clean")
249 return
250
251 if porcelain:
252 for p in sorted(modified):
253 print(f" M {p}")
254 for p in sorted(added):
255 print(f" A {p}")
256 for p in sorted(deleted):
257 print(f" D {p}")
258 for old, new in sorted(renamed.items()):
259 print(f" R {old} → {new}")
260 return
261
262 if short:
263 for p in sorted(modified):
264 print(f" {_color('M', _YELLOW, is_tty)} {p}")
265 for p in sorted(added):
266 print(f" {_color('A', _GREEN, is_tty)} {p}")
267 for p in sorted(deleted):
268 print(f" {_color('D', _RED, is_tty)} {p}")
269 for old, new in sorted(renamed.items()):
270 print(f" {_color('R', _CYAN, is_tty)} {old} → {new}")
271 return
272
273 print("\nChanges since last commit:")
274 print(' (use "muse commit -m <msg>" to record changes)\n')
275 for p in sorted(modified):
276 print(f"\t{_color(' modified:', _YELLOW, is_tty)} {p}")
277 for p in sorted(added):
278 print(f"\t{_color(' new file:', _GREEN, is_tty)} {p}")
279 for p in sorted(deleted):
280 print(f"\t{_color(' deleted:', _RED, is_tty)} {p}")
281 for old, new in sorted(renamed.items()):
282 print(f"\t{_color(' renamed:', _CYAN, is_tty)} {old} → {new}")
283
284
285 def _render_staged_status(
286 root: pathlib.Path,
287 plugin: StagePlugin,
288 branch: str,
289 fmt: str,
290 short: bool,
291 porcelain: bool,
292 is_tty: bool,
293 ) -> None:
294 """Render the three-bucket staged/unstaged/untracked view.
295
296 Displayed when the active plugin implements :class:`StagePlugin` and a
297 stage index is present. Mirrors ``git status`` output exactly.
298 """
299 status = plugin.stage_status(root)
300 staged = status["staged"]
301 unstaged = status["unstaged"]
302 untracked = status["untracked"]
303
304 clean = not staged and not unstaged and not untracked
305
306 _MODE_LABEL: dict[str, str] = {
307 "A": "new file",
308 "M": "modified",
309 "D": "deleted",
310 }
311
312 if fmt == "json":
313 print(json.dumps({
314 "branch": branch,
315 "clean": clean,
316 "staged": {
317 p: {"mode": e["mode"], "object_id": e["object_id"]}
318 for p, e in staged.items()
319 },
320 "unstaged": unstaged,
321 "untracked": untracked,
322 }))
323 return
324
325 if porcelain:
326 for p, entry in sorted(staged.items()):
327 print(f"{entry['mode']} {p}")
328 for p, label in sorted(unstaged.items()):
329 pu_letter = "M" if label == "modified" else "D"
330 print(f" {pu_letter} {p}")
331 for p in untracked:
332 print(f"?? {p}")
333 return
334
335 if short:
336 for p, entry in sorted(staged.items()):
337 s_mode = entry["mode"]
338 color = _GREEN if s_mode == "A" else _YELLOW if s_mode == "M" else _RED
339 print(f"{_color(s_mode, color, is_tty)} {p}")
340 for p, label in sorted(unstaged.items()):
341 u_letter = "M" if label == "modified" else "D"
342 u_color = _YELLOW if label == "modified" else _RED
343 print(f" {_color(u_letter, u_color, is_tty)} {p}")
344 for p in untracked:
345 print(f"?? {p}")
346 return
347
348 # Long form — mirrors git status exactly.
349 if staged:
350 print("\nChanges staged for commit:")
351 print(' (use "muse code reset HEAD <file>" to unstage)\n')
352 for p, entry in sorted(staged.items()):
353 label = _MODE_LABEL.get(entry["mode"], entry["mode"])
354 color = _GREEN if entry["mode"] == "A" else _YELLOW if entry["mode"] == "M" else _RED
355 pad = max(0, 10 - len(label))
356 print(f"\t{_color(label + ':', color, is_tty)}{' ' * pad} {p}")
357
358 if unstaged:
359 print("\nChanges not staged for commit:")
360 print(' (use "muse code add <file>" to update what will be committed)\n')
361 for p, label in sorted(unstaged.items()):
362 color = _YELLOW if label == "modified" else _RED
363 pad = max(0, 10 - len(label))
364 print(f"\t{_color(label + ':', color, is_tty)}{' ' * pad} {p}")
365
366 if untracked:
367 print("\nUntracked files:")
368 print(' (use "muse code add <file>" to include in what will be committed)\n')
369 for p in untracked:
370 print(f"\t{p}")
371
372 if clean:
373 print("\nNothing to commit, working tree clean")
374
375 if staged:
376 print() # trailing newline after last section