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