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