gabriel / muse public
status.py python
841 lines 33.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """muse status — 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 modified: tracks/drums.mid
15 new file: tracks/lead.mp3
16 deleted: tracks/scratch.mid
17 renamed: tracks/old.mid → tracks/new.mid
18
19 --short (color letter prefix when stdout is a TTY)::
20
21 M tracks/drums.mid
22 A tracks/lead.mp3
23 D tracks/scratch.mid
24 R tracks/old.mid → tracks/new.mid
25
26 --json (agent-native, always stable)::
27
28 {
29 "branch": "main",
30 "head_commit": "sha256:abc123…",
31 "upstream": null,
32 "ahead": null,
33 "behind": null,
34 "clean": true,
35 "dirty": false,
36 "total_changes": 0,
37 "added": [],
38 "modified": [],
39 "deleted": [],
40 "renamed": {},
41 "staged": {"added": [], "modified": [], "deleted": []},
42 "unstaged": {"added": [], "modified": [], "deleted": []},
43 "untracked": [],
44 "conflict_paths": [],
45 "merge_in_progress": false,
46 "merge_from": null,
47 "conflict_count": 0,
48 "checkout_interrupted": false,
49 "checkout_target": null,
50 "sparse_checkout": null,
51 "duration_ms": 1.2,
52 "exit_code": 0
53 }
54
55 ``sparse_checkout`` is ``null`` when sparse-checkout is disabled. When active::
56
57 "sparse_checkout": {"enabled": true, "mode": "cone", "patterns": ["src/"]}
58
59 The schema is **always the same shape** regardless of domain or staging state.
60 ``staged`` and ``unstaged`` are ``null`` for domains that have no staging concept
61 (e.g. non-code domains). For code-domain repos they are always ``{added,
62 modified, deleted}`` sub-objects — even when all three lists are empty.
63
64 ``added``, ``modified``, ``deleted`` are the flat union of staged + unstaged —
65 the primary interface for agents that only need "what changed". ``staged`` and
66 ``unstaged`` partition that union for agents that need staging detail.
67
68 --exit-code
69 Exits 0 when the working tree is clean, 1 when dirty. Combine with
70 ``--json`` for structured output plus a testable exit code.
71
72 Color convention
73 ----------------
74 yellow modified — file exists in both old and new snapshot, content changed
75 green new file — file is new, not present in last commit
76 red deleted — file was removed since last commit
77 cyan renamed — file was moved or renamed since last commit
78 """
79
80 from __future__ import annotations
81
82 import argparse
83 import json
84 import logging
85 import pathlib
86 import sys
87 from typing import TypedDict
88
89 from muse.cli.commands.checkout import read_checkout_head
90 from muse.cli.config import get_remote_head, get_upstream
91 from muse.core.envelope import EnvelopeJson, make_envelope
92 from muse.core._types import Manifest, Metadata, load_json_file
93 from muse.core.paths import repo_json_path as _repo_json_path, sparse_checkout_path as _sparse_checkout_path
94 from muse.core.errors import ExitCode
95 from muse.core.repo import require_repo
96 from muse.core.store import (
97 get_head_commit_id,
98 get_head_snapshot_manifest,
99 read_current_branch,
100 walk_commits_between,
101 )
102 from muse.core.validation import sanitize_display
103 from muse.core.snapshot import directories_from_manifest
104 from muse.core.timing import start_timer
105 from muse.domain import SnapshotManifest, StagePlugin
106 from muse.plugins.registry import resolve_plugin
107
108 logger = logging.getLogger(__name__)
109
110 # Default domain when repo.json is absent or corrupt. Must match
111 # the default used by ``muse init`` (currently "code").
112 _DEFAULT_DOMAIN = "code"
113
114
115 class _UpstreamInfo(TypedDict):
116 """Computed ahead/behind counts for the current branch vs its upstream."""
117
118 tracking_ref: str
119 ahead: int | None
120 behind: int | None
121 line: str
122
123
124 class _BranchOnlyJson(EnvelopeJson):
125 """JSON payload for ``--branch-only`` output.
126
127 All keys are always present — agents must not need ``dict.get`` guards.
128 """
129
130 branch: str
131 head_commit: str | None
132 upstream: str | None
133 ahead: int | None
134 behind: int | None
135 merge_in_progress: bool
136 merge_from: str | None
137 conflict_count: int
138
139
140 class _SparseCheckoutInfo(TypedDict):
141 """Sparse-checkout config surfaced in ``muse status --json``.
142
143 Agents can read the active sparse config in the same call as working-tree
144 state — no second command needed.
145 """
146
147 enabled: bool
148 mode: str | None
149 patterns: list[str]
150
151
152 class _StagedBucket(TypedDict):
153 """The added/modified/deleted breakdown for one staging layer."""
154
155 added: list[str]
156 modified: list[str]
157 deleted: list[str]
158
159
160 class _StatusJson(EnvelopeJson):
161 """Canonical ``muse status --json`` payload — always the same shape.
162
163 All keys are always present so agents can read them without ``dict.get``
164 guards. The schema is identical regardless of domain or whether a stage
165 index is active.
166
167 Schema
168 ------
169 branch Current branch name.
170 head_commit sha256:-prefixed HEAD commit ID; null on an empty repo.
171 upstream Tracking remote name if configured, else null.
172 clean True when no staged changes, no unstaged changes, and no
173 untracked files. Matches git: untracked files present = not clean.
174 dirty not clean — both always present for ergonomic CI checks.
175 ahead Commits ahead of remote; null when no upstream.
176 behind Commits behind remote; null when no upstream.
177 total_changes len(added) + len(modified) + len(deleted) + len(renamed).
178 added Flat union — paths added since HEAD (staged ∪ unstaged).
179 modified Flat union — paths modified since HEAD.
180 deleted Flat union — paths deleted since HEAD.
181 renamed Mapping old → new for renamed paths.
182 staged {added, modified, deleted} for staged changes only.
183 null when domain has no staging concept.
184 unstaged {added, modified, deleted} for unstaged changes only.
185 null when domain has no staging concept.
186 untracked Files on disk not tracked by Muse. [] for non-code domains.
187 conflict_paths Paths with unresolved merge conflicts.
188 merge_in_progress True when a merge is in progress.
189 merge_from Branch being merged; null when no merge.
190 conflict_count len(conflict_paths).
191 checkout_interrupted True when a checkout was interrupted mid-flight.
192 checkout_target Branch or snapshot targeted by the interrupted checkout.
193 """
194
195 branch: str
196 head_commit: str | None
197 upstream: str | None
198 clean: bool
199 dirty: bool
200 ahead: int | None
201 behind: int | None
202 total_changes: int
203 added: list[str]
204 modified: list[str]
205 deleted: list[str]
206 renamed: Manifest
207 staged: _StagedBucket | None
208 unstaged: _StagedBucket | None
209 untracked: list[str]
210 conflict_paths: list[str]
211 merge_in_progress: bool
212 merge_from: str | None
213 conflict_count: int
214 checkout_interrupted: bool
215 checkout_target: str | None
216 sparse_checkout: _SparseCheckoutInfo | None
217
218
219 def _read_sparse_checkout(root: pathlib.Path) -> _SparseCheckoutInfo | None:
220 """Read the sparse-checkout config and return a typed summary, or None.
221
222 Returns ``None`` when sparse-checkout is disabled (config file absent).
223 Silently returns ``None`` on any parse error — status must never crash due
224 to a corrupt sparse config.
225
226 Args:
227 root: Repository root (directory that contains ``.muse/``).
228
229 Returns:
230 :class:`_SparseCheckoutInfo` when active, ``None`` otherwise.
231 """
232 cfg_path = _sparse_checkout_path(root)
233 if not cfg_path.exists():
234 return None
235 data = load_json_file(cfg_path)
236 if not isinstance(data, dict):
237 return None
238 try:
239 return _SparseCheckoutInfo(
240 enabled=True,
241 mode=data.get("mode"),
242 patterns=list(data.get("patterns", [])),
243 )
244 except Exception:
245 return None
246
247
248 _YELLOW = "\033[33m"
249 _GREEN = "\033[32m"
250 _RED = "\033[31m"
251 _CYAN = "\033[36m"
252 _BOLD = "\033[1m"
253 _RESET = "\033[0m"
254
255
256 def _color(text: str, ansi: str, is_tty: bool) -> str:
257 """Wrap *text* in ANSI color codes only when writing to a TTY."""
258 return f"{_BOLD}{ansi}{text}{_RESET}" if is_tty else text
259
260
261 def _compute_upstream_info(
262 root: pathlib.Path,
263 branch: str,
264 upstream: str,
265 ) -> _UpstreamInfo:
266 """Compute ahead/behind counts and the human-readable tracking line once.
267
268 Centralises all ``walk_commits_between`` calls so they are executed exactly
269 once per ``muse status`` invocation regardless of output format. Previously
270 the text path called ``_tracking_line`` (two BFS walks) and the JSON path
271 re-implemented the same logic inline (two more BFS walks) — four total BFS
272 traversals per status call. This helper performs at most two walks and
273 returns a typed result consumed by both paths.
274
275 Args:
276 root: Repository root.
277 branch: Current branch name.
278 upstream: Upstream remote name (e.g. ``"origin"``).
279
280 Returns:
281 :class:`_UpstreamInfo` with ``tracking_ref``, ``ahead``, ``behind``,
282 and a pre-formatted ``line`` for the text output.
283 """
284 tracking_ref = f"{upstream}/{branch}"
285 remote_head = get_remote_head(upstream, branch, root)
286
287 if not remote_head:
288 return _UpstreamInfo(
289 tracking_ref=tracking_ref,
290 ahead=None,
291 behind=None,
292 line=f"Tracking: {tracking_ref} (not yet pushed)",
293 )
294
295 local_head = get_head_commit_id(root, branch)
296 if not local_head:
297 return _UpstreamInfo(
298 tracking_ref=tracking_ref,
299 ahead=None,
300 behind=None,
301 line=f"Tracking: {tracking_ref}",
302 )
303
304 if local_head == remote_head:
305 return _UpstreamInfo(
306 tracking_ref=tracking_ref,
307 ahead=0,
308 behind=0,
309 line=f"Your branch is up to date with '{tracking_ref}'.",
310 )
311
312 # Both walks are necessary only for the diverged case; the common case
313 # (up-to-date) returns early above without any BFS at all.
314 ahead = len(walk_commits_between(root, local_head, remote_head))
315 behind = len(walk_commits_between(root, remote_head, local_head))
316
317 if ahead and behind:
318 line = (
319 f"Your branch and '{tracking_ref}' have diverged, "
320 f"and have {ahead} and {behind} different commits each."
321 )
322 elif ahead:
323 suffix = "commit" if ahead == 1 else "commits"
324 line = f"Your branch is ahead of '{tracking_ref}' by {ahead} {suffix}."
325 elif behind:
326 suffix = "commit" if behind == 1 else "commits"
327 line = f"Your branch is behind '{tracking_ref}' by {behind} {suffix}."
328 else:
329 line = f"Your branch is up to date with '{tracking_ref}'."
330
331 return _UpstreamInfo(
332 tracking_ref=tracking_ref,
333 ahead=ahead,
334 behind=behind,
335 line=line,
336 )
337
338
339 def _read_repo_meta(root: pathlib.Path) -> tuple[str, str]:
340 """Read ``.muse/repo.json`` once and return ``(repo_id, domain)``.
341
342 Returns sensible defaults on any read or parse failure rather than
343 propagating an unhandled exception to the user. Status degrades
344 gracefully to an empty diff in the worst case.
345
346 The domain default is ``"code"`` — matching ``muse init``'s default — so
347 that a corrupt or absent ``repo.json`` produces sensible ignore rules rather
348 than silently switching to the ``midi`` domain.
349 """
350 data = load_json_file(_repo_json_path(root))
351 if data is None:
352 return "", _DEFAULT_DOMAIN
353 repo_id_raw = data.get("repo_id", "")
354 repo_id = str(repo_id_raw) if isinstance(repo_id_raw, str) and repo_id_raw else ""
355 domain_raw = data.get("domain", "")
356 domain = str(domain_raw) if isinstance(domain_raw, str) and domain_raw else _DEFAULT_DOMAIN
357 return repo_id, domain
358
359
360 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
361 """Register the ``muse status`` subcommand and its flags."""
362 parser = subparsers.add_parser(
363 "status",
364 help="Show working-tree drift against HEAD.",
365 description=__doc__,
366 formatter_class=argparse.RawDescriptionHelpFormatter,
367 )
368 parser.add_argument(
369 "--short", "-s", action="store_true",
370 help="Condensed one-letter-per-file output.",
371 )
372 parser.add_argument(
373 "--branch", "-b", action="store_true", dest="branch_only",
374 help="Show branch/upstream info only — skip the file diff.",
375 )
376 parser.add_argument(
377 "--json", "-j",
378 action="store_true",
379 dest="json_out",
380 help="Emit JSON output (default: human-readable text).",
381 )
382 parser.add_argument(
383 "--exit-code", action="store_true", dest="exit_code",
384 help=(
385 "Exit 0 when the working tree is clean, 1 when dirty. "
386 "Combines with --json for structured output plus a testable exit code."
387 ),
388 )
389 parser.set_defaults(func=run)
390
391
392 def run(args: argparse.Namespace) -> None:
393 """Show working-tree drift against HEAD.
394
395 Covers four scenarios: clean tree, dirty tree (added/modified/deleted/renamed
396 files), merge in progress (conflict count and resolution steps), and staged
397 index active (code plugin: three-bucket staged/unstaged/untracked view).
398 Use ``--branch`` for a lightweight branch-info check without diffing files.
399
400 Agent quickstart::
401
402 muse status --json
403 muse status --branch --json
404 muse status --exit-code --json
405 muse status --short --json
406
407 JSON fields::
408
409 branch Current branch name.
410 head_commit SHA-256-prefixed HEAD commit ID; ``null`` on empty repo.
411 upstream Tracking remote name if configured, else ``null``.
412 clean ``true`` when working tree exactly matches HEAD.
413 dirty ``not clean``.
414 ahead Commits ahead of remote; ``null`` when no upstream.
415 behind Commits behind remote; ``null`` when no upstream.
416 total_changes ``len(added) + len(modified) + len(deleted) + len(renamed)``.
417 added Flat union of staged + unstaged new paths.
418 modified Flat union of staged + unstaged changed paths.
419 deleted Flat union of staged + unstaged removed paths.
420 renamed Mapping old → new for renamed paths.
421 staged ``{added, modified, deleted}`` staged changes; ``null`` for non-code.
422 unstaged ``{added, modified, deleted}`` unstaged changes; ``null`` for non-code.
423 untracked Files on disk not tracked by Muse; ``[]`` for non-code.
424 conflict_paths Paths with unresolved merge conflicts.
425 merge_in_progress ``true`` when a merge is in progress.
426 merge_from Branch being merged; ``null`` when no merge.
427 conflict_count ``len(conflict_paths)``.
428 checkout_interrupted ``true`` when a checkout was killed mid-flight.
429 checkout_target Branch or snapshot targeted by the interrupted checkout.
430 sparse_checkout Active sparse config ``{enabled, mode, patterns}``; ``null`` when disabled.
431 muse_version Muse release that produced this output.
432 schema Envelope schema version (int).
433 exit_code ``0`` normally; ``1`` when ``--exit-code`` and working tree is dirty.
434 duration_ms Wall-clock milliseconds for the command.
435 timestamp ISO-8601 UTC timestamp of command completion.
436 warnings List of non-fatal advisory messages.
437
438 Exit codes::
439
440 0 Success (or clean tree when ``--exit-code`` is set).
441 1 Dirty working tree (only when ``--exit-code`` is given).
442 2 Usage error (invalid ``--format`` value).
443 3 Internal error (repository not found).
444 """
445 from muse.core.merge_engine import read_merge_state
446
447 elapsed = start_timer()
448
449 json_out: bool = args.json_out
450 short: bool = args.short
451 branch_only: bool = args.branch_only
452 exit_code_flag: bool = args.exit_code
453
454 root = require_repo()
455 try:
456 branch = read_current_branch(root)
457 except ValueError as exc:
458 print(f"fatal: {exc}", file=sys.stderr)
459 raise SystemExit(ExitCode.USER_ERROR)
460
461 repo_id, domain = _read_repo_meta(root)
462 upstream = get_upstream(branch, root)
463
464 # ── Checkout-interrupted state ────────────────────────────────────────────
465 # .muse/CHECKOUT_HEAD exists only when a previous checkout was killed
466 # mid-flight. The working tree may be partially mutated; warn loudly so
467 # the user knows to retry the checkout rather than treating missing files
468 # as uncommitted deletions.
469 checkout_target: str | None = read_checkout_head(root)
470 checkout_interrupted: bool = checkout_target is not None
471
472 # ── Merge-in-progress state ───────────────────────────────────────────────
473 merge_state = read_merge_state(root)
474 merge_in_progress = merge_state is not None
475 conflict_paths: list[str] = merge_state.conflict_paths if merge_state else []
476 conflict_count = len(conflict_paths)
477 merge_from: str | None = merge_state.other_branch if merge_state else None
478
479 # ── HEAD commit id ────────────────────────────────────────────────────────
480 head_commit: str | None = get_head_commit_id(root, branch)
481
482 # ── Upstream ahead/behind (computed once, shared by all output formats) ──
483 upstream_info: _UpstreamInfo | None = None
484 if upstream:
485 upstream_info = _compute_upstream_info(root, branch, upstream)
486
487 # ── Text: checkout-interrupted banner ─────────────────────────────────────
488 if not json_out and checkout_interrupted:
489 safe_target = sanitize_display(checkout_target) if checkout_target else ""
490 print(
491 f"\n🚨 CHECKOUT INTERRUPTED — the previous checkout to "
492 f"'{safe_target}' did not complete.",
493 file=sys.stderr,
494 )
495 print(
496 " The working tree may be partially mutated.\n"
497 " Files shown as 'deleted' below may be missing because of the\n"
498 " interrupted checkout, not because you deleted them.\n"
499 " Next steps:",
500 file=sys.stderr,
501 )
502 print(
503 f" muse checkout {safe_target} # retry the checkout\n"
504 " muse checkout <branch> # switch to a different branch",
505 file=sys.stderr,
506 )
507
508 # ── Text: merge banner and branch line ────────────────────────────────────
509 if not json_out:
510 if not short:
511 print(f"On branch {sanitize_display(branch)}")
512 if upstream_info:
513 print(upstream_info["line"])
514
515 if merge_in_progress:
516 safe_merge_from = sanitize_display(merge_from) if merge_from else ""
517 label = f" merging '{safe_merge_from}'" if safe_merge_from else ""
518 print(f"\n⚠️ You have an unresolved merge in progress{label}.")
519 if conflict_count:
520 print(f" {conflict_count} unresolved conflict(s).")
521 print(" Next steps:")
522 print(" muse conflicts # see all conflicts")
523 print(" muse checkout --ours <path> # accept your version")
524 print(" muse checkout --theirs <path> # accept their version")
525 print(" muse checkout --ours --all # resolve all — keep ours")
526 print(" muse checkout --theirs --all # resolve all — keep theirs")
527 print(" muse commit # once all resolved")
528 print(" muse merge --abort # cancel the merge")
529 else:
530 print(" All conflicts resolved — run `muse commit` to complete the merge.")
531
532 # ── Branch-only mode ──────────────────────────────────────────────────────
533 if branch_only:
534 if json_out:
535 out = _BranchOnlyJson(
536 **make_envelope(elapsed),
537 branch=sanitize_display(branch),
538 head_commit=head_commit,
539 upstream=upstream,
540 ahead=upstream_info["ahead"] if upstream_info else None,
541 behind=upstream_info["behind"] if upstream_info else None,
542 merge_in_progress=merge_in_progress,
543 merge_from=sanitize_display(merge_from) if merge_from else None,
544 conflict_count=conflict_count,
545 )
546 print(json.dumps(out))
547 return
548
549 is_tty = sys.stdout.isatty() and not json_out
550
551 plugin = resolve_plugin(root)
552
553 # ── Staged-index path (any domain that supports staging) ─────────────────
554 # Route here whenever the plugin supports StagePlugin, regardless of whether
555 # the index file exists — so JSON output always includes staged/unstaged
556 # buckets for code-domain repos, even when the index is empty/absent.
557 sparse_checkout = _read_sparse_checkout(root)
558
559 if isinstance(plugin, StagePlugin):
560 _render_staged_status(
561 root, plugin, branch, head_commit, json_out, short, is_tty,
562 upstream_info=upstream_info,
563 merge_in_progress=merge_in_progress,
564 conflict_paths=conflict_paths,
565 merge_from=merge_from,
566 exit_code_flag=exit_code_flag,
567 checkout_interrupted=checkout_interrupted,
568 checkout_target=checkout_target,
569 sparse_checkout=sparse_checkout,
570 elapsed_fn=elapsed,
571 )
572 return
573
574 # ── Drift computation ─────────────────────────────────────────────────────
575 head_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
576 committed_snap = SnapshotManifest(files=head_manifest, domain=domain, directories=directories_from_manifest(head_manifest))
577 report = plugin.drift(committed_snap, root)
578 delta = report.delta
579
580 added: set[str] = set()
581 modified: set[str] = set()
582 deleted: set[str] = set()
583 renamed: Manifest = {}
584
585 for op in delta["ops"]:
586 op_type = op["op"]
587 addr = op["address"]
588 if op_type == "insert":
589 added.add(addr)
590 elif op_type == "delete":
591 deleted.add(addr)
592 elif op_type == "replace":
593 modified.add(addr)
594 elif op_type == "patch":
595 from_addr = op.get("from_address")
596 if from_addr:
597 renamed[str(from_addr)] = addr
598 else:
599 modified.add(addr)
600 elif op_type == "directory_rename":
601 from_addr = op.get("from_address")
602 if from_addr:
603 renamed[str(from_addr)] = addr
604
605 clean = not (added or modified or deleted or renamed)
606 dirty = not clean
607
608 # ── JSON output ───────────────────────────────────────────────────────────
609 if json_out:
610 out_json = _StatusJson(
611 **make_envelope(elapsed),
612 branch=sanitize_display(branch),
613 head_commit=head_commit,
614 upstream=upstream,
615 clean=clean,
616 dirty=dirty,
617 ahead=upstream_info["ahead"] if upstream_info else None,
618 behind=upstream_info["behind"] if upstream_info else None,
619 total_changes=len(added) + len(modified) + len(deleted) + len(renamed),
620 added=sorted(added),
621 modified=sorted(modified),
622 deleted=sorted(deleted),
623 renamed=renamed,
624 # Non-stage domains have no staging concept — null signals this clearly.
625 staged=None,
626 unstaged=None,
627 untracked=[],
628 conflict_paths=conflict_paths,
629 merge_in_progress=merge_in_progress,
630 merge_from=sanitize_display(merge_from) if merge_from else None,
631 conflict_count=conflict_count,
632 checkout_interrupted=checkout_interrupted,
633 checkout_target=sanitize_display(checkout_target) if checkout_target else None,
634 sparse_checkout=sparse_checkout,
635 )
636 print(json.dumps(out_json))
637 if exit_code_flag and dirty:
638 raise SystemExit(1)
639 return
640
641 # ── Short output ──────────────────────────────────────────────────────────
642 if short:
643 for p in sorted(modified):
644 print(f" {_color('M', _YELLOW, is_tty)} {p}")
645 for p in sorted(added):
646 print(f" {_color('A', _GREEN, is_tty)} {p}")
647 for p in sorted(deleted):
648 print(f" {_color('D', _RED, is_tty)} {p}")
649 for old, new in sorted(renamed.items()):
650 print(f" {_color('R', _CYAN, is_tty)} {old} → {new}")
651 if exit_code_flag and dirty:
652 raise SystemExit(1)
653 return
654
655 # ── Long text output ──────────────────────────────────────────────────────
656 if clean and not merge_in_progress:
657 print("\nNothing to commit, working tree clean")
658 if exit_code_flag:
659 raise SystemExit(0)
660 return
661
662 if not clean:
663 print("\nChanges since last commit:")
664 print(' (use "muse commit -m <msg>" to record changes)\n')
665 for p in sorted(modified):
666 print(f"\t{_color(' modified:', _YELLOW, is_tty)} {sanitize_display(p)}")
667 for p in sorted(added):
668 print(f"\t{_color(' new file:', _GREEN, is_tty)} {sanitize_display(p)}")
669 for p in sorted(deleted):
670 print(f"\t{_color(' deleted:', _RED, is_tty)} {sanitize_display(p)}")
671 for old, new in sorted(renamed.items()):
672 print(
673 f"\t{_color(' renamed:', _CYAN, is_tty)} "
674 f"{sanitize_display(old)} → {sanitize_display(new)}"
675 )
676
677 if exit_code_flag and dirty:
678 raise SystemExit(1)
679
680
681 def _render_staged_status(
682 root: pathlib.Path,
683 plugin: StagePlugin,
684 branch: str,
685 head_commit: str | None,
686 json_out: bool,
687 short: bool,
688 is_tty: bool,
689 *,
690 upstream_info: "_UpstreamInfo | None" = None,
691 merge_in_progress: bool = False,
692 conflict_paths: list[str] | None = None,
693 merge_from: str | None = None,
694 exit_code_flag: bool = False,
695 checkout_interrupted: bool = False,
696 checkout_target: str | None = None,
697 sparse_checkout: "_SparseCheckoutInfo | None" = None,
698 elapsed_fn: "callable[[], float] | None" = None,
699 ) -> None:
700 """Render the three-bucket staged / unstaged / untracked view.
701
702 Displayed when the active plugin implements :class:`~muse.domain.StagePlugin`
703 and a stage index is present. Mirrors ``git status`` long-form output.
704
705 Args:
706 root: Repository root.
707 plugin: Active plugin (must implement :class:`StagePlugin`).
708 branch: Current branch name.
709 head_commit: SHA-256 of HEAD commit (null on empty repo).
710 json_out: True to emit JSON, False for human-readable text.
711 short: Render condensed one-letter-per-file output.
712 is_tty: True when stdout is a terminal (enables color).
713 upstream_info: Ahead/behind tracking info; ``None`` when no remote.
714 merge_in_progress: True when a merge is in progress.
715 conflict_paths: Paths with unresolved merge conflicts.
716 merge_from: Branch being merged in.
717 exit_code_flag: Exit 1 when dirty.
718 checkout_interrupted: True when a previous checkout was killed mid-flight.
719 checkout_target: Branch or snapshot targeted by the interrupted checkout.
720 sparse_checkout: Active sparse-checkout config; ``None`` when disabled.
721 elapsed_fn: Callable returning milliseconds since ``run()`` started.
722 """
723 status = plugin.stage_status(root)
724 staged = status["staged"]
725 unstaged = status["unstaged"]
726 untracked = status["untracked"]
727
728 # clean = no staged changes, no unstaged changes, and no untracked files.
729 # Matches git: 'untracked files present' is not a clean working tree.
730 clean = not staged and not unstaged and not untracked
731 dirty = not clean
732 _conflict_paths: list[str] = conflict_paths or []
733
734 _MODE_LABEL: Metadata = {
735 "A": "new file",
736 "M": "modified",
737 "D": "deleted",
738 }
739
740 if json_out:
741 # Build the staged sub-bucket from the stage index (mode A/M/D per path).
742 staged_added = sorted(p for p, e in staged.items() if e["mode"] == "A")
743 staged_modified = sorted(p for p, e in staged.items() if e["mode"] == "M")
744 staged_deleted = sorted(p for p, e in staged.items() if e["mode"] == "D")
745
746 # Build the unstaged sub-bucket from the working-tree drift dict.
747 unstaged_added: list[str] = [] # new files appear as untracked, not unstaged
748 unstaged_modified = sorted(p for p, lbl in unstaged.items() if lbl == "modified")
749 unstaged_deleted = sorted(p for p, lbl in unstaged.items() if lbl == "deleted")
750
751 # Flat view is the union of staged and unstaged, deduplicated.
752 flat_added = sorted(set(staged_added) | set(unstaged_added))
753 flat_modified = sorted(set(staged_modified) | set(unstaged_modified))
754 flat_deleted = sorted(set(staged_deleted) | set(unstaged_deleted))
755 total = len(flat_added) + len(flat_modified) + len(flat_deleted)
756
757 out = _StatusJson(
758 **make_envelope(elapsed_fn),
759 branch=sanitize_display(branch),
760 head_commit=head_commit,
761 upstream=upstream_info["tracking_ref"] if upstream_info else None,
762 clean=clean,
763 dirty=dirty,
764 ahead=upstream_info["ahead"] if upstream_info else None,
765 behind=upstream_info["behind"] if upstream_info else None,
766 total_changes=total,
767 added=flat_added,
768 modified=flat_modified,
769 deleted=flat_deleted,
770 renamed={},
771 staged=_StagedBucket(
772 added=staged_added,
773 modified=staged_modified,
774 deleted=staged_deleted,
775 ),
776 unstaged=_StagedBucket(
777 added=unstaged_added,
778 modified=unstaged_modified,
779 deleted=unstaged_deleted,
780 ),
781 untracked=list(untracked),
782 conflict_paths=_conflict_paths,
783 merge_in_progress=merge_in_progress,
784 merge_from=sanitize_display(merge_from) if merge_from else None,
785 conflict_count=len(_conflict_paths),
786 checkout_interrupted=checkout_interrupted,
787 checkout_target=sanitize_display(checkout_target) if checkout_target else None,
788 sparse_checkout=sparse_checkout,
789 )
790 print(json.dumps(out))
791 if exit_code_flag and dirty:
792 raise SystemExit(1)
793 return
794
795 if short:
796 for p, entry in sorted(staged.items()):
797 s_mode = entry["mode"]
798 color = _GREEN if s_mode == "A" else _YELLOW if s_mode == "M" else _RED
799 print(f"{_color(s_mode, color, is_tty)} {p}")
800 for p, label in sorted(unstaged.items()):
801 u_letter = "M" if label == "modified" else "D"
802 u_color = _YELLOW if label == "modified" else _RED
803 print(f" {_color(u_letter, u_color, is_tty)} {p}")
804 for p in untracked:
805 print(f"?? {p}")
806 if exit_code_flag and dirty:
807 raise SystemExit(1)
808 return
809
810 # Long form — mirrors git status exactly.
811 if staged:
812 print("\nChanges staged for commit:")
813 print(' (use "muse code reset HEAD <file>" to unstage)\n')
814 for p, entry in sorted(staged.items()):
815 label = _MODE_LABEL.get(entry["mode"], entry["mode"])
816 color = _GREEN if entry["mode"] == "A" else _YELLOW if entry["mode"] == "M" else _RED
817 pad = max(0, 10 - len(label))
818 print(f"\t{_color(label + ':', color, is_tty)}{' ' * pad} {p}")
819
820 if unstaged:
821 print("\nChanges not staged for commit:")
822 print(' (use "muse code add <file>" to update what will be committed)\n')
823 for p, label in sorted(unstaged.items()):
824 color = _YELLOW if label == "modified" else _RED
825 pad = max(0, 10 - len(label))
826 print(f"\t{_color(label + ':', color, is_tty)}{' ' * pad} {p}")
827
828 if untracked:
829 print("\nUntracked files:")
830 print(' (use "muse code add <file>" to include in what will be committed)\n')
831 for p in untracked:
832 print(f"\t{p}")
833
834 if clean and not merge_in_progress:
835 print("\nNothing to commit, working tree clean")
836
837 if staged:
838 print() # trailing newline after last section
839
840 if exit_code_flag and dirty:
841 raise SystemExit(1)
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago