conflicts.py
python
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
22 hours 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 | "duration_ms": 0.003, |
| 50 | "exit_code": 0 |
| 51 | } |
| 52 | |
| 53 | When no merge is in progress the JSON schema is the same with |
| 54 | ``merge_in_progress: false``, ``conflict_count: 0``, ``conflicts: []``, |
| 55 | and ``merge_from``/``ours_commit``/``theirs_commit``/``base_commit`` all ``null``. |
| 56 | Agents should check ``conflict_count`` rather than the exit code for conditional logic. |
| 57 | |
| 58 | ``duration_ms`` |
| 59 | Wall-clock time from argument parsing to output. |
| 60 | ``exit_code`` |
| 61 | Mirrors the process exit code: ``0`` normally; ``1`` when ``--exit-code`` |
| 62 | is set and at least one (filtered) conflict remains. |
| 63 | |
| 64 | Exit codes:: |
| 65 | |
| 66 | 0 — success (conflicts listed, or no merge in progress, or all resolved) |
| 67 | 1 — ``--exit-code`` flag set and at least one conflict remains |
| 68 | 1 — repository error (not inside a Muse repo) |
| 69 | """ |
| 70 | |
| 71 | import argparse |
| 72 | import json |
| 73 | from collections import defaultdict |
| 74 | |
| 75 | from muse.core.errors import ExitCode |
| 76 | from muse.core.merge_engine import read_merge_state |
| 77 | from muse.core.repo import require_repo |
| 78 | from muse.core.terminal import use_color |
| 79 | |
| 80 | _use_color = use_color |
| 81 | from muse.core.validation import sanitize_display |
| 82 | from muse.core.timing import start_timer |
| 83 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 84 | from typing import TypedDict |
| 85 | |
| 86 | type _StepMap = dict[str, str] |
| 87 | type _ByFile = dict[str, list["_ConflictInfo"]] |
| 88 | |
| 89 | class _ConflictInfo(TypedDict): |
| 90 | """One entry in the conflicts list.""" |
| 91 | |
| 92 | path: str |
| 93 | file: str |
| 94 | symbol: str | None |
| 95 | kind: str # "symbol" | "file" |
| 96 | |
| 97 | class _ConflictsJson(EnvelopeJson): |
| 98 | """JSON output for ``muse conflicts --json``.""" |
| 99 | |
| 100 | merge_in_progress: bool |
| 101 | merge_from: str | None |
| 102 | ours_commit: str | None |
| 103 | theirs_commit: str | None |
| 104 | base_commit: str | None |
| 105 | conflict_count: int |
| 106 | total_conflict_count: int |
| 107 | resolved_count: int |
| 108 | conflicts: list[_ConflictInfo] |
| 109 | resolved_conflicts: list[_ConflictInfo] |
| 110 | next_steps: dict[str, str] |
| 111 | |
| 112 | def _bold(s: str) -> str: |
| 113 | return f"\033[1m{s}\033[0m" if use_color() else s |
| 114 | |
| 115 | def _yellow(s: str) -> str: |
| 116 | return f"\033[33m{s}\033[0m" if use_color() else s |
| 117 | |
| 118 | def _dim(s: str) -> str: |
| 119 | return f"\033[2m{s}\033[0m" if use_color() else s |
| 120 | |
| 121 | def _parse_conflict(path: str) -> _ConflictInfo: |
| 122 | """Return ``{'path', 'file', 'symbol', 'kind'}`` for one conflict path. |
| 123 | |
| 124 | All string values are passed through ``sanitize_display`` before being |
| 125 | returned so callers never receive raw ANSI sequences. |
| 126 | """ |
| 127 | clean_path = sanitize_display(path) |
| 128 | if "::" in clean_path: |
| 129 | file_part, symbol_part = clean_path.split("::", 1) |
| 130 | return { |
| 131 | "path": clean_path, |
| 132 | "file": file_part, |
| 133 | "symbol": symbol_part, |
| 134 | "kind": "symbol", |
| 135 | } |
| 136 | return { |
| 137 | "path": clean_path, |
| 138 | "file": clean_path, |
| 139 | "symbol": None, |
| 140 | "kind": "file", |
| 141 | } |
| 142 | |
| 143 | _NEXT_STEPS: _StepMap = { |
| 144 | "resolve_ours": "muse checkout --ours <path>", |
| 145 | "resolve_theirs": "muse checkout --theirs <path>", |
| 146 | "resolve_all_ours": "muse checkout --ours --all", |
| 147 | "resolve_all_theirs": "muse checkout --theirs --all", |
| 148 | "commit": "muse commit (once all conflicts are resolved)", |
| 149 | "abort": "muse merge --abort", |
| 150 | } |
| 151 | |
| 152 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 153 | """Register the ``muse conflicts`` subcommand and all its flags.""" |
| 154 | parser = subparsers.add_parser( |
| 155 | "conflicts", |
| 156 | help="List unresolved merge conflicts.", |
| 157 | description=__doc__, |
| 158 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 159 | ) |
| 160 | parser.add_argument( |
| 161 | "--filter", "-f", dest="kind_filter", |
| 162 | choices=["symbol", "file", "deleted", "modified", "all"], |
| 163 | default="all", |
| 164 | help=( |
| 165 | "Narrow output: " |
| 166 | "'symbol' = symbol-level conflicts only, " |
| 167 | "'file' = whole-file conflicts only, " |
| 168 | "'deleted' = paths where one side deleted the file, " |
| 169 | "'modified' = symbol-body edit conflicts (non-deleted), " |
| 170 | "'all' = everything (default)." |
| 171 | ), |
| 172 | ) |
| 173 | parser.add_argument( |
| 174 | "--count", "-n", action="store_true", |
| 175 | help="Print only the number of (filtered) conflicts, then exit.", |
| 176 | ) |
| 177 | parser.add_argument( |
| 178 | "--exit-code", "-z", action="store_true", dest="exit_code", |
| 179 | help=( |
| 180 | "Exit 1 when at least one (filtered) conflict remains, 0 when none. " |
| 181 | "Useful in CI scripts: ``muse conflicts --exit-code || muse merge --abort``." |
| 182 | ), |
| 183 | ) |
| 184 | parser.add_argument( |
| 185 | "--json", "-j", action="store_true", dest="json_out", |
| 186 | help="Emit machine-readable JSON instead of human text.", |
| 187 | ) |
| 188 | parser.set_defaults(func=run) |
| 189 | |
| 190 | def run(args: argparse.Namespace) -> None: |
| 191 | """List all unresolved merge conflicts with next-step guidance. |
| 192 | |
| 193 | Reads the current MERGE_STATE and prints each conflicting path along with |
| 194 | the conflict kind and suggested resolution commands. Use ``--exit-code`` in |
| 195 | CI scripts to gate on remaining conflicts; use ``--filter`` to narrow by |
| 196 | conflict kind (``"symbol"``, ``"binary"``, ``"deleted"``, etc.). |
| 197 | |
| 198 | Agent quickstart |
| 199 | ---------------- |
| 200 | :: |
| 201 | |
| 202 | muse conflicts --json |
| 203 | muse conflicts --exit-code --json |
| 204 | muse conflicts --filter symbol --count --json |
| 205 | |
| 206 | JSON fields |
| 207 | ----------- |
| 208 | merge_in_progress ``true`` if a merge is currently in progress. |
| 209 | from_branch Branch being merged in. |
| 210 | conflicts List of conflict objects: ``path``, ``kind``. |
| 211 | count Number of conflicts (after any ``--filter``). |
| 212 | |
| 213 | Exit codes |
| 214 | ---------- |
| 215 | 0 Success (conflicts may exist; use ``--exit-code`` to gate on them). |
| 216 | 1 ``--exit-code`` set and conflicts remain; or not inside a Muse repository. |
| 217 | """ |
| 218 | elapsed = start_timer() |
| 219 | json_out: bool = args.json_out |
| 220 | kind_filter: str = args.kind_filter |
| 221 | count_only: bool = args.count |
| 222 | use_exit_code: bool = args.exit_code |
| 223 | |
| 224 | root = require_repo() |
| 225 | merge_state = read_merge_state(root) |
| 226 | |
| 227 | if merge_state is None: |
| 228 | if count_only: |
| 229 | print("0") |
| 230 | return |
| 231 | if json_out: |
| 232 | print(json.dumps(_ConflictsJson( |
| 233 | **make_envelope(elapsed), |
| 234 | merge_in_progress=False, |
| 235 | merge_from=None, |
| 236 | ours_commit=None, |
| 237 | theirs_commit=None, |
| 238 | base_commit=None, |
| 239 | conflict_count=0, |
| 240 | total_conflict_count=0, |
| 241 | resolved_count=0, |
| 242 | conflicts=[], |
| 243 | resolved_conflicts=[], |
| 244 | next_steps={}, |
| 245 | ))) |
| 246 | else: |
| 247 | print("✅ No merge in progress.") |
| 248 | return |
| 249 | |
| 250 | all_conflicts = [_parse_conflict(p) for p in merge_state.conflict_paths] |
| 251 | |
| 252 | # Resolved conflicts: in original_conflict_paths but no longer in conflict_paths. |
| 253 | current_set = set(merge_state.conflict_paths) |
| 254 | resolved_conflicts = [ |
| 255 | _parse_conflict(p) |
| 256 | for p in merge_state.original_conflict_paths |
| 257 | if p not in current_set |
| 258 | ] |
| 259 | |
| 260 | # Apply filter — 'deleted' and 'modified' are symbol-level sub-filters. |
| 261 | # A 'deleted' conflict means one side removed the file entirely (kind='file'). |
| 262 | # A 'modified' conflict means a symbol body was edited (kind='symbol'). |
| 263 | if kind_filter == "symbol": |
| 264 | conflicts = [c for c in all_conflicts if c["kind"] == "symbol"] |
| 265 | elif kind_filter == "file": |
| 266 | conflicts = [c for c in all_conflicts if c["kind"] == "file"] |
| 267 | elif kind_filter == "deleted": |
| 268 | # Whole-file deletions: kind='file' (no symbol qualifier) |
| 269 | conflicts = [c for c in all_conflicts if c["kind"] == "file"] |
| 270 | elif kind_filter == "modified": |
| 271 | # Symbol-level body conflicts: kind='symbol' |
| 272 | conflicts = [c for c in all_conflicts if c["kind"] == "symbol"] |
| 273 | else: |
| 274 | conflicts = all_conflicts |
| 275 | |
| 276 | conflict_count = len(conflicts) |
| 277 | merge_from: str | None = ( |
| 278 | sanitize_display(merge_state.other_branch) if merge_state.other_branch else None |
| 279 | ) |
| 280 | ours_commit: str | None = merge_state.ours_commit |
| 281 | theirs_commit: str | None = merge_state.theirs_commit |
| 282 | base_commit: str | None = merge_state.base_commit |
| 283 | |
| 284 | if count_only: |
| 285 | print(str(conflict_count)) |
| 286 | if use_exit_code and conflict_count > 0: |
| 287 | raise SystemExit(ExitCode.USER_ERROR) |
| 288 | return |
| 289 | |
| 290 | if json_out: |
| 291 | _exit_code = 1 if (use_exit_code and conflict_count > 0) else 0 |
| 292 | print(json.dumps(_ConflictsJson( |
| 293 | **make_envelope(elapsed, exit_code=_exit_code), |
| 294 | merge_in_progress=True, |
| 295 | merge_from=merge_from, |
| 296 | ours_commit=ours_commit, |
| 297 | theirs_commit=theirs_commit, |
| 298 | base_commit=base_commit, |
| 299 | conflict_count=conflict_count, |
| 300 | total_conflict_count=len(all_conflicts), |
| 301 | resolved_count=len(resolved_conflicts), |
| 302 | conflicts=conflicts, |
| 303 | resolved_conflicts=resolved_conflicts, |
| 304 | next_steps=_NEXT_STEPS, |
| 305 | ))) |
| 306 | if _exit_code: |
| 307 | raise SystemExit(ExitCode.USER_ERROR) |
| 308 | return |
| 309 | |
| 310 | # ── Text output ─────────────────────────────────────────────────────────── |
| 311 | if conflict_count == 0 and len(all_conflicts) == 0: |
| 312 | print("✅ All conflicts resolved — run `muse commit` to complete the merge.") |
| 313 | if use_exit_code: |
| 314 | return |
| 315 | return |
| 316 | |
| 317 | if conflict_count == 0: |
| 318 | print( |
| 319 | f"✅ No conflicts match filter '{kind_filter}'. " |
| 320 | f"Total remaining: {len(all_conflicts)}." |
| 321 | ) |
| 322 | return |
| 323 | |
| 324 | from_label = f" from '{_bold(merge_from)}'" if merge_from else "" |
| 325 | n_resolved = len(resolved_conflicts) |
| 326 | progress = ( |
| 327 | f" ({n_resolved} resolved)" if n_resolved else "" |
| 328 | ) |
| 329 | print(f"\n⚠️ Merging{from_label} — {_bold(str(conflict_count))} unresolved conflict(s){progress}:\n") |
| 330 | |
| 331 | # Group by file for readability. |
| 332 | by_file: _ByFile = defaultdict(list) |
| 333 | for c in conflicts: |
| 334 | by_file[str(c["file"])].append(c) |
| 335 | |
| 336 | for file_path in sorted(by_file): |
| 337 | entries = by_file[file_path] |
| 338 | file_conflicts = [e for e in entries if e["kind"] == "file"] |
| 339 | sym_conflicts = [e for e in entries if e["kind"] == "symbol"] |
| 340 | |
| 341 | print(f" {_bold(file_path)}") |
| 342 | for _ in file_conflicts: |
| 343 | print(f" {_yellow('conflict')} (whole file)") |
| 344 | for sc in sym_conflicts: |
| 345 | print(f" {_yellow('conflict')} {_dim(str(sc['symbol']))}") |
| 346 | |
| 347 | if n_resolved: |
| 348 | print(f"\n {n_resolved} already resolved (run `muse conflicts --filter all` to see all):") |
| 349 | by_resolved: _ByFile = defaultdict(list) |
| 350 | for c in resolved_conflicts: |
| 351 | by_resolved[str(c["file"])].append(c) |
| 352 | for file_path in sorted(by_resolved): |
| 353 | entries = by_resolved[file_path] |
| 354 | print(f" {_dim(file_path)}") |
| 355 | for sc in entries: |
| 356 | sym = sc["symbol"] |
| 357 | suffix = f" {_dim(str(sym))}" if sym else " (whole file)" |
| 358 | print(f" ✅ resolved{suffix}") |
| 359 | |
| 360 | print(f"\n Resolve individual paths:") |
| 361 | print(f" muse resolve <path> # after manual edit") |
| 362 | print(f" muse checkout --ours <path> # keep your version") |
| 363 | print(f" muse checkout --theirs <path> # keep their version") |
| 364 | print(f"\n Resolve everything at once:") |
| 365 | print(f" muse checkout --ours --all # accept all — keep ours") |
| 366 | print(f" muse checkout --theirs --all # accept all — keep theirs") |
| 367 | print(f"\n When done: muse commit") |
| 368 | print(f" To cancel: muse merge --abort\n") |
| 369 | |
| 370 | if use_exit_code and conflict_count > 0: |
| 371 | raise SystemExit(ExitCode.USER_ERROR) |
File History
7 commits
sha256:18b983389ee1b55900fcd799bfbb496552d2e3ecded9d18cefbfef188947a12e
chore: remove blob-debug test marker file
Sonnet 4.6
22 hours ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
8 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
10 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
24 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
30 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
30 days ago