gabriel / muse public
resolve.py python
228 lines 7.9 KB
Raw
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor ⚠ breaking 16 days ago
1 """``muse resolve`` — mark conflict paths as resolved after manual editing.
2
3 After manually editing a conflicted file (or accepting one side with
4 ``muse checkout --ours / --theirs``), use ``muse resolve`` to remove it from
5 the conflict list so Muse knows the path is clean.
6
7 Usage::
8
9 muse resolve <path> — mark all conflicts in a file as resolved
10 muse resolve <path>::<symbol> — mark one symbol-level conflict as resolved
11 muse resolve --all — mark every remaining conflict as resolved
12 muse resolve --json — machine-readable output
13
14 When a path still has active conflict markers in the working tree, Muse
15 records it as resolved anyway (you are responsible for editing first).
16 The file is staged automatically — just commit when all conflicts are clear::
17
18 muse resolve src/billing.py
19 muse commit -m "merge: resolve billing conflicts"
20
21 JSON output (``--json``)::
22
23 {
24 "resolved": ["src/billing.py::Invoice.charge", "src/billing.py::Invoice.refund"],
25 "remaining": 1,
26 "ready_to_commit": false,
27 "duration_ms": 0.4,
28 "exit_code": 0
29 }
30
31 Exit codes::
32
33 0 — success (resolved, nothing to resolve, or --all with 0 remaining)
34 1 — no merge in progress, or target not found
35 """
36
37 from __future__ import annotations
38
39 import argparse
40 import json
41 import pathlib
42 from typing import Literal, TypedDict
43
44 from muse.core.errors import ExitCode
45 from muse.core.merge_engine import read_merge_state, resolve_path, resolve_symbol
46 from muse.core.repo import require_repo
47 from muse.core.timing import start_timer
48 from muse.core.envelope import EnvelopeJson, make_envelope
49 from muse.core.validation import sanitize_display
50
51
52 class _ResolveJson(EnvelopeJson):
53 resolved: list[str]
54 remaining: int
55 ready_to_commit: bool
56
57
58 def _stage_files(root: pathlib.Path, rel_paths: list[str]) -> None:
59 """Stage resolved files into the code index (no-op for non-code domains)."""
60 from muse.plugins.registry import read_domain
61 if read_domain(root) != "code":
62 return
63 from muse.core.object_store import write_object
64 from muse.core.types import blob_id as _blob_id
65 from muse.plugins.code.stage import make_entry, read_stage, write_stage
66 from muse.cli.commands.code_stage import _head_manifest
67
68 head = _head_manifest(root)
69 stage = dict(read_stage(root))
70 changed = False
71 for rel_path in rel_paths:
72 full_path = root / rel_path
73 if not full_path.exists():
74 continue
75 content = full_path.read_bytes()
76 oid = _blob_id(content)
77 write_object(root, oid, content)
78 mode: Literal["A", "M", "D"] = "M" if rel_path in head else "A"
79 stage[rel_path] = make_entry(oid, mode)
80 changed = True
81 if changed:
82 write_stage(root, stage)
83
84
85 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
86 parser = subparsers.add_parser(
87 "resolve",
88 help="Mark conflict paths as resolved after manual editing.",
89 description=__doc__,
90 formatter_class=argparse.RawDescriptionHelpFormatter,
91 )
92 parser.add_argument(
93 "target",
94 nargs="?",
95 metavar="PATH[::SYMBOL]",
96 help=(
97 "Path to mark resolved: plain 'file.py' resolves all conflicts in that "
98 "file; 'file.py::Symbol' resolves exactly one symbol-level conflict."
99 ),
100 )
101 parser.add_argument(
102 "--all", "-a", action="store_true", dest="resolve_all",
103 help="Resolve every remaining conflict at once.",
104 )
105 parser.add_argument(
106 "--json", "-j", action="store_true", dest="json_out",
107 help="Emit machine-readable JSON instead of human text.",
108 )
109 parser.set_defaults(func=run)
110
111
112 def run(args: argparse.Namespace) -> None:
113 """Mark conflict paths as resolved after manual editing."""
114 import sys
115
116 elapsed = start_timer()
117 target: str | None = args.target
118 resolve_all: bool = args.resolve_all
119 json_out: bool = args.json_out
120
121 if not target and not resolve_all:
122 print(
123 "❌ Specify a path (e.g. 'muse resolve src/billing.py') "
124 "or use --all to resolve every conflict.",
125 file=sys.stderr,
126 )
127 raise SystemExit(ExitCode.USER_ERROR)
128
129 if target and resolve_all:
130 print("❌ Cannot combine a specific path with --all.", file=sys.stderr)
131 raise SystemExit(ExitCode.USER_ERROR)
132
133 root = require_repo()
134 merge_state = read_merge_state(root)
135
136 if merge_state is None:
137 if json_out:
138 print(json.dumps(_ResolveJson(
139 **make_envelope(elapsed, exit_code=ExitCode.USER_ERROR),
140 resolved=[],
141 remaining=0,
142 ready_to_commit=False,
143 )))
144 else:
145 print("❌ No merge in progress — nothing to resolve.", file=sys.stderr)
146 raise SystemExit(ExitCode.USER_ERROR)
147
148 resolved: list[str] = []
149
150 if resolve_all:
151 # Drain the entire conflict list, file by file.
152 paths_to_clear = list(merge_state.conflict_paths)
153 staged_files: list[str] = []
154 for entry in paths_to_clear:
155 file_part = entry.split("::")[0] if "::" in entry else entry
156 cleared = resolve_path(root, file_part)
157 for c in cleared:
158 if c not in resolved:
159 resolved.append(c)
160 if cleared and file_part not in staged_files:
161 staged_files.append(file_part)
162 # Re-read state after each write so we don't re-process.
163 merge_state = read_merge_state(root) or merge_state
164 _stage_files(root, staged_files)
165 elif "::" in (target or ""):
166 # Symbol-level resolve.
167 found = resolve_symbol(root, target or "")
168 if found:
169 resolved.append(target or "")
170 file_part = (target or "").split("::")[0]
171 _stage_files(root, [file_part])
172 else:
173 display = sanitize_display(target or "")
174 if json_out:
175 print(json.dumps(_ResolveJson(
176 **make_envelope(elapsed),
177 resolved=[],
178 remaining=len(merge_state.conflict_paths),
179 ready_to_commit=len(merge_state.conflict_paths) == 0,
180 )))
181 else:
182 print(f"ℹ️ '{display}' is not in the conflict list — already resolved or never conflicted.")
183 return
184 else:
185 # File-level resolve.
186 cleared = resolve_path(root, target or "")
187 if not cleared:
188 display = sanitize_display(target or "")
189 if json_out:
190 print(json.dumps(_ResolveJson(
191 **make_envelope(elapsed),
192 resolved=[],
193 remaining=len(merge_state.conflict_paths),
194 ready_to_commit=len(merge_state.conflict_paths) == 0,
195 )))
196 else:
197 print(f"ℹ️ '{display}' is not in the conflict list — already resolved or never conflicted.")
198 return
199 resolved.extend(cleared)
200 _stage_files(root, [target or ""])
201
202 # Read final state for remaining count.
203 final_state = read_merge_state(root)
204 remaining = len(final_state.conflict_paths) if final_state is not None else 0
205 ready = remaining == 0
206
207 if json_out:
208 print(json.dumps(_ResolveJson(
209 **make_envelope(elapsed),
210 resolved=resolved,
211 remaining=remaining,
212 ready_to_commit=ready,
213 )))
214 return
215
216 # Human text output.
217 if not resolved:
218 print("ℹ️ No conflicts were cleared.")
219 return
220
221 for entry in resolved:
222 print(f"✅ Resolved: {sanitize_display(entry)}")
223
224 if ready:
225 print("\n✅ All conflicts resolved — files staged automatically.")
226 print(" muse commit -m 'merge: ...'")
227 else:
228 print(f"\n {remaining} conflict(s) remaining — run 'muse conflicts' to see them.")
File History 2 commits
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor 16 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 21 days ago