cgcardona / muse public
merge.py python
211 lines 7.9 KB
a55ffe59 docs: fill docstring gaps identified in Phase 3 audit Gabriel Cardona <gabriel@tellurstori.com> 2d ago
1 """muse merge — three-way merge a branch into the current branch.
2
3 Algorithm
4 ---------
5 1. Find the merge base (LCA) of HEAD and the target branch.
6 2. Delegate conflict detection and manifest reconciliation to the domain plugin.
7 3. If clean → apply merged manifest, write new commit, advance HEAD.
8 4. If conflicts → write muse-work/ with conflict markers, write
9 ``.muse/MERGE_STATE.json``, exit non-zero.
10 """
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import logging
16 import pathlib
17 import shutil
18
19 import typer
20
21 from muse.core.errors import ExitCode
22 from muse.core.merge_engine import (
23 find_merge_base,
24 write_merge_state,
25 )
26 from muse.core.object_store import restore_object
27 from muse.core.repo import require_repo
28 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
29 from muse.core.store import (
30 CommitRecord,
31 SnapshotRecord,
32 get_head_commit_id,
33 get_head_snapshot_manifest,
34 read_commit,
35 read_snapshot,
36 write_commit,
37 write_snapshot,
38 )
39 from muse.domain import SnapshotManifest, StructuredMergePlugin
40 from muse.plugins.registry import read_domain, resolve_plugin
41
42 logger = logging.getLogger(__name__)
43
44 app = typer.Typer()
45
46
47 def _read_branch(root: pathlib.Path) -> str:
48 """Return the current branch name by reading ``.muse/HEAD``."""
49 head_ref = (root / ".muse" / "HEAD").read_text().strip()
50 return head_ref.removeprefix("refs/heads/").strip()
51
52
53 def _read_repo_id(root: pathlib.Path) -> str:
54 """Return the repository UUID from ``.muse/repo.json``."""
55 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
56
57
58 def _restore_from_manifest(root: pathlib.Path, manifest: dict[str, str]) -> None:
59 """Rebuild ``muse-work/`` to exactly match *manifest*.
60
61 Wipes the existing ``muse-work/`` directory (destructive), recreates it,
62 and restores each object from the local content-addressed store. All
63 parent directories for nested paths are created as needed.
64
65 Args:
66 root: Repository root (parent of ``.muse/``).
67 manifest: Mapping of workspace-relative POSIX paths to SHA-256
68 object IDs to restore.
69 """
70 workdir = root / "muse-work"
71 if workdir.exists():
72 shutil.rmtree(workdir)
73 workdir.mkdir()
74 for rel_path, object_id in manifest.items():
75 restore_object(root, object_id, workdir / rel_path)
76
77
78 @app.callback(invoke_without_command=True)
79 def merge(
80 ctx: typer.Context,
81 branch: str = typer.Argument(..., help="Branch to merge into the current branch."),
82 no_ff: bool = typer.Option(False, "--no-ff", help="Always create a merge commit, even for fast-forward."),
83 message: str | None = typer.Option(None, "-m", "--message", help="Override the merge commit message."),
84 ) -> None:
85 """Three-way merge a branch into the current branch."""
86 root = require_repo()
87 repo_id = _read_repo_id(root)
88 current_branch = _read_branch(root)
89 domain = read_domain(root)
90 plugin = resolve_plugin(root)
91
92 if branch == current_branch:
93 typer.echo("❌ Cannot merge a branch into itself.")
94 raise typer.Exit(code=ExitCode.USER_ERROR)
95
96 ours_commit_id = get_head_commit_id(root, current_branch)
97 theirs_commit_id = get_head_commit_id(root, branch)
98
99 if theirs_commit_id is None:
100 typer.echo(f"❌ Branch '{branch}' has no commits.")
101 raise typer.Exit(code=ExitCode.USER_ERROR)
102
103 if ours_commit_id is None:
104 typer.echo("❌ Current branch has no commits.")
105 raise typer.Exit(code=ExitCode.USER_ERROR)
106
107 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
108
109 if base_commit_id == theirs_commit_id:
110 typer.echo("Already up to date.")
111 return
112
113 if base_commit_id == ours_commit_id and not no_ff:
114 theirs_commit = read_commit(root, theirs_commit_id)
115 if theirs_commit:
116 snapshot = json.loads((root / ".muse" / "snapshots" / f"{theirs_commit.snapshot_id}.json").read_text())
117 _restore_from_manifest(root, snapshot["manifest"])
118 (root / ".muse" / "refs" / "heads" / current_branch).write_text(theirs_commit_id)
119 typer.echo(f"Fast-forward to {theirs_commit_id[:8]}")
120 return
121
122 ours_manifest = get_head_snapshot_manifest(root, repo_id, current_branch) or {}
123 theirs_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
124 base_manifest: dict[str, str] = {}
125 if base_commit_id:
126 base_commit = read_commit(root, base_commit_id)
127 if base_commit:
128 base_snap = read_snapshot(root, base_commit.snapshot_id)
129 if base_snap:
130 base_manifest = base_snap.manifest
131
132 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain)
133 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain)
134 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain)
135
136 # Phase 3: prefer operation-level merge when the plugin supports it.
137 # Produces finer-grained conflict detection (sub-file / note level).
138 # Falls back to file-level merge() for plugins without this capability.
139 if isinstance(plugin, StructuredMergePlugin):
140 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
141 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
142 result = plugin.merge_ops(
143 base_snap_obj,
144 ours_snap_obj,
145 theirs_snap_obj,
146 ours_delta["ops"],
147 theirs_delta["ops"],
148 repo_root=root,
149 )
150 logger.debug(
151 "merge: used operation-level merge (%s); %d conflict(s)",
152 type(plugin).__name__,
153 len(result.conflicts),
154 )
155 else:
156 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
157
158 # Report any .museattributes auto-resolutions.
159 if result.applied_strategies:
160 for p, strategy in sorted(result.applied_strategies.items()):
161 if strategy == "dimension-merge":
162 dim_detail = result.dimension_reports.get(p, {})
163 dim_summary = ", ".join(
164 f"{d}={v}" for d, v in sorted(dim_detail.items())
165 )
166 typer.echo(f" ✔ dimension-merge: {p} ({dim_summary})")
167 elif strategy != "manual":
168 typer.echo(f" ✔ [{strategy}] {p}")
169
170 if not result.is_clean:
171 write_merge_state(
172 root,
173 base_commit=base_commit_id or "",
174 ours_commit=ours_commit_id,
175 theirs_commit=theirs_commit_id,
176 conflict_paths=result.conflicts,
177 other_branch=branch,
178 )
179 typer.echo(f"❌ Merge conflict in {len(result.conflicts)} file(s):")
180 for p in sorted(result.conflicts):
181 typer.echo(f" CONFLICT (both modified): {p}")
182 typer.echo('\nFix conflicts and run "muse commit" to complete the merge.')
183 raise typer.Exit(code=ExitCode.USER_ERROR)
184
185 merged_manifest = result.merged["files"]
186 _restore_from_manifest(root, merged_manifest)
187
188 snapshot_id = compute_snapshot_id(merged_manifest)
189 committed_at = datetime.datetime.now(datetime.timezone.utc)
190 merge_message = message or f"Merge branch '{branch}' into {current_branch}"
191 commit_id = compute_commit_id(
192 parent_ids=[ours_commit_id, theirs_commit_id],
193 snapshot_id=snapshot_id,
194 message=merge_message,
195 committed_at_iso=committed_at.isoformat(),
196 )
197
198 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest))
199 write_commit(root, CommitRecord(
200 commit_id=commit_id,
201 repo_id=repo_id,
202 branch=current_branch,
203 snapshot_id=snapshot_id,
204 message=merge_message,
205 committed_at=committed_at,
206 parent_commit_id=ours_commit_id,
207 parent2_commit_id=theirs_commit_id,
208 ))
209 (root / ".muse" / "refs" / "heads" / current_branch).write_text(commit_id)
210
211 typer.echo(f"Merged '{branch}' into '{current_branch}' ({commit_id[:8]})")