"""``muse conflicts`` — list and triage unresolved merge conflicts. Shows all paths that remain in conflict after a ``muse merge`` that produced conflicts, grouped by source file for readability. Usage:: muse conflicts — all conflicts, grouped by file muse conflicts --json — machine-readable for agents muse conflicts --filter symbol — only symbol-level conflicts muse conflicts --filter file — only whole-file conflicts muse conflicts --filter deleted — only conflicts where one side deleted the file muse conflicts --filter modified — only symbol-body edit conflicts muse conflicts --count — print only the count, then exit muse conflicts --exit-code — exit 1 if any conflicts, 0 if none JSON output (``--format json`` or ``--json``):: { "merge_in_progress": true, "merge_from": "dev", "ours_commit": "", "theirs_commit": "", "base_commit": "", "conflict_count": 3, "total_conflict_count": 5, "conflicts": [ { "path": "src/billing.py::Invoice.charge", "file": "src/billing.py", "symbol": "Invoice.charge", "kind": "symbol" }, { "path": "alembic/versions/0004.py", "file": "alembic/versions/0004.py", "symbol": null, "kind": "file" } ], "next_steps": { "resolve_ours": "muse checkout --ours ", "resolve_theirs": "muse checkout --theirs ", "resolve_all_ours": "muse checkout --ours --all", "resolve_all_theirs": "muse checkout --theirs --all", "commit": "muse commit (once all conflicts are resolved)", "abort": "muse merge --abort" } } When no merge is in progress the JSON schema is the same with ``merge_in_progress: false``, ``conflict_count: 0``, ``conflicts: []``, and ``merge_from``/``ours_commit``/``theirs_commit``/``base_commit`` all ``null``. Agents should check ``conflict_count`` rather than the exit code for conditional logic. Exit codes:: 0 — success (conflicts listed, or no merge in progress, or all resolved) 1 — ``--exit-code`` flag set and at least one conflict remains 1 — repository error (not inside a Muse repo) """ from __future__ import annotations import argparse import json import os import sys from collections import defaultdict from muse.core.errors import ExitCode from muse.core.merge_engine import read_merge_state from muse.core.repo import require_repo from muse.core.validation import sanitize_display type _StepMap = dict[str, str] type _ConflictInfo = dict[str, str | None] type _ByFile = dict[str, list[_ConflictInfo]] def _use_color() -> bool: """Return True only when colour output is appropriate. Respects ``NO_COLOR`` (https://no-color.org/), ``TERM=dumb``, and the ``sys.stdout.isatty()`` check so raw ANSI never leaks into pipes or logs. """ if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb": return False return sys.stdout.isatty() def _bold(s: str) -> str: return f"\033[1m{s}\033[0m" if _use_color() else s def _yellow(s: str) -> str: return f"\033[33m{s}\033[0m" if _use_color() else s def _dim(s: str) -> str: return f"\033[2m{s}\033[0m" if _use_color() else s def _parse_conflict(path: str) -> _ConflictInfo: """Return ``{'path', 'file', 'symbol', 'kind'}`` for one conflict path. All string values are passed through ``sanitize_display`` before being returned so callers never receive raw ANSI sequences. """ clean_path = sanitize_display(path) if "::" in clean_path: file_part, symbol_part = clean_path.split("::", 1) return { "path": clean_path, "file": file_part, "symbol": symbol_part, "kind": "symbol", } return { "path": clean_path, "file": clean_path, "symbol": None, "kind": "file", } _NEXT_STEPS: _StepMap = { "resolve_ours": "muse checkout --ours ", "resolve_theirs": "muse checkout --theirs ", "resolve_all_ours": "muse checkout --ours --all", "resolve_all_theirs": "muse checkout --theirs --all", "commit": "muse commit (once all conflicts are resolved)", "abort": "muse merge --abort", } def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the ``muse conflicts`` subcommand and all its flags.""" parser = subparsers.add_parser( "conflicts", help="List unresolved merge conflicts.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--filter", "-f", dest="kind_filter", choices=["symbol", "file", "deleted", "modified", "all"], default="all", help=( "Narrow output: " "'symbol' = symbol-level conflicts only, " "'file' = whole-file conflicts only, " "'deleted' = paths where one side deleted the file, " "'modified' = symbol-body edit conflicts (non-deleted), " "'all' = everything (default)." ), ) parser.add_argument( "--count", "-n", action="store_true", help="Print only the number of (filtered) conflicts, then exit.", ) parser.add_argument( "--exit-code", "-z", action="store_true", dest="exit_code", help=( "Exit 1 when at least one (filtered) conflict remains, 0 when none. " "Useful in CI scripts: ``muse conflicts --exit-code || muse merge --abort``." ), ) parser.add_argument( "--format", default="text", dest="fmt", help="Output format: text (default) or json.", ) parser.add_argument( "--json", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """List all unresolved merge conflicts with next-step guidance. All conflict paths and branch names are sanitised before printing to prevent ANSI injection from crafted branch names or file paths. Agents should pass ``--format json`` for a machine-readable result. The ``--exit-code`` flag makes the command usable in CI shell scripts:: muse conflicts --exit-code || muse merge --abort The ``--filter`` flag narrows both the display and the count:: muse conflicts --filter symbol --count # how many symbol conflicts? muse conflicts --filter deleted # which files were deleted? Exit codes:: 0 — success 1 — ``--exit-code`` flag set and at least one (filtered) conflict remains 1 — not inside a Muse repo """ fmt: str = args.fmt kind_filter: str = args.kind_filter count_only: bool = args.count use_exit_code: bool = args.exit_code root = require_repo() merge_state = read_merge_state(root) if merge_state is None: if count_only: print("0") return if fmt == "json": print(json.dumps({ "merge_in_progress": False, "merge_from": None, "ours_commit": None, "theirs_commit": None, "base_commit": None, "conflict_count": 0, "total_conflict_count": 0, "conflicts": [], "next_steps": {}, })) else: print("✅ No merge in progress.") return all_conflicts = [_parse_conflict(p) for p in merge_state.conflict_paths] # Apply filter — 'deleted' and 'modified' are symbol-level sub-filters. # A 'deleted' conflict means one side removed the file entirely (kind='file'). # A 'modified' conflict means a symbol body was edited (kind='symbol'). if kind_filter == "symbol": conflicts = [c for c in all_conflicts if c["kind"] == "symbol"] elif kind_filter == "file": conflicts = [c for c in all_conflicts if c["kind"] == "file"] elif kind_filter == "deleted": # Whole-file deletions: kind='file' (no symbol qualifier) conflicts = [c for c in all_conflicts if c["kind"] == "file"] elif kind_filter == "modified": # Symbol-level body conflicts: kind='symbol' conflicts = [c for c in all_conflicts if c["kind"] == "symbol"] else: conflicts = all_conflicts conflict_count = len(conflicts) merge_from: str | None = ( sanitize_display(merge_state.other_branch) if merge_state.other_branch else None ) ours_commit: str | None = merge_state.ours_commit theirs_commit: str | None = merge_state.theirs_commit base_commit: str | None = merge_state.base_commit if count_only: print(str(conflict_count)) if use_exit_code and conflict_count > 0: raise SystemExit(ExitCode.USER_ERROR) return if fmt == "json": print(json.dumps({ "merge_in_progress": True, "merge_from": merge_from, "ours_commit": ours_commit, "theirs_commit": theirs_commit, "base_commit": base_commit, "conflict_count": conflict_count, "total_conflict_count": len(all_conflicts), "conflicts": conflicts, "next_steps": _NEXT_STEPS, })) if use_exit_code and conflict_count > 0: raise SystemExit(ExitCode.USER_ERROR) return # ── Text output ─────────────────────────────────────────────────────────── if conflict_count == 0 and len(all_conflicts) == 0: print("✅ All conflicts resolved — run `muse commit` to complete the merge.") if use_exit_code: return return if conflict_count == 0: print( f"✅ No conflicts match filter '{kind_filter}'. " f"Total remaining: {len(all_conflicts)}." ) return from_label = f" from '{_bold(merge_from)}'" if merge_from else "" print(f"\n⚠️ Merging{from_label} — {_bold(str(conflict_count))} unresolved conflict(s):\n") # Group by file for readability. by_file: _ByFile = defaultdict(list) for c in conflicts: by_file[str(c["file"])].append(c) for file_path in sorted(by_file): entries = by_file[file_path] file_conflicts = [e for e in entries if e["kind"] == "file"] sym_conflicts = [e for e in entries if e["kind"] == "symbol"] print(f" {_bold(file_path)}") for _ in file_conflicts: print(f" {_yellow('conflict')} (whole file)") for sc in sym_conflicts: print(f" {_yellow('conflict')} {_dim(str(sc['symbol']))}") print(f"\n Resolve individual paths:") print(f" muse checkout --ours # keep your version") print(f" muse checkout --theirs # keep their version") print(f"\n Resolve everything at once:") print(f" muse checkout --ours --all # accept all — keep ours") print(f" muse checkout --theirs --all # accept all — keep theirs") print(f"\n When done: muse commit") print(f" To cancel: muse merge --abort\n") if use_exit_code and conflict_count > 0: raise SystemExit(ExitCode.USER_ERROR)