conflicts.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
| 1 | """``muse conflicts`` — list and triage unresolved merge conflicts. |
| 2 | |
| 3 | Shows all paths that remain in conflict after a ``muse merge`` that produced |
| 4 | conflicts, grouped by source file for readability. |
| 5 | |
| 6 | Usage:: |
| 7 | |
| 8 | muse conflicts — all conflicts, grouped by file |
| 9 | muse conflicts --json — machine-readable for agents |
| 10 | muse conflicts --filter symbol — only symbol-level conflicts |
| 11 | muse conflicts --filter file — only whole-file conflicts |
| 12 | muse conflicts --filter deleted — only conflicts where one side deleted the file |
| 13 | muse conflicts --filter modified — only symbol-body edit conflicts |
| 14 | muse conflicts --count — print only the count, then exit |
| 15 | muse conflicts --exit-code — exit 1 if any conflicts, 0 if none |
| 16 | |
| 17 | JSON output (``--format json`` or ``--json``):: |
| 18 | |
| 19 | { |
| 20 | "merge_in_progress": true, |
| 21 | "merge_from": "dev", |
| 22 | "ours_commit": "<sha256>", |
| 23 | "theirs_commit": "<sha256>", |
| 24 | "base_commit": "<sha256>", |
| 25 | "conflict_count": 3, |
| 26 | "total_conflict_count": 5, |
| 27 | "conflicts": [ |
| 28 | { |
| 29 | "path": "src/billing.py::Invoice.charge", |
| 30 | "file": "src/billing.py", |
| 31 | "symbol": "Invoice.charge", |
| 32 | "kind": "symbol" |
| 33 | }, |
| 34 | { |
| 35 | "path": "alembic/versions/0004.py", |
| 36 | "file": "alembic/versions/0004.py", |
| 37 | "symbol": null, |
| 38 | "kind": "file" |
| 39 | } |
| 40 | ], |
| 41 | "next_steps": { |
| 42 | "resolve_ours": "muse checkout --ours <path>", |
| 43 | "resolve_theirs": "muse checkout --theirs <path>", |
| 44 | "resolve_all_ours": "muse checkout --ours --all", |
| 45 | "resolve_all_theirs": "muse checkout --theirs --all", |
| 46 | "commit": "muse commit (once all conflicts are resolved)", |
| 47 | "abort": "muse merge --abort" |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | When no merge is in progress the JSON schema is the same with |
| 52 | ``merge_in_progress: false``, ``conflict_count: 0``, ``conflicts: []``, |
| 53 | and ``merge_from``/``ours_commit``/``theirs_commit``/``base_commit`` all ``null``. |
| 54 | Agents should check ``conflict_count`` rather than the exit code for conditional logic. |
| 55 | |
| 56 | Exit codes:: |
| 57 | |
| 58 | 0 — success (conflicts listed, or no merge in progress, or all resolved) |
| 59 | 1 — ``--exit-code`` flag set and at least one conflict remains |
| 60 | 1 — repository error (not inside a Muse repo) |
| 61 | """ |
| 62 | from __future__ import annotations |
| 63 | |
| 64 | import argparse |
| 65 | import json |
| 66 | import os |
| 67 | import sys |
| 68 | from collections import defaultdict |
| 69 | |
| 70 | from muse.core.errors import ExitCode |
| 71 | from muse.core.merge_engine import read_merge_state |
| 72 | from muse.core.repo import require_repo |
| 73 | from muse.core.validation import sanitize_display |
| 74 | |
| 75 | |
| 76 | |
| 77 | |
| 78 | type _StepMap = dict[str, str] |
| 79 | type _ConflictInfo = dict[str, str | None] |
| 80 | type _ByFile = dict[str, list[_ConflictInfo]] |
| 81 | def _use_color() -> bool: |
| 82 | """Return True only when colour output is appropriate. |
| 83 | |
| 84 | Respects ``NO_COLOR`` (https://no-color.org/), ``TERM=dumb``, and the |
| 85 | ``sys.stdout.isatty()`` check so raw ANSI never leaks into pipes or logs. |
| 86 | """ |
| 87 | if os.environ.get("NO_COLOR") or os.environ.get("TERM") == "dumb": |
| 88 | return False |
| 89 | return sys.stdout.isatty() |
| 90 | |
| 91 | |
| 92 | def _bold(s: str) -> str: |
| 93 | return f"\033[1m{s}\033[0m" if _use_color() else s |
| 94 | |
| 95 | |
| 96 | def _yellow(s: str) -> str: |
| 97 | return f"\033[33m{s}\033[0m" if _use_color() else s |
| 98 | |
| 99 | |
| 100 | def _dim(s: str) -> str: |
| 101 | return f"\033[2m{s}\033[0m" if _use_color() else s |
| 102 | |
| 103 | |
| 104 | def _parse_conflict(path: str) -> _ConflictInfo: |
| 105 | """Return ``{'path', 'file', 'symbol', 'kind'}`` for one conflict path. |
| 106 | |
| 107 | All string values are passed through ``sanitize_display`` before being |
| 108 | returned so callers never receive raw ANSI sequences. |
| 109 | """ |
| 110 | clean_path = sanitize_display(path) |
| 111 | if "::" in clean_path: |
| 112 | file_part, symbol_part = clean_path.split("::", 1) |
| 113 | return { |
| 114 | "path": clean_path, |
| 115 | "file": file_part, |
| 116 | "symbol": symbol_part, |
| 117 | "kind": "symbol", |
| 118 | } |
| 119 | return { |
| 120 | "path": clean_path, |
| 121 | "file": clean_path, |
| 122 | "symbol": None, |
| 123 | "kind": "file", |
| 124 | } |
| 125 | |
| 126 | |
| 127 | _NEXT_STEPS: _StepMap = { |
| 128 | "resolve_ours": "muse checkout --ours <path>", |
| 129 | "resolve_theirs": "muse checkout --theirs <path>", |
| 130 | "resolve_all_ours": "muse checkout --ours --all", |
| 131 | "resolve_all_theirs": "muse checkout --theirs --all", |
| 132 | "commit": "muse commit (once all conflicts are resolved)", |
| 133 | "abort": "muse merge --abort", |
| 134 | } |
| 135 | |
| 136 | |
| 137 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 138 | """Register the ``muse conflicts`` subcommand and all its flags.""" |
| 139 | parser = subparsers.add_parser( |
| 140 | "conflicts", |
| 141 | help="List unresolved merge conflicts.", |
| 142 | description=__doc__, |
| 143 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 144 | ) |
| 145 | parser.add_argument( |
| 146 | "--filter", "-f", dest="kind_filter", |
| 147 | choices=["symbol", "file", "deleted", "modified", "all"], |
| 148 | default="all", |
| 149 | help=( |
| 150 | "Narrow output: " |
| 151 | "'symbol' = symbol-level conflicts only, " |
| 152 | "'file' = whole-file conflicts only, " |
| 153 | "'deleted' = paths where one side deleted the file, " |
| 154 | "'modified' = symbol-body edit conflicts (non-deleted), " |
| 155 | "'all' = everything (default)." |
| 156 | ), |
| 157 | ) |
| 158 | parser.add_argument( |
| 159 | "--count", "-n", action="store_true", |
| 160 | help="Print only the number of (filtered) conflicts, then exit.", |
| 161 | ) |
| 162 | parser.add_argument( |
| 163 | "--exit-code", "-z", action="store_true", dest="exit_code", |
| 164 | help=( |
| 165 | "Exit 1 when at least one (filtered) conflict remains, 0 when none. " |
| 166 | "Useful in CI scripts: ``muse conflicts --exit-code || muse merge --abort``." |
| 167 | ), |
| 168 | ) |
| 169 | parser.add_argument( |
| 170 | "--format", default="text", dest="fmt", |
| 171 | help="Output format: text (default) or json.", |
| 172 | ) |
| 173 | parser.add_argument( |
| 174 | "--json", action="store_const", const="json", dest="fmt", |
| 175 | help="Shorthand for --format json.", |
| 176 | ) |
| 177 | parser.set_defaults(func=run) |
| 178 | |
| 179 | |
| 180 | def run(args: argparse.Namespace) -> None: |
| 181 | """List all unresolved merge conflicts with next-step guidance. |
| 182 | |
| 183 | All conflict paths and branch names are sanitised before printing to |
| 184 | prevent ANSI injection from crafted branch names or file paths. |
| 185 | |
| 186 | Agents should pass ``--format json`` for a machine-readable result. |
| 187 | The ``--exit-code`` flag makes the command usable in CI shell scripts:: |
| 188 | |
| 189 | muse conflicts --exit-code || muse merge --abort |
| 190 | |
| 191 | The ``--filter`` flag narrows both the display and the count:: |
| 192 | |
| 193 | muse conflicts --filter symbol --count # how many symbol conflicts? |
| 194 | muse conflicts --filter deleted # which files were deleted? |
| 195 | |
| 196 | Exit codes:: |
| 197 | |
| 198 | 0 — success |
| 199 | 1 — ``--exit-code`` flag set and at least one (filtered) conflict remains |
| 200 | 1 — not inside a Muse repo |
| 201 | """ |
| 202 | fmt: str = args.fmt |
| 203 | kind_filter: str = args.kind_filter |
| 204 | count_only: bool = args.count |
| 205 | use_exit_code: bool = args.exit_code |
| 206 | |
| 207 | root = require_repo() |
| 208 | merge_state = read_merge_state(root) |
| 209 | |
| 210 | if merge_state is None: |
| 211 | if count_only: |
| 212 | print("0") |
| 213 | return |
| 214 | if fmt == "json": |
| 215 | print(json.dumps({ |
| 216 | "merge_in_progress": False, |
| 217 | "merge_from": None, |
| 218 | "ours_commit": None, |
| 219 | "theirs_commit": None, |
| 220 | "base_commit": None, |
| 221 | "conflict_count": 0, |
| 222 | "total_conflict_count": 0, |
| 223 | "conflicts": [], |
| 224 | "next_steps": {}, |
| 225 | })) |
| 226 | else: |
| 227 | print("✅ No merge in progress.") |
| 228 | return |
| 229 | |
| 230 | all_conflicts = [_parse_conflict(p) for p in merge_state.conflict_paths] |
| 231 | |
| 232 | # Apply filter — 'deleted' and 'modified' are symbol-level sub-filters. |
| 233 | # A 'deleted' conflict means one side removed the file entirely (kind='file'). |
| 234 | # A 'modified' conflict means a symbol body was edited (kind='symbol'). |
| 235 | if kind_filter == "symbol": |
| 236 | conflicts = [c for c in all_conflicts if c["kind"] == "symbol"] |
| 237 | elif kind_filter == "file": |
| 238 | conflicts = [c for c in all_conflicts if c["kind"] == "file"] |
| 239 | elif kind_filter == "deleted": |
| 240 | # Whole-file deletions: kind='file' (no symbol qualifier) |
| 241 | conflicts = [c for c in all_conflicts if c["kind"] == "file"] |
| 242 | elif kind_filter == "modified": |
| 243 | # Symbol-level body conflicts: kind='symbol' |
| 244 | conflicts = [c for c in all_conflicts if c["kind"] == "symbol"] |
| 245 | else: |
| 246 | conflicts = all_conflicts |
| 247 | |
| 248 | conflict_count = len(conflicts) |
| 249 | merge_from: str | None = ( |
| 250 | sanitize_display(merge_state.other_branch) if merge_state.other_branch else None |
| 251 | ) |
| 252 | ours_commit: str | None = merge_state.ours_commit |
| 253 | theirs_commit: str | None = merge_state.theirs_commit |
| 254 | base_commit: str | None = merge_state.base_commit |
| 255 | |
| 256 | if count_only: |
| 257 | print(str(conflict_count)) |
| 258 | if use_exit_code and conflict_count > 0: |
| 259 | raise SystemExit(ExitCode.USER_ERROR) |
| 260 | return |
| 261 | |
| 262 | if fmt == "json": |
| 263 | print(json.dumps({ |
| 264 | "merge_in_progress": True, |
| 265 | "merge_from": merge_from, |
| 266 | "ours_commit": ours_commit, |
| 267 | "theirs_commit": theirs_commit, |
| 268 | "base_commit": base_commit, |
| 269 | "conflict_count": conflict_count, |
| 270 | "total_conflict_count": len(all_conflicts), |
| 271 | "conflicts": conflicts, |
| 272 | "next_steps": _NEXT_STEPS, |
| 273 | })) |
| 274 | if use_exit_code and conflict_count > 0: |
| 275 | raise SystemExit(ExitCode.USER_ERROR) |
| 276 | return |
| 277 | |
| 278 | # ── Text output ─────────────────────────────────────────────────────────── |
| 279 | if conflict_count == 0 and len(all_conflicts) == 0: |
| 280 | print("✅ All conflicts resolved — run `muse commit` to complete the merge.") |
| 281 | if use_exit_code: |
| 282 | return |
| 283 | return |
| 284 | |
| 285 | if conflict_count == 0: |
| 286 | print( |
| 287 | f"✅ No conflicts match filter '{kind_filter}'. " |
| 288 | f"Total remaining: {len(all_conflicts)}." |
| 289 | ) |
| 290 | return |
| 291 | |
| 292 | from_label = f" from '{_bold(merge_from)}'" if merge_from else "" |
| 293 | print(f"\n⚠️ Merging{from_label} — {_bold(str(conflict_count))} unresolved conflict(s):\n") |
| 294 | |
| 295 | # Group by file for readability. |
| 296 | by_file: _ByFile = defaultdict(list) |
| 297 | for c in conflicts: |
| 298 | by_file[str(c["file"])].append(c) |
| 299 | |
| 300 | for file_path in sorted(by_file): |
| 301 | entries = by_file[file_path] |
| 302 | file_conflicts = [e for e in entries if e["kind"] == "file"] |
| 303 | sym_conflicts = [e for e in entries if e["kind"] == "symbol"] |
| 304 | |
| 305 | print(f" {_bold(file_path)}") |
| 306 | for _ in file_conflicts: |
| 307 | print(f" {_yellow('conflict')} (whole file)") |
| 308 | for sc in sym_conflicts: |
| 309 | print(f" {_yellow('conflict')} {_dim(str(sc['symbol']))}") |
| 310 | |
| 311 | print(f"\n Resolve individual paths:") |
| 312 | print(f" muse checkout --ours <path> # keep your version") |
| 313 | print(f" muse checkout --theirs <path> # keep their version") |
| 314 | print(f"\n Resolve everything at once:") |
| 315 | print(f" muse checkout --ours --all # accept all — keep ours") |
| 316 | print(f" muse checkout --theirs --all # accept all — keep theirs") |
| 317 | print(f"\n When done: muse commit") |
| 318 | print(f" To cancel: muse merge --abort\n") |
| 319 | |
| 320 | if use_exit_code and conflict_count > 0: |
| 321 | raise SystemExit(ExitCode.USER_ERROR) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
32 days ago