cgcardona / muse public
merge.py python
614 lines 23.7 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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)