merge_tree.py
python
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