gabriel / muse public
conflicts.py python
371 lines 13.5 KB
Raw
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