gabriel / muse public
merge_tree.py python
398 lines 13.7 KB
Raw
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
1 """``muse merge-tree <branch1> <branch2>`` — three-way merge without touching the working tree.
2
3 Computes the merged manifest of two branches entirely in memory and returns
4 the result as structured data. Nothing is written to the working tree, HEAD,
5 any branch ref, or MERGE_STATE.json — this command is a pure computation.
6
7 Output (text, default)::
8
9 Merge base: <commit_id>
10 branch1: <commit_id> (<branch1>)
11 branch2: <commit_id> (<branch2>)
12
13 Merged files: 14
14 Conflicts: 0
15 Result: trivially merged
16
17 CONFLICT shared.py (both modified)
18
19 JSON (``--json``)::
20
21 {
22 "base": "<commit_id>",
23 "branch1": "<commit_id>",
24 "branch2": "<commit_id>",
25 "conflicts": ["shared.py"],
26 "merged_manifest": {"src/foo.py": "<object_id>", "shared.py": null},
27 "trivially_merged": false,
28 "duration_ms": 1.2,
29 "exit_code": 1
30 }
31
32 With ``--write-objects`` the merged snapshot is also written to the object
33 store and its ID is included::
34
35 {"snapshot_id": "<snapshot_id>", "duration_ms": 0.8, "exit_code": 0, ...}
36
37 Agent workflow::
38
39 muse merge-tree feature dev --json
40 # → check conflicts == [] and exit_code == 0
41 muse commit-tree <snapshot_id> -m "merge feature into dev"
42
43 Flags
44 -----
45 ``--base <ref>``
46 Override the automatically computed merge base (LCA). Useful when the
47 caller already knows the common ancestor and wants to skip BFS. Accepts
48 branch names or sha256:-prefixed commit IDs.
49
50 ``--write-objects``
51 Write the merged snapshot to ``.muse/objects/sha256/`` so the caller can
52 reference it in a subsequent ``muse commit-tree``. Only the snapshot
53 record is written; no commit, no branch update, no working-tree changes.
54
55 ``--json``
56 Emit a single JSON object on stdout. In this mode all errors also go to
57 stdout as JSON — stderr will be empty. Agents should parse stdout and
58 check ``exit_code``.
59
60 Output contract
61 ---------------
62
63 - ``exit_code`` 0: clean merge (zero conflicts).
64 - ``exit_code`` 1: merge has conflicts or a ref cannot be resolved.
65 - ``exit_code`` 2: usage error (bad ref name, ANSI injection).
66 - ``elapsed()``: wall-clock milliseconds for the merge computation.
67
68 JSON error schema (--json mode)::
69
70 {
71 "status": "error",
72 "error": "<human-readable message>",
73 "exit_code": <int>
74 }
75
76 Exit codes::
77
78 0 — merge is clean (zero conflicts)
79 1 — merge has conflicts or a ref cannot be resolved
80 2 — usage error (bad ref name, ANSI injection)
81 """
82
83 import argparse
84 import json as _json
85 import logging
86 import pathlib
87 import re
88 import sys
89 from typing import TypedDict
90
91 from muse.core.types import load_json_file
92 from muse.core.paths import ref_path as _ref_path, repo_json_path as _repo_json_path
93 from muse.core.errors import ExitCode
94 from muse.core.merge_engine import (
95 apply_merge,
96 detect_conflicts,
97 diff_snapshots,
98 find_merge_base,
99 )
100 from muse.core.repo import require_repo
101 from muse.core.refs import read_ref
102 from muse.core.refs import (
103 get_head_commit_id,
104 read_current_branch,
105 )
106 from muse.core.commits import read_commit
107 from muse.core.snapshots import read_snapshot
108 from muse.core.envelope import EnvelopeJson, make_envelope
109 from muse.core.validation import sanitize_display, validate_object_id
110 from muse.core.timing import start_timer
111
112 logger = logging.getLogger(__name__)
113
114 # Allow alphanumeric, underscore, slash, dot, hyphen, plus full sha256:-prefixed commit IDs.
115 _SAFE_REF_RE = re.compile(r"^(sha256:[0-9a-f]{64}|[a-zA-Z0-9_/.\-]+)$")
116
117 # ---------------------------------------------------------------------------
118 # Wire-format TypedDicts
119 # ---------------------------------------------------------------------------
120
121 class _MergeTreeJsonBase(EnvelopeJson):
122 """Stable JSON envelope for merge-tree results.
123
124 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
125 """
126 base: str
127 branch1: str
128 branch2: str
129 conflicts: list[str]
130 merged_manifest: dict[str, str | None]
131 trivially_merged: bool
132
133 class _MergeTreeJson(_MergeTreeJsonBase, total=False):
134 """Extended merge-tree JSON — adds optional ``snapshot_id`` when ``--write-objects`` is used."""
135 snapshot_id: str
136
137 class _MergeTreeErrorJson(EnvelopeJson):
138 """Error payload for usage/internal errors in --json mode."""
139 status: str # "error"
140 error: str
141
142 # ---------------------------------------------------------------------------
143 # Helpers
144 # ---------------------------------------------------------------------------
145
146 def _emit_error(json_out: bool, msg: str, code: ExitCode, elapsed: float) -> None:
147 """Print an error and raise SystemExit. Never returns.
148
149 In ``--json`` mode the error goes to stdout as a JSON payload so agents
150 always get parseable output. In text mode it goes to stderr.
151 """
152 if json_out:
153 print(_json.dumps(_MergeTreeErrorJson(
154 **make_envelope(elapsed, exit_code=int(code)),
155 status="error",
156 error=msg,
157 )))
158 else:
159 print(f"❌ {msg}", file=sys.stderr)
160 raise SystemExit(code)
161
162 # ---------------------------------------------------------------------------
163 # Ref resolution
164 # ---------------------------------------------------------------------------
165
166 def _resolve_ref(root: pathlib.Path, treeish: str) -> str | None:
167 """Resolve HEAD, a branch name, or a sha256:-prefixed commit ID to a commit ID."""
168 if treeish.upper() == "HEAD":
169 try:
170 branch = read_current_branch(root)
171 return get_head_commit_id(root, branch)
172 except Exception:
173 return None
174
175 # Accept sha256:-prefixed commit IDs directly.
176 if treeish.startswith("sha256:"):
177 try:
178 validate_object_id(treeish)
179 record = read_commit(root, treeish)
180 return record.commit_id if record else None
181 except ValueError:
182 return None
183
184 # Try as branch ref file.
185 ref_file = _ref_path(root, treeish)
186 return read_ref(ref_file)
187
188 # ---------------------------------------------------------------------------
189 # Registration
190 # ---------------------------------------------------------------------------
191
192 def register(
193 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
194 ) -> None:
195 """Register the ``muse merge-tree`` subcommand."""
196 parser = subparsers.add_parser(
197 "merge-tree",
198 help="Three-way merge without modifying the working tree.",
199 description=__doc__,
200 formatter_class=argparse.RawDescriptionHelpFormatter,
201 )
202 parser.add_argument(
203 "branch1",
204 metavar="BRANCH1",
205 help="First branch (ours).",
206 )
207 parser.add_argument(
208 "branch2",
209 metavar="BRANCH2",
210 help="Second branch (theirs).",
211 )
212 parser.add_argument(
213 "--base",
214 metavar="REF",
215 default=None,
216 help="Explicit merge base commit ID or branch name (overrides LCA).",
217 )
218 parser.add_argument(
219 "--write-objects",
220 action="store_true",
221 dest="write_objects",
222 help="Write the merged snapshot to the object store.",
223 )
224 parser.add_argument(
225 "--json", "-j",
226 action="store_true",
227 dest="json_out",
228 help="Emit a single JSON object on stdout.",
229 )
230 parser.set_defaults(func=run)
231
232 # ---------------------------------------------------------------------------
233 # Run
234 # ---------------------------------------------------------------------------
235
236 def run(args: argparse.Namespace) -> None:
237 """Perform a three-way merge and report the result without touching the working tree.
238
239 Computes a three-way merge between two branches (or explicit commit IDs)
240 using their lowest common ancestor as the base. Never modifies the working
241 tree or any branch refs — useful for previewing conflicts before a real merge.
242
243 Agent quickstart
244 ----------------
245 ::
246
247 muse merge-tree dev feat/billing --json
248 muse merge-tree feat/x main --base sha256:<base-id> --json
249 muse merge-tree dev feat/audio --write-objects --json
250
251 JSON fields
252 -----------
253 base Resolved merge-base commit ID.
254 branch1 Resolved commit ID for the first branch (ours).
255 branch2 Resolved commit ID for the second branch (theirs).
256 conflicts List of paths with unresolved conflicts.
257 merged_manifest Map of ``path → object_id`` for the merged result.
258 trivially_merged ``true`` when there are zero conflicts.
259 snapshot_id Written snapshot ID (only present when ``--write-objects``).
260
261 Exit codes
262 ----------
263 0 Clean merge — zero conflicts.
264 1 Merge has conflicts, or a ref cannot be resolved.
265 2 Usage error.
266 """
267 elapsed = start_timer()
268
269 branch1_ref: str = args.branch1
270 branch2_ref: str = args.branch2
271 base_ref: str | None = args.base
272 write_objects: bool = args.write_objects
273 json_out: bool = args.json_out
274
275 # Validate all ref names before doing any work.
276 for ref in [branch1_ref, branch2_ref] + ([base_ref] if base_ref else []):
277 if not _SAFE_REF_RE.match(ref):
278 print(
279 f"❌ Invalid ref: {sanitize_display(ref)}",
280 file=sys.stderr,
281 )
282 raise SystemExit(ExitCode.USER_ERROR)
283
284 root = require_repo()
285
286 repo_id = (load_json_file(_repo_json_path(root)) or {}).get("repo_id", "")
287
288 # Resolve branch refs to commit IDs.
289 branch1_id = _resolve_ref(root, branch1_ref)
290 if branch1_id is None:
291 _emit_error(json_out, f"Cannot resolve ref: {sanitize_display(branch1_ref)}", ExitCode.USER_ERROR, elapsed)
292
293 branch2_id = _resolve_ref(root, branch2_ref)
294 if branch2_id is None:
295 _emit_error(json_out, f"Cannot resolve ref: {sanitize_display(branch2_ref)}", ExitCode.USER_ERROR, elapsed)
296
297 # Resolve merge base.
298 if base_ref is not None:
299 base_id = _resolve_ref(root, base_ref)
300 if base_id is None:
301 _emit_error(json_out, f"Cannot resolve base ref: {sanitize_display(base_ref)}", ExitCode.USER_ERROR, elapsed)
302 # Verify the base commit actually exists.
303 if read_commit(root, base_id) is None:
304 _emit_error(json_out, f"Base commit not found: {sanitize_display(base_id)}", ExitCode.USER_ERROR, elapsed)
305 else:
306 base_id = find_merge_base(root, branch1_id, branch2_id)
307 if base_id is None:
308 _emit_error(
309 json_out,
310 f"No common ancestor found between "
311 f"{sanitize_display(branch1_ref)} and {sanitize_display(branch2_ref)}",
312 ExitCode.USER_ERROR,
313 elapsed,
314 )
315
316 # Load all three snapshots.
317 branch1_commit = read_commit(root, branch1_id)
318 branch2_commit = read_commit(root, branch2_id)
319 base_commit = read_commit(root, base_id)
320
321 base_snap = read_snapshot(root, base_commit.snapshot_id) if base_commit else None
322 branch1_snap = read_snapshot(root, branch1_commit.snapshot_id) if branch1_commit else None
323 branch2_snap = read_snapshot(root, branch2_commit.snapshot_id) if branch2_commit else None
324
325 base_manifest = dict(base_snap.manifest) if base_snap else {}
326 branch1_manifest = dict(branch1_snap.manifest) if branch1_snap else {}
327 branch2_manifest = dict(branch2_snap.manifest) if branch2_snap else {}
328
329 # Three-way merge (pure, no I/O).
330 branch1_changed = diff_snapshots(base_manifest, branch1_manifest)
331 branch2_changed = diff_snapshots(base_manifest, branch2_manifest)
332 conflict_paths = detect_conflicts(
333 branch1_changed, branch2_changed, branch1_manifest, branch2_manifest
334 )
335 merged_manifest = apply_merge(
336 base_manifest, branch1_manifest, branch2_manifest,
337 branch1_changed, branch2_changed, conflict_paths,
338 )
339
340 trivially_merged = len(conflict_paths) == 0
341
342 # Build output manifest: conflict paths get null object_id.
343 output_manifest: dict[str, str | None] = {k: v for k, v in merged_manifest.items()}
344 for path in conflict_paths:
345 output_manifest[path] = None
346 exit_code = 0 if trivially_merged else int(ExitCode.USER_ERROR)
347
348 result = _MergeTreeJson(
349 **make_envelope(elapsed, exit_code=exit_code),
350 base=base_id,
351 branch1=branch1_id,
352 branch2=branch2_id,
353 conflicts=sorted(conflict_paths),
354 merged_manifest=output_manifest,
355 trivially_merged=trivially_merged,
356 )
357
358 # --write-objects: persist the conflict-free merged snapshot.
359 if write_objects:
360 from muse.core.ids import hash_snapshot
361 from muse.core.snapshots import (
362 SnapshotRecord,
363 write_snapshot,
364 )
365 snapshot_id = hash_snapshot(merged_manifest)
366 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest))
367 result["snapshot_id"] = snapshot_id
368
369 if json_out:
370 print(_json.dumps(result))
371 else:
372 _print_text(result, branch1_ref, branch2_ref)
373
374 if not trivially_merged:
375 raise SystemExit(ExitCode.USER_ERROR)
376
377 def _print_text(result: _MergeTreeJsonBase, branch1_ref: str, branch2_ref: str) -> None:
378 """Print a human-readable merge-tree summary."""
379 base = result["base"]
380 b1 = result["branch1"]
381 b2 = result["branch2"]
382 conflicts = result["conflicts"]
383 n_merged = len(result["merged_manifest"])
384 trivial = result["trivially_merged"]
385
386 print(f"Merge base: {base}")
387 print(f"branch1: {b1} ({sanitize_display(branch1_ref)})")
388 print(f"branch2: {b2} ({sanitize_display(branch2_ref)})")
389 print()
390 print(f"Merged files: {n_merged}")
391 print(f"Conflicts: {len(conflicts)}")
392 result_label = "trivially merged" if trivial else f"{len(conflicts)} conflict(s)"
393 print(f"Result: {result_label}")
394
395 if conflicts:
396 print()
397 for path in conflicts:
398 print(f"CONFLICT {sanitize_display(path)} (both modified)")
File History 3 commits
sha256:ae1eca169c5efe00a6415971ed0e7259f68df3e3ce23935c4b1b714c2df6a240 Merge branch 'dev' into main Human 22 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 33 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 52 days ago