merge.py
python
| 1 | """muse merge — fast-forward and 3-way merge with path-level conflict detection. |
| 2 | |
| 3 | Algorithm |
| 4 | --------- |
| 5 | 1. Block if ``.muse/MERGE_STATE.json`` already exists (merge in progress). |
| 6 | 2. Resolve ``ours_commit_id`` from ``.muse/refs/heads/<current_branch>``. |
| 7 | 3. Resolve ``theirs_commit_id`` from ``.muse/refs/heads/<target_branch>``. |
| 8 | 4. Find merge base: LCA of the two commits via BFS over the commit graph. |
| 9 | 5. **Fast-forward** — if ``base == ours`` *and* ``--no-ff`` is not set, target |
| 10 | is strictly ahead: move the current branch pointer to ``theirs`` (no new commit). |
| 11 | With ``--no-ff``, a merge commit is forced even when fast-forward is possible. |
| 12 | 6. **Already up-to-date** — if ``base == theirs``, current branch is already |
| 13 | ahead of target: exit 0. |
| 14 | 7. **--squash** — collapse all commits from target into a single new commit on |
| 15 | current branch; only one parent (ours_commit_id); no ``parent2_commit_id``. |
| 16 | 8. **--strategy ours|theirs** — shortcut resolution before conflict detection: |
| 17 | ``ours`` keeps every file from the current branch; ``theirs`` takes every file |
| 18 | from the target branch. No conflict detection runs when a strategy is set. |
| 19 | 9. **3-way merge** — branches have diverged: |
| 20 | a. Compute ``diff(base → ours)`` and ``diff(base → theirs)``. |
| 21 | b. Detect conflicts (paths changed on both sides). |
| 22 | c. If conflicts exist: write ``.muse/MERGE_STATE.json`` and exit 1. |
| 23 | d. Otherwise: build merged manifest, persist snapshot, insert merge commit |
| 24 | with two parent IDs, advance branch pointer. |
| 25 | |
| 26 | ``--continue`` |
| 27 | -------------- |
| 28 | After resolving all conflicts via ``muse resolve``, run:: |
| 29 | |
| 30 | muse merge --continue |
| 31 | |
| 32 | This reads the persisted ``MERGE_STATE.json``, verifies all conflicts are |
| 33 | cleared, builds a merge commit from the current ``muse-work/`` contents, and |
| 34 | advances the branch pointer. |
| 35 | """ |
| 36 | from __future__ import annotations |
| 37 | |
| 38 | import asyncio |
| 39 | import datetime |
| 40 | import json |
| 41 | import logging |
| 42 | import pathlib |
| 43 | from typing import Optional |
| 44 | |
| 45 | import typer |
| 46 | from sqlalchemy.ext.asyncio import AsyncSession |
| 47 | |
| 48 | from maestro.muse_cli._repo import require_repo |
| 49 | from maestro.muse_cli.db import ( |
| 50 | get_commit_snapshot_manifest, |
| 51 | insert_commit, |
| 52 | open_session, |
| 53 | upsert_object, |
| 54 | upsert_snapshot, |
| 55 | ) |
| 56 | from maestro.muse_cli.errors import ExitCode |
| 57 | from maestro.muse_cli.merge_engine import ( |
| 58 | apply_merge, |
| 59 | apply_resolution, |
| 60 | clear_merge_state, |
| 61 | detect_conflicts, |
| 62 | diff_snapshots, |
| 63 | find_merge_base, |
| 64 | read_merge_state, |
| 65 | write_merge_state, |
| 66 | ) |
| 67 | from maestro.muse_cli.models import MuseCliCommit |
| 68 | from maestro.muse_cli.snapshot import build_snapshot_manifest, compute_commit_id, compute_snapshot_id |
| 69 | |
| 70 | logger = logging.getLogger(__name__) |
| 71 | |
| 72 | app = typer.Typer() |
| 73 | |
| 74 | |
| 75 | # --------------------------------------------------------------------------- |
| 76 | # Testable async core |
| 77 | # --------------------------------------------------------------------------- |
| 78 | |
| 79 | |
| 80 | async def _merge_async( |
| 81 | *, |
| 82 | branch: str, |
| 83 | root: pathlib.Path, |
| 84 | session: AsyncSession, |
| 85 | no_ff: bool = False, |
| 86 | squash: bool = False, |
| 87 | strategy: str | None = None, |
| 88 | ) -> None: |
| 89 | """Run the merge pipeline. |
| 90 | |
| 91 | All filesystem and DB side-effects are isolated here so tests can inject |
| 92 | an in-memory SQLite session and a ``tmp_path`` root without touching a |
| 93 | real database. |
| 94 | |
| 95 | Raises :class:`typer.Exit` with the appropriate exit code on every |
| 96 | terminal condition (success, conflict, or user error) so the Typer |
| 97 | callback surfaces a clean message. |
| 98 | |
| 99 | Args: |
| 100 | branch: Name of the branch to merge into the current branch. |
| 101 | root: Repository root (directory containing ``.muse/``). |
| 102 | session: Open async DB session. |
| 103 | no_ff: Force a merge commit even when fast-forward is possible. |
| 104 | Preserves branch topology in the history graph. |
| 105 | squash: Squash all commits from *branch* into one new commit on the |
| 106 | current branch. The resulting commit has a single parent |
| 107 | (HEAD) and no ``parent2_commit_id`` — it does not form a |
| 108 | merge commit in the DAG. |
| 109 | strategy: Resolution shortcut applied before conflict detection. |
| 110 | ``"ours"`` keeps every file from the current branch. |
| 111 | ``"theirs"`` takes every file from the target branch. |
| 112 | ``None`` (default) uses the standard 3-way merge. |
| 113 | """ |
| 114 | muse_dir = root / ".muse" |
| 115 | |
| 116 | # ── Guard: merge already in progress ──────────────────────────────── |
| 117 | if read_merge_state(root) is not None: |
| 118 | typer.echo( |
| 119 | 'Merge in progress. Resolve conflicts and run "muse merge --continue".' |
| 120 | ) |
| 121 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 122 | |
| 123 | # ── Repo identity ──────────────────────────────────────────────────── |
| 124 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 125 | repo_id = repo_data["repo_id"] |
| 126 | |
| 127 | # ── Current branch ─────────────────────────────────────────────────── |
| 128 | head_ref = (muse_dir / "HEAD").read_text().strip() # "refs/heads/main" |
| 129 | current_branch = head_ref.rsplit("/", 1)[-1] # "main" |
| 130 | our_ref_path = muse_dir / pathlib.Path(head_ref) |
| 131 | |
| 132 | ours_commit_id = our_ref_path.read_text().strip() if our_ref_path.exists() else "" |
| 133 | if not ours_commit_id: |
| 134 | typer.echo("❌ Current branch has no commits. Cannot merge.") |
| 135 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 136 | |
| 137 | # ── Target branch ──────────────────────────────────────────────────── |
| 138 | their_ref_path = muse_dir / "refs" / "heads" / branch |
| 139 | theirs_commit_id = ( |
| 140 | their_ref_path.read_text().strip() if their_ref_path.exists() else "" |
| 141 | ) |
| 142 | if not theirs_commit_id: |
| 143 | typer.echo(f"❌ Branch '{branch}' has no commits or does not exist.") |
| 144 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 145 | |
| 146 | # ── Already up-to-date (same HEAD) ─────────────────────────────────── |
| 147 | if ours_commit_id == theirs_commit_id: |
| 148 | typer.echo("Already up-to-date.") |
| 149 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 150 | |
| 151 | # ── Find merge base (LCA) ──────────────────────────────────────────── |
| 152 | base_commit_id = await find_merge_base(session, ours_commit_id, theirs_commit_id) |
| 153 | |
| 154 | # ── Validate strategy ──────────────────────────────────────────────── |
| 155 | _VALID_STRATEGIES = {"ours", "theirs"} |
| 156 | if strategy is not None and strategy not in _VALID_STRATEGIES: |
| 157 | typer.echo( |
| 158 | f"❌ Unknown strategy '{strategy}'. Valid options: ours, theirs." |
| 159 | ) |
| 160 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 161 | |
| 162 | # ── Fast-forward: ours IS the base → theirs is ahead ───────────────── |
| 163 | if base_commit_id == ours_commit_id and not no_ff and not squash: |
| 164 | our_ref_path.write_text(theirs_commit_id) |
| 165 | typer.echo( |
| 166 | f"✅ Fast-forward: {current_branch} → {theirs_commit_id[:8]}" |
| 167 | ) |
| 168 | logger.info( |
| 169 | "✅ muse merge fast-forward %r to %s", current_branch, theirs_commit_id[:8] |
| 170 | ) |
| 171 | return |
| 172 | |
| 173 | # ── Already up-to-date: theirs IS the base → we are ahead ──────────── |
| 174 | if base_commit_id == theirs_commit_id: |
| 175 | typer.echo("Already up-to-date.") |
| 176 | raise typer.Exit(code=ExitCode.SUCCESS) |
| 177 | |
| 178 | # ── Load manifests ──────────────────────────────────────────────────── |
| 179 | base_manifest: dict[str, str] = {} |
| 180 | if base_commit_id is not None: |
| 181 | loaded_base = await get_commit_snapshot_manifest(session, base_commit_id) |
| 182 | base_manifest = loaded_base or {} |
| 183 | |
| 184 | ours_manifest = await get_commit_snapshot_manifest(session, ours_commit_id) or {} |
| 185 | theirs_manifest = ( |
| 186 | await get_commit_snapshot_manifest(session, theirs_commit_id) or {} |
| 187 | ) |
| 188 | |
| 189 | # ── Strategy shortcut (bypasses conflict detection) ─────────────────── |
| 190 | if strategy == "ours": |
| 191 | merged_manifest = dict(ours_manifest) |
| 192 | elif strategy == "theirs": |
| 193 | merged_manifest = dict(theirs_manifest) |
| 194 | else: |
| 195 | # ── 3-way merge ────────────────────────────────────────────────── |
| 196 | ours_changed = diff_snapshots(base_manifest, ours_manifest) |
| 197 | theirs_changed = diff_snapshots(base_manifest, theirs_manifest) |
| 198 | conflict_paths = detect_conflicts(ours_changed, theirs_changed) |
| 199 | |
| 200 | if conflict_paths: |
| 201 | write_merge_state( |
| 202 | root, |
| 203 | base_commit=base_commit_id or "", |
| 204 | ours_commit=ours_commit_id, |
| 205 | theirs_commit=theirs_commit_id, |
| 206 | conflict_paths=sorted(conflict_paths), |
| 207 | other_branch=branch, |
| 208 | ) |
| 209 | typer.echo(f"❌ Merge conflict in {len(conflict_paths)} file(s):") |
| 210 | for path in sorted(conflict_paths): |
| 211 | typer.echo(f"\tboth modified: {path}") |
| 212 | typer.echo('Fix conflicts and run "muse commit" to conclude the merge.') |
| 213 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 214 | |
| 215 | merged_manifest = apply_merge( |
| 216 | base_manifest, |
| 217 | ours_manifest, |
| 218 | theirs_manifest, |
| 219 | ours_changed, |
| 220 | theirs_changed, |
| 221 | conflict_paths, |
| 222 | ) |
| 223 | |
| 224 | # ── Persist merged snapshot ─────────────────────────────────────────── |
| 225 | merged_snapshot_id = compute_snapshot_id(merged_manifest) |
| 226 | await upsert_snapshot(session, manifest=merged_manifest, snapshot_id=merged_snapshot_id) |
| 227 | await session.flush() |
| 228 | |
| 229 | # ── Build commit ────────────────────────────────────────────────────── |
| 230 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 231 | |
| 232 | if squash: |
| 233 | # Squash: single parent (HEAD), no parent2 — collapses target history. |
| 234 | squash_message = f"Squash merge branch '{branch}' into {current_branch}" |
| 235 | squash_commit_id = compute_commit_id( |
| 236 | parent_ids=[ours_commit_id], |
| 237 | snapshot_id=merged_snapshot_id, |
| 238 | message=squash_message, |
| 239 | committed_at_iso=committed_at.isoformat(), |
| 240 | ) |
| 241 | squash_commit = MuseCliCommit( |
| 242 | commit_id=squash_commit_id, |
| 243 | repo_id=repo_id, |
| 244 | branch=current_branch, |
| 245 | parent_commit_id=ours_commit_id, |
| 246 | parent2_commit_id=None, |
| 247 | snapshot_id=merged_snapshot_id, |
| 248 | message=squash_message, |
| 249 | author="", |
| 250 | committed_at=committed_at, |
| 251 | ) |
| 252 | await insert_commit(session, squash_commit) |
| 253 | our_ref_path.write_text(squash_commit_id) |
| 254 | typer.echo( |
| 255 | f"✅ Squash commit [{current_branch} {squash_commit_id[:8]}] " |
| 256 | f"— squashed '{branch}' into '{current_branch}'" |
| 257 | ) |
| 258 | logger.info( |
| 259 | "✅ muse merge --squash commit %s on %r (parent: %s)", |
| 260 | squash_commit_id[:8], |
| 261 | current_branch, |
| 262 | ours_commit_id[:8], |
| 263 | ) |
| 264 | return |
| 265 | |
| 266 | # Merge commit (standard or --no-ff): two parents. |
| 267 | if strategy is not None: |
| 268 | merge_message = ( |
| 269 | f"Merge branch '{branch}' into {current_branch} (strategy={strategy})" |
| 270 | ) |
| 271 | else: |
| 272 | merge_message = f"Merge branch '{branch}' into {current_branch}" |
| 273 | |
| 274 | parent_ids = sorted([ours_commit_id, theirs_commit_id]) |
| 275 | merge_commit_id = compute_commit_id( |
| 276 | parent_ids=parent_ids, |
| 277 | snapshot_id=merged_snapshot_id, |
| 278 | message=merge_message, |
| 279 | committed_at_iso=committed_at.isoformat(), |
| 280 | ) |
| 281 | |
| 282 | merge_commit = MuseCliCommit( |
| 283 | commit_id=merge_commit_id, |
| 284 | repo_id=repo_id, |
| 285 | branch=current_branch, |
| 286 | parent_commit_id=ours_commit_id, |
| 287 | parent2_commit_id=theirs_commit_id, |
| 288 | snapshot_id=merged_snapshot_id, |
| 289 | message=merge_message, |
| 290 | author="", |
| 291 | committed_at=committed_at, |
| 292 | ) |
| 293 | await insert_commit(session, merge_commit) |
| 294 | |
| 295 | # ── Advance branch pointer ──────────────────────────────────────────── |
| 296 | our_ref_path.write_text(merge_commit_id) |
| 297 | |
| 298 | flag_note = " (--no-ff)" if no_ff else "" |
| 299 | if strategy is not None: |
| 300 | flag_note += f" (--strategy={strategy})" |
| 301 | typer.echo( |
| 302 | f"✅ Merge commit [{current_branch} {merge_commit_id[:8]}]{flag_note} " |
| 303 | f"— merged '{branch}' into '{current_branch}'" |
| 304 | ) |
| 305 | logger.info( |
| 306 | "✅ muse merge commit %s on %r (parents: %s, %s)", |
| 307 | merge_commit_id[:8], |
| 308 | current_branch, |
| 309 | ours_commit_id[:8], |
| 310 | theirs_commit_id[:8], |
| 311 | ) |
| 312 | |
| 313 | |
| 314 | # --------------------------------------------------------------------------- |
| 315 | # --continue: complete a conflicted merge after all paths are resolved |
| 316 | # --------------------------------------------------------------------------- |
| 317 | |
| 318 | |
| 319 | async def _merge_continue_async( |
| 320 | *, |
| 321 | root: pathlib.Path, |
| 322 | session: AsyncSession, |
| 323 | ) -> None: |
| 324 | """Finalize a merge that was paused due to conflicts. |
| 325 | |
| 326 | Reads ``MERGE_STATE.json``, verifies all conflicts are cleared, builds a |
| 327 | snapshot from the current ``muse-work/`` contents, inserts a merge commit |
| 328 | with two parent IDs, advances the branch pointer, and clears |
| 329 | ``MERGE_STATE.json``. |
| 330 | |
| 331 | Args: |
| 332 | root: Repository root. |
| 333 | session: Open async DB session. |
| 334 | |
| 335 | Raises: |
| 336 | :class:`typer.Exit`: If no merge is in progress, if unresolved |
| 337 | conflicts remain, or if ``muse-work/`` is empty. |
| 338 | """ |
| 339 | merge_state = read_merge_state(root) |
| 340 | if merge_state is None: |
| 341 | typer.echo("❌ No merge in progress. Nothing to continue.") |
| 342 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 343 | |
| 344 | if merge_state.conflict_paths: |
| 345 | typer.echo( |
| 346 | f"❌ {len(merge_state.conflict_paths)} conflict(s) not yet resolved:\n" |
| 347 | + "\n".join(f"\tboth modified: {p}" for p in merge_state.conflict_paths) |
| 348 | + "\nRun 'muse resolve <path> --ours/--theirs' for each file." |
| 349 | ) |
| 350 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 351 | |
| 352 | muse_dir = root / ".muse" |
| 353 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 354 | repo_id = repo_data["repo_id"] |
| 355 | |
| 356 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 357 | current_branch = head_ref.rsplit("/", 1)[-1] |
| 358 | our_ref_path = muse_dir / pathlib.Path(head_ref) |
| 359 | |
| 360 | ours_commit_id = merge_state.ours_commit or "" |
| 361 | theirs_commit_id = merge_state.theirs_commit or "" |
| 362 | other_branch = merge_state.other_branch or "unknown" |
| 363 | |
| 364 | if not ours_commit_id or not theirs_commit_id: |
| 365 | typer.echo("❌ MERGE_STATE.json is missing commit references. Cannot continue.") |
| 366 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 367 | |
| 368 | # Build snapshot from current muse-work/ contents (conflicts already resolved). |
| 369 | workdir = root / "muse-work" |
| 370 | if not workdir.exists(): |
| 371 | typer.echo("⚠️ muse-work/ is missing. Cannot create merge snapshot.") |
| 372 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 373 | |
| 374 | manifest = build_snapshot_manifest(workdir) |
| 375 | if not manifest: |
| 376 | typer.echo("⚠️ muse-work/ is empty. Nothing to commit for the merge.") |
| 377 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 378 | |
| 379 | snapshot_id = compute_snapshot_id(manifest) |
| 380 | |
| 381 | # Persist objects and snapshot. |
| 382 | for rel_path, object_id in manifest.items(): |
| 383 | file_path = workdir / rel_path |
| 384 | size = file_path.stat().st_size |
| 385 | await upsert_object(session, object_id=object_id, size_bytes=size) |
| 386 | |
| 387 | await upsert_snapshot(session, manifest=manifest, snapshot_id=snapshot_id) |
| 388 | await session.flush() |
| 389 | |
| 390 | # Build merge commit. |
| 391 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 392 | merge_message = f"Merge branch '{other_branch}' into {current_branch}" |
| 393 | parent_ids = sorted([ours_commit_id, theirs_commit_id]) |
| 394 | merge_commit_id = compute_commit_id( |
| 395 | parent_ids=parent_ids, |
| 396 | snapshot_id=snapshot_id, |
| 397 | message=merge_message, |
| 398 | committed_at_iso=committed_at.isoformat(), |
| 399 | ) |
| 400 | |
| 401 | merge_commit = MuseCliCommit( |
| 402 | commit_id=merge_commit_id, |
| 403 | repo_id=repo_id, |
| 404 | branch=current_branch, |
| 405 | parent_commit_id=ours_commit_id, |
| 406 | parent2_commit_id=theirs_commit_id, |
| 407 | snapshot_id=snapshot_id, |
| 408 | message=merge_message, |
| 409 | author="", |
| 410 | committed_at=committed_at, |
| 411 | ) |
| 412 | await insert_commit(session, merge_commit) |
| 413 | |
| 414 | # Advance branch pointer. |
| 415 | our_ref_path.write_text(merge_commit_id) |
| 416 | |
| 417 | # Clear merge state. |
| 418 | clear_merge_state(root) |
| 419 | |
| 420 | typer.echo( |
| 421 | f"✅ Merge commit [{current_branch} {merge_commit_id[:8]}] " |
| 422 | f"— merged '{other_branch}' into '{current_branch}'" |
| 423 | ) |
| 424 | logger.info( |
| 425 | "✅ muse merge --continue: commit %s on %r (parents: %s, %s)", |
| 426 | merge_commit_id[:8], |
| 427 | current_branch, |
| 428 | ours_commit_id[:8], |
| 429 | theirs_commit_id[:8], |
| 430 | ) |
| 431 | |
| 432 | |
| 433 | # --------------------------------------------------------------------------- |
| 434 | # --abort: cancel an in-progress merge and restore pre-merge state |
| 435 | # --------------------------------------------------------------------------- |
| 436 | |
| 437 | |
| 438 | async def _merge_abort_async( |
| 439 | *, |
| 440 | root: pathlib.Path, |
| 441 | session: AsyncSession, |
| 442 | ) -> None: |
| 443 | """Cancel an in-progress merge and restore each conflicted path to its pre-merge version. |
| 444 | |
| 445 | Reads ``MERGE_STATE.json``, fetches the ours_commit snapshot manifest, and |
| 446 | restores the ours version of each conflicted file from the local object |
| 447 | store to ``muse-work/``. Clears ``MERGE_STATE.json`` on success. |
| 448 | |
| 449 | Files that existed only on the theirs branch (i.e. path absent from ours |
| 450 | manifest) are removed from ``muse-work/`` — they should not exist in the |
| 451 | pre-merge state. |
| 452 | |
| 453 | Args: |
| 454 | root: Repository root. |
| 455 | session: Open async DB session used to look up the ours commit's |
| 456 | snapshot manifest. |
| 457 | |
| 458 | Raises: |
| 459 | :class:`typer.Exit`: If no merge is in progress or if the merge state |
| 460 | is missing required commit IDs. |
| 461 | """ |
| 462 | merge_state = read_merge_state(root) |
| 463 | if merge_state is None: |
| 464 | typer.echo("❌ No merge in progress. Nothing to abort.") |
| 465 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 466 | |
| 467 | ours_commit_id = merge_state.ours_commit |
| 468 | if not ours_commit_id: |
| 469 | typer.echo("❌ MERGE_STATE.json is missing ours_commit. Cannot abort.") |
| 470 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 471 | |
| 472 | ours_manifest = await get_commit_snapshot_manifest(session, ours_commit_id) or {} |
| 473 | |
| 474 | restored_count = 0 |
| 475 | for rel_path in merge_state.conflict_paths: |
| 476 | object_id = ours_manifest.get(rel_path) |
| 477 | if object_id is None: |
| 478 | # Path was added by theirs (not present before the merge) — remove it. |
| 479 | dest = root / "muse-work" / rel_path |
| 480 | if dest.exists(): |
| 481 | dest.unlink() |
| 482 | logger.debug("✅ Removed '%s' (not in pre-merge snapshot)", rel_path) |
| 483 | continue |
| 484 | try: |
| 485 | apply_resolution(root, rel_path, object_id) |
| 486 | restored_count += 1 |
| 487 | except FileNotFoundError as exc: |
| 488 | logger.warning("⚠️ Could not restore '%s': %s", rel_path, exc) |
| 489 | |
| 490 | clear_merge_state(root) |
| 491 | |
| 492 | typer.echo(f"✅ Merge aborted. Restored {restored_count} conflicted file(s).") |
| 493 | logger.info( |
| 494 | "✅ muse merge --abort: cleared merge state, restored %d file(s)", restored_count |
| 495 | ) |
| 496 | |
| 497 | |
| 498 | # --------------------------------------------------------------------------- |
| 499 | # Typer command |
| 500 | # --------------------------------------------------------------------------- |
| 501 | |
| 502 | |
| 503 | @app.callback(invoke_without_command=True) |
| 504 | def merge( |
| 505 | ctx: typer.Context, |
| 506 | branch: Optional[str] = typer.Argument( |
| 507 | None, |
| 508 | help="Name of the branch to merge into HEAD. Omit when using --continue or --abort.", |
| 509 | ), |
| 510 | cont: bool = typer.Option( |
| 511 | False, |
| 512 | "--continue/--no-continue", |
| 513 | help="Finalize a paused merge after resolving all conflicts.", |
| 514 | ), |
| 515 | abort: bool = typer.Option( |
| 516 | False, |
| 517 | "--abort/--no-abort", |
| 518 | help="Cancel the in-progress merge and restore the pre-merge state.", |
| 519 | ), |
| 520 | no_ff: bool = typer.Option( |
| 521 | False, |
| 522 | "--no-ff/--ff", |
| 523 | help="Force a merge commit even when fast-forward is possible.", |
| 524 | ), |
| 525 | squash: bool = typer.Option( |
| 526 | False, |
| 527 | "--squash/--no-squash", |
| 528 | help=( |
| 529 | "Squash all commits from the target branch into one new commit on " |
| 530 | "the current branch. The result has a single parent and no merge " |
| 531 | "commit in the history graph." |
| 532 | ), |
| 533 | ), |
| 534 | strategy: Optional[str] = typer.Option( |
| 535 | None, |
| 536 | "--strategy", |
| 537 | help=( |
| 538 | "Merge strategy shortcut. 'ours' keeps all files from the current " |
| 539 | "branch; 'theirs' takes all files from the target branch. Both skip " |
| 540 | "conflict detection." |
| 541 | ), |
| 542 | ), |
| 543 | ) -> None: |
| 544 | """Merge a branch into the current branch (fast-forward or 3-way). |
| 545 | |
| 546 | Flags: |
| 547 | --no-ff Force a merge commit even when fast-forward is possible. |
| 548 | --squash Collapse target branch history into one commit (no parent2). |
| 549 | --strategy Resolution shortcut: 'ours' or 'theirs'. |
| 550 | --continue Finalize a paused merge after resolving all conflicts. |
| 551 | --abort Cancel and restore the pre-merge working-tree state. |
| 552 | """ |
| 553 | root = require_repo() |
| 554 | |
| 555 | if cont and abort: |
| 556 | typer.echo("❌ Cannot use --continue and --abort together.") |
| 557 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 558 | |
| 559 | if cont: |
| 560 | async def _run_continue() -> None: |
| 561 | async with open_session() as session: |
| 562 | await _merge_continue_async(root=root, session=session) |
| 563 | |
| 564 | try: |
| 565 | asyncio.run(_run_continue()) |
| 566 | except typer.Exit: |
| 567 | raise |
| 568 | except Exception as exc: |
| 569 | typer.echo(f"❌ muse merge --continue failed: {exc}") |
| 570 | logger.error("❌ muse merge --continue error: %s", exc, exc_info=True) |
| 571 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 572 | return |
| 573 | |
| 574 | if abort: |
| 575 | async def _run_abort() -> None: |
| 576 | async with open_session() as session: |
| 577 | await _merge_abort_async(root=root, session=session) |
| 578 | |
| 579 | try: |
| 580 | asyncio.run(_run_abort()) |
| 581 | except typer.Exit: |
| 582 | raise |
| 583 | except Exception as exc: |
| 584 | typer.echo(f"❌ muse merge --abort failed: {exc}") |
| 585 | logger.error("❌ muse merge --abort error: %s", exc, exc_info=True) |
| 586 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |
| 587 | return |
| 588 | |
| 589 | if not branch: |
| 590 | typer.echo( |
| 591 | "❌ Branch name required " |
| 592 | "(or use --continue / --abort to manage a paused merge)." |
| 593 | ) |
| 594 | raise typer.Exit(code=ExitCode.USER_ERROR) |
| 595 | |
| 596 | async def _run() -> None: |
| 597 | async with open_session() as session: |
| 598 | await _merge_async( |
| 599 | branch=branch, |
| 600 | root=root, |
| 601 | session=session, |
| 602 | no_ff=no_ff, |
| 603 | squash=squash, |
| 604 | strategy=strategy, |
| 605 | ) |
| 606 | |
| 607 | try: |
| 608 | asyncio.run(_run()) |
| 609 | except typer.Exit: |
| 610 | raise |
| 611 | except Exception as exc: |
| 612 | typer.echo(f"❌ muse merge failed: {exc}") |
| 613 | logger.error("❌ muse merge error: %s", exc, exc_info=True) |
| 614 | raise typer.Exit(code=ExitCode.INTERNAL_ERROR) |