diff.py python
814 lines 31.1 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """``muse diff`` — show what has changed since the last commit.
2
3 ``muse diff`` always answers: **what has changed since my last commit?**
4 That means HEAD vs the actual working tree, regardless of what is staged.
5 The stage is a commit-preparation tool; it does not change the meaning of diff.
6
7 Usage
8 -----
9
10 Everything changed since last commit (default)::
11
12 muse diff
13
14 What *will* be committed (staged changes vs HEAD)::
15
16 muse diff --staged
17
18 What is *not yet* staged (working tree vs stage)::
19
20 muse diff --unstaged
21
22 Two commits::
23
24 muse diff <commit_a> <commit_b>
25
26 Limit output to specific files or directories::
27
28 muse diff -p muse/cli/commands/status.py
29 muse diff -p muse/cli/ -p muse/plugins/
30 muse diff --staged -p muse/cli/commands/status.py
31
32 Show stashed changes vs HEAD::
33
34 muse diff --stash # most recent stash entry
35 muse diff --stash 1 # stash entry at index 1
36
37 CI / agent pipeline usage::
38
39 muse diff --exit-code # exits 1 when changes exist, 0 when clean
40 muse diff --json --exit-code # structured output + exit code for scripting
41 """
42
43 from __future__ import annotations
44
45 import argparse
46 import difflib
47 import json
48 import logging
49 import os
50 import pathlib
51 import sys
52
53 from muse.core._types import Manifest
54 from muse.core.errors import ExitCode
55 from muse.core.merge_engine import read_merge_state
56 from muse.core.object_store import read_object
57 from muse.core.repo import read_repo_id, require_repo
58 from muse.core.store import (
59 get_commit_snapshot_manifest,
60 get_head_snapshot_manifest,
61 read_current_branch,
62 resolve_commit_ref,
63 )
64 from muse.core.validation import sanitize_display
65 from muse.core.snapshot import directories_from_manifest
66 from muse.core.cohen_transform import (
67 CONFLICT_SEPARATOR,
68 annotate_hunk_action,
69 format_conflict_diff,
70 )
71 from muse.domain import DomainOp, PatchOp, SnapshotManifest, StagePlugin
72 from muse.plugins.registry import read_domain, resolve_plugin
73 from muse.cli.commands.stash import _load_stash, _resolve_index
74
75 logger = logging.getLogger(__name__)
76
77
78 _MAX_INLINE_CHILDREN = 12
79
80 # Sentinel: the two-space + "L" prefix used by the domain plugin to annotate
81 # symbol locations inside op summaries (e.g. "added function foo L4–8").
82 _LOC_SEP = " L"
83
84
85 # ── Colour helpers ────────────────────────────────────────────────────────────
86 # Colours are applied only when stdout is a real TTY. When output is piped or
87 # redirected (e.g. into an agent tool, a file, or `less`) the raw text is
88 # emitted without escape sequences. Pass NO_COLOR=1 or TERM=dumb to force
89 # plain output even on a TTY.
90
91 def _use_color() -> bool:
92 """Return True when ANSI colours should be emitted to stdout."""
93 if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb":
94 return False
95 return sys.stdout.isatty()
96
97
98 def _green(text: str) -> str:
99 return f"\033[32m{text}\033[0m" if _use_color() else text
100
101
102 def _red(text: str) -> str:
103 return f"\033[31m{text}\033[0m" if _use_color() else text
104
105
106 def _yellow(text: str) -> str:
107 return f"\033[33m{text}\033[0m" if _use_color() else text
108
109
110 def _cyan(text: str) -> str:
111 return f"\033[36m{text}\033[0m" if _use_color() else text
112
113
114 def _bold(text: str) -> str:
115 return f"\033[1m{text}\033[0m" if _use_color() else text
116
117
118 # ── Op categorization ─────────────────────────────────────────────────────────
119
120
121 def _classify_patch_op(op: PatchOp) -> str:
122 """Classify a ``patch`` op as ``'insert'``, ``'delete'``, or ``'replace'``.
123
124 The domain plugin emits ``patch`` for file-level changes with child ops
125 describing symbol-level mutations. A patch whose children are exclusively
126 ``insert`` ops represents a newly added file; one whose children are all
127 ``delete`` ops represents a removed file; anything else is a modification.
128
129 This mirrors the heuristic already used in ``_print_structured_delta`` for
130 the text output path, and makes the JSON ``added``/``deleted`` lists
131 correct for agent consumers.
132 """
133 child_ops: list[DomainOp] = op["child_ops"]
134 if not child_ops:
135 return "replace"
136 if all(c["op"] == "insert" for c in child_ops):
137 return "insert"
138 if all(c["op"] == "delete" for c in child_ops):
139 return "delete"
140 return "replace"
141
142
143 def _op_category(op: DomainOp) -> str:
144 """Return the logical category (``'insert'`` / ``'delete'`` / ``'replace'``) of any op."""
145 if op["op"] == "patch":
146 return _classify_patch_op(op)
147 if op["op"] in ("replace", "mutate", "move"):
148 return "replace"
149 return op["op"] # "insert" or "delete"
150
151
152 # ── Display helpers ───────────────────────────────────────────────────────────
153
154
155 def _split_loc(summary: str) -> tuple[str, str]:
156 """Split ``'added function foo L4–8'`` into ``('added function foo', 'L4–8')``.
157
158 Returns the original string and an empty loc when no location suffix is
159 present (e.g. cross-file move annotations that carry no line data).
160 """
161 if _LOC_SEP in summary:
162 label, _, loc = summary.rpartition(_LOC_SEP)
163 return label, f"L{loc}"
164 return summary, ""
165
166
167 def _print_child_ops(child_ops: list[DomainOp]) -> None:
168 """Render symbol-level child ops with aligned columns and colours.
169
170 Labels are left-padded to a uniform width within the group so the
171 line-range column (``L{start}–{end}``) lines up vertically. Shows up
172 to ``_MAX_INLINE_CHILDREN`` entries inline; summarises the rest on a
173 single trailing line.
174 """
175 visible = child_ops[:_MAX_INLINE_CHILDREN]
176 overflow = len(child_ops) - len(visible)
177
178 rows: list[tuple[str, str, str]] = []
179 for cop in visible:
180 if cop["op"] == "insert":
181 label, loc = _split_loc(cop["content_summary"])
182 rows.append(("insert", label, loc))
183 elif cop["op"] == "delete":
184 label, loc = _split_loc(cop["content_summary"])
185 rows.append(("delete", label, loc))
186 elif cop["op"] == "replace":
187 label, loc = _split_loc(cop["new_summary"])
188 rows.append(("replace", label, loc))
189 elif cop["op"] == "move":
190 label = f"{cop['address']} ({cop['from_position']} → {cop['to_position']})"
191 rows.append(("move", label, ""))
192 else:
193 rows.append(("unknown", "", ""))
194
195 for i, (op_type, label, loc) in enumerate(rows):
196 is_last = (i == len(rows) - 1) and overflow == 0
197 connector = "└─" if is_last else "├─"
198 if op_type == "insert":
199 styled = _green(label)
200 elif op_type == "delete":
201 styled = _red(label)
202 elif op_type == "replace":
203 styled = _yellow(label)
204 elif op_type == "move":
205 styled = _cyan(label)
206 else:
207 styled = label
208 suffix = f" {loc}" if loc else ""
209 print(f" {connector} {styled}{suffix}")
210
211 if overflow > 0:
212 print(f" └─ … and {overflow} more")
213
214
215 def _print_structured_delta(ops: list[DomainOp]) -> int:
216 """Print a colour-coded delta op-by-op. Returns the number of ops printed.
217
218 Colour scheme mirrors standard diff conventions:
219
220 - Green → added (A)
221 - Red → deleted (D)
222 - Yellow → modified (M)
223 - Cyan → moved / renamed (R)
224
225 Each branch checks ``op["op"]`` directly so mypy can narrow the
226 TypedDict union to the specific subtype before accessing its fields.
227 """
228 for op in ops:
229 if op["op"] == "insert":
230 print(_green(f"A {op['address']}"))
231 elif op["op"] == "delete":
232 print(_red(f"D {op['address']}"))
233 elif op["op"] == "replace":
234 print(_yellow(f"M {op['address']}"))
235 elif op["op"] == "move":
236 print(
237 _cyan(f"R {op['address']} ({op['from_position']} → {op['to_position']})")
238 )
239 elif op["op"] == "patch":
240 child_ops = op["child_ops"]
241 from_address = op.get("from_address")
242 if from_address:
243 # File was renamed AND edited simultaneously.
244 print(_cyan(f"R {from_address} → {op['address']}"))
245 else:
246 # Classify the patch: all-inserts = new file, all-deletes =
247 # removed file, mixed = modification.
248 category = _classify_patch_op(op)
249 if category == "insert":
250 print(_green(f"A {op['address']}"))
251 elif category == "delete":
252 print(_red(f"D {op['address']}"))
253 else:
254 print(_yellow(f"M {op['address']}"))
255 _print_child_ops(child_ops)
256 return len(ops)
257
258
259 def _print_text_diff(
260 base_files: Manifest,
261 target_files: Manifest,
262 root: pathlib.Path,
263 workdir: pathlib.Path | None,
264 ) -> int:
265 """Print a coloured unified diff for every changed file. Returns change count."""
266 base_paths = set(base_files)
267 target_paths = set(target_files)
268 changed = (
269 sorted(target_paths - base_paths) # added
270 + sorted(base_paths - target_paths) # removed
271 + sorted( # modified
272 p for p in base_paths & target_paths
273 if base_files[p] != target_files[p]
274 )
275 )
276
277 for path in changed:
278 # Sanitize the path before using it in diff headers so that file
279 # names containing ANSI escape sequences cannot spoof terminal output.
280 safe_path = sanitize_display(path)
281
282 # Read base content.
283 if path in base_files:
284 raw_base = read_object(root, base_files[path])
285 base_lines = (
286 raw_base.decode("utf-8", errors="replace").splitlines()
287 if raw_base
288 else []
289 )
290 base_label = f"a/{safe_path}"
291 else:
292 base_lines = []
293 base_label = "/dev/null"
294
295 # Read target content (object store first, then disk for working tree).
296 if path in target_files:
297 raw_target = read_object(root, target_files[path])
298 if raw_target is None and workdir is not None:
299 disk = workdir / path
300 if disk.is_file():
301 raw_target = disk.read_bytes()
302 target_lines = (
303 raw_target.decode("utf-8", errors="replace").splitlines()
304 if raw_target
305 else []
306 )
307 target_label = f"b/{safe_path}"
308 else:
309 target_lines = []
310 target_label = "/dev/null"
311
312 hunks = list(difflib.unified_diff(
313 base_lines, target_lines,
314 fromfile=base_label, tofile=target_label,
315 lineterm="",
316 ))
317 if not hunks:
318 continue
319
320 for line in hunks:
321 if line.startswith("---") or line.startswith("+++"):
322 print(_bold(line))
323 elif line.startswith("@@"):
324 print(_cyan(line))
325 elif line.startswith("+"):
326 print(_green(line))
327 elif line.startswith("-"):
328 print(_red(line))
329 else:
330 print(line)
331
332 return len(changed)
333
334
335 # ── Registration ──────────────────────────────────────────────────────────────
336
337
338 def _print_conflict_diff(
339 root: pathlib.Path,
340 base_commit_id: str | None,
341 ours_commit_id: str | None,
342 theirs_commit_id: str | None,
343 conflict_paths: list[str],
344 path_filter: list[str],
345 *,
346 ours_label: str,
347 theirs_label: str,
348 fmt: str,
349 ) -> int:
350 """Render a Cohen-transform labeled diff for every conflicting file.
351
352 For each path in *conflict_paths*, computes ``base→ours`` and
353 ``base→theirs`` unified diffs and renders them with per-hunk action
354 annotations (``[ours: deleted]``, ``[theirs: inserted]``, …) so the
355 user can immediately see *what each side did* rather than staring at two
356 opaque blobs.
357
358 This is the direct implementation of the conflict-presentation insight
359 from Bram Cohen's Manyana project. Credit: Bram Cohen,
360 https://github.com/bramcohen/manyana.
361
362 Args:
363 root: Repository root.
364 base_commit_id: Merge-base commit ID (``None`` if unavailable).
365 ours_commit_id: Our branch commit ID at merge time.
366 theirs_commit_id: Their branch commit ID.
367 conflict_paths: Paths with unresolved conflicts (from MERGE_STATE).
368 path_filter: If non-empty, only render paths matching this list.
369 ours_label: Human-readable name for the ours side (branch name).
370 theirs_label: Human-readable name for the theirs side.
371 fmt: ``'text'`` or ``'json'``.
372
373 Returns:
374 Number of conflicting paths rendered.
375 """
376 base_manifest = get_commit_snapshot_manifest(root, base_commit_id) or {} if base_commit_id else {}
377 ours_manifest = get_commit_snapshot_manifest(root, ours_commit_id) or {} if ours_commit_id else {}
378 theirs_manifest = get_commit_snapshot_manifest(root, theirs_commit_id) or {} if theirs_commit_id else {}
379
380 paths = [
381 p for p in sorted(conflict_paths)
382 if not path_filter or any(p == pf or p.startswith(pf + "/") for pf in path_filter)
383 ]
384
385 if fmt == "json":
386 conflicts_out = []
387 for path in paths:
388 def _lines(manifest: dict[str, str], disk_fallback: bool = False) -> list[str]:
389 oid = manifest.get(path)
390 if oid:
391 raw = read_object(root, oid)
392 if raw is not None:
393 return raw.decode("utf-8", errors="replace").splitlines(keepends=True)
394 if disk_fallback:
395 disk = root / path
396 if disk.is_file():
397 return disk.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
398 return []
399
400 safe = sanitize_display(path)
401 base_lines = _lines(base_manifest)
402 ours_lines = _lines(ours_manifest, disk_fallback=True)
403 theirs_lines = _lines(theirs_manifest)
404
405 ours_diff = "".join(difflib.unified_diff(
406 base_lines, ours_lines,
407 fromfile=f"base/{safe}", tofile=f"{ours_label}/{safe}", lineterm="",
408 ))
409 theirs_diff = "".join(difflib.unified_diff(
410 base_lines, theirs_lines,
411 fromfile=f"base/{safe}", tofile=f"{theirs_label}/{safe}", lineterm="",
412 ))
413 conflicts_out.append({
414 "path": safe,
415 "ours_diff": ours_diff,
416 "theirs_diff": theirs_diff,
417 })
418
419 print(json.dumps({
420 "status": "conflict",
421 "base_commit": base_commit_id,
422 "ours_commit": ours_commit_id,
423 "theirs_commit": theirs_commit_id,
424 "ours_label": ours_label,
425 "theirs_label": theirs_label,
426 "conflicts": conflicts_out,
427 }))
428 return len(paths)
429
430 # Text mode — render with color.
431 use_color = _use_color()
432 for path in paths:
433 lines = format_conflict_diff(
434 path, root,
435 base_manifest, ours_manifest, theirs_manifest,
436 read_object,
437 use_color=use_color,
438 ours_label=ours_label,
439 theirs_label=theirs_label,
440 )
441 for line in lines:
442 print(line)
443
444 if paths:
445 print(f"\n{len(paths)} conflicting file(s). "
446 f"Run 'muse checkout --ours/--theirs <file>' to resolve.")
447
448 return len(paths)
449
450
451 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
452 """Register the ``muse diff`` subcommand and its flags."""
453 parser = subparsers.add_parser(
454 "diff",
455 help="Compare working tree against HEAD, or compare two commits.",
456 description=__doc__,
457 formatter_class=argparse.RawDescriptionHelpFormatter,
458 )
459 parser.add_argument(
460 "commit_a", nargs="?", default=None,
461 help="Base commit ID (default: HEAD).",
462 )
463 parser.add_argument(
464 "commit_b", nargs="?", default=None,
465 help="Target commit ID (default: working tree).",
466 )
467 parser.add_argument(
468 "--path", "-p", dest="paths", action="append", default=[],
469 metavar="path",
470 help="Limit diff to this file or directory. Repeat for multiple paths.",
471 )
472 parser.add_argument(
473 "--staged", action="store_true",
474 help="Show staged changes vs HEAD (what will be committed).",
475 )
476 parser.add_argument(
477 "--unstaged", action="store_true",
478 help="Show working-tree changes not yet staged (working tree vs stage).",
479 )
480 parser.add_argument(
481 "--stat", action="store_true",
482 help="Show summary statistics only.",
483 )
484 parser.add_argument(
485 "--text", action="store_true",
486 help="Show line-level unified diff instead of semantic symbols.",
487 )
488 parser.add_argument(
489 "--exit-code", "-z", action="store_true", dest="exit_code",
490 help=(
491 "Exit with code 1 when changes are present, 0 when the working "
492 "tree is clean. Useful in CI pipelines and agent preflight checks."
493 ),
494 )
495 parser.add_argument(
496 "--format", "-f", default="text", dest="fmt",
497 help="Output format: text (default) or json.",
498 )
499 parser.add_argument(
500 "--json", action="store_const", const="json", dest="fmt",
501 help="Shorthand for --format json.",
502 )
503 parser.add_argument(
504 "--stash", action="store_true", dest="stash",
505 help=(
506 "Show the stashed changes vs HEAD. "
507 "Pass a positional index (default 0) to select an entry: "
508 "muse diff --stash 1"
509 ),
510 )
511 parser.add_argument(
512 "--conflict", action="store_true", dest="conflict",
513 help=(
514 "Show a Cohen-transform labeled diff for every conflicting file "
515 "in the current in-progress merge. For each conflict, renders "
516 "base→ours and base→theirs diffs side-by-side, with each hunk "
517 "annotated by its action ([inserted], [deleted], [modified]). "
518 "Exits 1 when no merge is in progress and --conflict is forced."
519 ),
520 )
521 parser.set_defaults(func=run, conflict=False)
522
523
524 # ── Manifest filter ───────────────────────────────────────────────────────────
525
526
527 def _filter_manifest(manifest: Manifest, paths: list[str]) -> Manifest:
528 """Return a copy of *manifest* restricted to entries matching *paths*.
529
530 Each entry in *paths* is treated as a prefix — it matches both exact file
531 paths (``muse/cli/commands/status.py``) and directory prefixes
532 (``muse/cli/``). An empty *paths* list returns the manifest unchanged.
533 """
534 if not paths:
535 return manifest
536 normalised = [p.rstrip("/") for p in paths]
537 return {
538 rel: oid
539 for rel, oid in manifest.items()
540 if any(rel == p or rel.startswith(p + "/") for p in normalised)
541 }
542
543
544 # ── Command entry point ───────────────────────────────────────────────────────
545
546
547 def run(args: argparse.Namespace) -> None:
548 """Show what has changed since the last commit.
549
550 Default: HEAD vs working tree (everything changed, staged or not).
551 Use ``--staged`` to see only what will be committed.
552 Use ``--unstaged`` to see only what is not yet staged.
553 Use ``--exit-code`` for scripting: exits 1 when changes exist, 0 when clean.
554
555 Agents should pass ``--json`` to receive a structured result::
556
557 {
558 "from_ref": "HEAD",
559 "to_ref": "working tree",
560 "from_commit_id": "<sha256> | null",
561 "to_commit_id": "<sha256> | null",
562 "has_changes": true,
563 "summary": "3 changes",
564 "added": ["path/to/new_file.py"],
565 "deleted": ["path/to/removed.py"],
566 "modified": ["path/to/changed.py"],
567 "total_changes": 3
568 }
569
570 ``--exit-code`` exits with 1 when changes exist and 0 when the working
571 tree is clean, regardless of ``--format``. Combine with ``--json`` for
572 fully machine-readable CI preflight output.
573 """
574 commit_a: str | None = args.commit_a
575 commit_b: str | None = args.commit_b
576 path_filter: list[str] = args.paths
577 staged: bool = args.staged
578 unstaged: bool = args.unstaged
579 stash: bool = args.stash
580 stat: bool = args.stat
581 text: bool = args.text
582 exit_code: bool = args.exit_code
583 fmt: str = args.fmt
584 conflict: bool = getattr(args, "conflict", False)
585 as_json = fmt == "json"
586
587 if stash and staged:
588 print("❌ --stash and --staged are mutually exclusive.", file=sys.stderr)
589 raise SystemExit(ExitCode.USER_ERROR)
590 if stash and unstaged:
591 print("❌ --stash and --unstaged are mutually exclusive.", file=sys.stderr)
592 raise SystemExit(ExitCode.USER_ERROR)
593 if staged and unstaged:
594 print("❌ --staged and --unstaged are mutually exclusive.", file=sys.stderr)
595 raise SystemExit(ExitCode.USER_ERROR)
596 if fmt not in ("text", "json"):
597 print(
598 f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.",
599 file=sys.stderr,
600 )
601 raise SystemExit(ExitCode.USER_ERROR)
602
603 root = require_repo()
604
605 # ── Cohen-transform conflict diff mode ────────────────────────────────────
606 # Activated by --conflict, or automatically when a merge is in progress and
607 # no positional refs are given. Renders a labeled two-sided diff for each
608 # conflicting file (base→ours and base→theirs, hunk-annotated).
609 if conflict or (not commit_a and not commit_b and not stash):
610 merge_state = read_merge_state(root)
611 if conflict and merge_state is None:
612 print(
613 "❌ --conflict requires an in-progress merge. "
614 "No MERGE_STATE.json found.",
615 file=sys.stderr,
616 )
617 raise SystemExit(ExitCode.USER_ERROR)
618 if merge_state is not None and conflict:
619 branch = read_current_branch(root)
620 ours_label = branch or "ours"
621 theirs_label = merge_state.other_branch or "theirs"
622 count = _print_conflict_diff(
623 root,
624 merge_state.base_commit,
625 merge_state.ours_commit,
626 merge_state.theirs_commit,
627 merge_state.conflict_paths,
628 path_filter,
629 ours_label=ours_label,
630 theirs_label=theirs_label,
631 fmt=fmt,
632 )
633 raise SystemExit(1 if count > 0 else 0)
634 # No merge in progress — fall through to normal diff logic.
635 # ── End conflict diff mode ────────────────────────────────────────────────
636 repo_id = read_repo_id(root)
637 branch = read_current_branch(root)
638 domain = read_domain(root)
639 plugin = resolve_plugin(root)
640
641 # Cached commit ID for each resolved ref — populated alongside manifests so
642 # agents can track exactly which commits were compared.
643 from_commit_id: str | None = None
644 to_commit_id: str | None = None
645
646 def _resolve_manifest(ref: str) -> tuple[dict[str, str], str | None]:
647 """Resolve a ref to (manifest, commit_id). Exits on unknown ref."""
648 resolved = resolve_commit_ref(root, repo_id, branch, ref)
649 if resolved is None:
650 print(f"⚠️ Commit '{sanitize_display(ref)}' not found.", file=sys.stderr)
651 raise SystemExit(ExitCode.USER_ERROR)
652 manifest = get_commit_snapshot_manifest(root, resolved.commit_id) or {}
653 return manifest, resolved.commit_id
654
655 # Track human-readable ref labels for JSON output so agents know exactly
656 # what was compared without having to re-parse positional arguments.
657 from_ref: str
658 to_ref: str
659
660 if stash:
661 # --stash: diff HEAD vs the stashed snapshot at index N.
662 # commit_a is reused as the optional index argument (default "0").
663 stash_index_raw: str | None = commit_a
664 entries = _load_stash(root)
665 if not entries:
666 msg = "no stash entries — run `muse stash` to save changes first"
667 if as_json:
668 print(json.dumps({"error": msg, "exit_code": ExitCode.USER_ERROR}))
669 else:
670 print(f"❌ {msg}", file=sys.stderr)
671 raise SystemExit(ExitCode.USER_ERROR)
672 idx = _resolve_index(entries, stash_index_raw)
673 entry = entries[idx]
674 label = f"stash@{{{idx}}}"
675 if entry.get("message"):
676 label += f": {entry['message']}"
677 head_files = get_head_snapshot_manifest(root, repo_id, branch) or {}
678 head_ref = resolve_commit_ref(root, repo_id, branch, None)
679 from_commit_id = head_ref.commit_id if head_ref else None
680 # Build the stash manifest: HEAD + stash delta + stash deletions.
681 stash_files: Manifest = {**head_files}
682 stash_files.update(entry["delta"])
683 for deleted_path in entry["deleted"]:
684 stash_files.pop(deleted_path, None)
685 base_snap = SnapshotManifest(
686 files=head_files,
687 domain=domain,
688 directories=directories_from_manifest(head_files),
689 )
690 target_snap = SnapshotManifest(
691 files=stash_files,
692 domain=domain,
693 directories=directories_from_manifest(stash_files),
694 )
695 from_ref, to_ref = "HEAD", label
696
697 elif commit_a is None:
698 head_files = get_head_snapshot_manifest(root, repo_id, branch) or {}
699 head_ref = resolve_commit_ref(root, repo_id, branch, None)
700 from_commit_id = head_ref.commit_id if head_ref else None
701
702 if staged and isinstance(plugin, StagePlugin):
703 # --staged: what will be committed (stage vs HEAD).
704 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files))
705 target_snap = plugin.snapshot(root)
706 from_ref, to_ref = "HEAD", "staged"
707 elif unstaged and isinstance(plugin, StagePlugin):
708 # --unstaged: working-tree changes not yet added to the stage.
709 base_snap = plugin.snapshot(root) # staged manifest
710 target_snap = plugin.workdir_snapshot(root)
711 from_ref, to_ref = "staged", "working tree"
712 elif isinstance(plugin, StagePlugin):
713 # Default with staging: HEAD vs full working tree.
714 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files))
715 target_snap = plugin.workdir_snapshot(root)
716 from_ref, to_ref = "HEAD", "working tree"
717 else:
718 # No staging support: HEAD vs working tree (original behaviour).
719 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files))
720 target_snap = plugin.snapshot(root)
721 from_ref, to_ref = "HEAD", "working tree"
722 elif commit_b is None:
723 # Single ref: diff HEAD vs that commit's snapshot.
724 head_files = get_head_snapshot_manifest(root, repo_id, branch) or {}
725 head_ref = resolve_commit_ref(root, repo_id, branch, None)
726 from_commit_id = head_ref.commit_id if head_ref else None
727 target_manifest, to_commit_id = _resolve_manifest(commit_a)
728 base_snap = SnapshotManifest(files=head_files, domain=domain, directories=directories_from_manifest(head_files))
729 target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest))
730 from_ref, to_ref = "HEAD", commit_a
731 else:
732 base_manifest, from_commit_id = _resolve_manifest(commit_a)
733 target_manifest, to_commit_id = _resolve_manifest(commit_b)
734 base_snap = SnapshotManifest(files=base_manifest, domain=domain, directories=directories_from_manifest(base_manifest))
735 target_snap = SnapshotManifest(files=target_manifest, domain=domain, directories=directories_from_manifest(target_manifest))
736 from_ref, to_ref = commit_a, commit_b
737
738 if path_filter:
739 filtered_base_files = _filter_manifest(base_snap["files"], path_filter)
740 filtered_target_files = _filter_manifest(target_snap["files"], path_filter)
741 base_snap = SnapshotManifest(
742 files=filtered_base_files,
743 domain=domain,
744 directories=directories_from_manifest(filtered_base_files),
745 )
746 target_snap = SnapshotManifest(
747 files=filtered_target_files,
748 domain=domain,
749 directories=directories_from_manifest(filtered_target_files),
750 )
751
752 if text and fmt != "json":
753 workdir = root if commit_a is None else None
754 changed = _print_text_diff(
755 base_snap["files"], target_snap["files"], root, workdir
756 )
757 if changed == 0:
758 print("No differences.")
759 if exit_code:
760 raise SystemExit(1 if changed > 0 else 0)
761 return
762
763 delta = plugin.diff(base_snap, target_snap, repo_root=root)
764
765 if fmt == "json":
766 # Categorize ops into added/deleted/modified, correctly handling the
767 # ``patch`` op that the code plugin emits for file-level changes.
768 # A ``patch`` with all-insert children = file added.
769 # A ``patch`` with all-delete children = file deleted.
770 # Anything else = file modified.
771 added: list[str] = []
772 deleted: list[str] = []
773 modified: list[str] = []
774 for op in delta["ops"]:
775 category = _op_category(op)
776 if category == "insert":
777 added.append(op["address"])
778 elif category == "delete":
779 deleted.append(op["address"])
780 else:
781 modified.append(op["address"])
782
783 has_changes = bool(delta["ops"])
784 print(json.dumps({
785 "from_ref": from_ref,
786 "to_ref": to_ref,
787 "from_commit_id": from_commit_id,
788 "to_commit_id": to_commit_id,
789 "has_changes": has_changes,
790 "summary": delta["summary"],
791 "added": sorted(added),
792 "deleted": sorted(deleted),
793 "modified": sorted(modified),
794 "total_changes": len(delta["ops"]),
795 }))
796 if exit_code:
797 raise SystemExit(1 if has_changes else 0)
798 return
799
800 if stat:
801 print(delta["summary"] if delta["ops"] else "No differences.")
802 if exit_code:
803 raise SystemExit(1 if delta["ops"] else 0)
804 return
805
806 changed = _print_structured_delta(delta["ops"])
807
808 if changed == 0:
809 print("No differences.")
810 else:
811 print(f"\n{delta['summary']}")
812
813 if exit_code:
814 raise SystemExit(1 if changed > 0 else 0)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago