conflicts.py python
321 lines 11.5 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 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 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago