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