gabriel / muse public
merge.py python
228 lines 8.6 KB
f7645c07 feat(store): self-describing HEAD format with typed read/write API (#163) Gabriel Cardona <cgcardona@gmail.com> 3d 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 conflict markers to the working tree, write
9 ``.muse/MERGE_STATE.json``, exit non-zero.
10 """
11
12 from __future__ import annotations
13
14 import datetime
15 import json
16 import logging
17 import pathlib
18 import typer
19
20 from muse.core.errors import ExitCode
21 from muse.core.merge_engine import (
22 find_merge_base,
23 write_merge_state,
24 )
25 from muse.core.repo import require_repo
26 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
27 from muse.core.store import (
28 CommitRecord,
29 SnapshotRecord,
30 get_head_commit_id,
31 get_head_snapshot_manifest,
32 read_commit,
33 read_current_branch,
34 read_snapshot,
35 write_commit,
36 write_snapshot,
37 )
38 from muse.core.reflog import append_reflog
39 from muse.core.validation import sanitize_display, validate_branch_name
40 from muse.core.workdir import apply_manifest
41 from muse.domain import SnapshotManifest, StructuredMergePlugin
42 from muse.plugins.registry import read_domain, resolve_plugin
43
44 logger = logging.getLogger(__name__)
45
46 app = typer.Typer()
47
48
49 def _read_branch(root: pathlib.Path) -> str:
50 """Return the current branch name by reading ``.muse/HEAD``."""
51 return read_current_branch(root)
52
53
54 def _read_repo_id(root: pathlib.Path) -> str:
55 """Return the repository UUID from ``.muse/repo.json``."""
56 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
57
58
59 def _restore_from_manifest(root: pathlib.Path, manifest: dict[str, str]) -> None:
60 """Apply *manifest* to the working tree at *root*.
61
62 Delegates to :func:`muse.core.workdir.apply_manifest` which surgically
63 removes files no longer present in the target and restores the rest from
64 the content-addressed object store.
65
66 Args:
67 root: Repository root (the directory containing ``.muse/``).
68 manifest: Mapping of POSIX-relative paths to SHA-256 object IDs.
69 """
70 apply_manifest(root, manifest)
71
72
73 @app.callback(invoke_without_command=True)
74 def merge(
75 ctx: typer.Context,
76 branch: str = typer.Argument(..., help="Branch to merge into the current branch."),
77 no_ff: bool = typer.Option(False, "--no-ff", help="Always create a merge commit, even for fast-forward."),
78 message: str | None = typer.Option(None, "-m", "--message", help="Override the merge commit message."),
79 ) -> None:
80 """Three-way merge a branch into the current branch."""
81 root = require_repo()
82 repo_id = _read_repo_id(root)
83 current_branch = _read_branch(root)
84 domain = read_domain(root)
85 plugin = resolve_plugin(root)
86
87 if branch == current_branch:
88 typer.echo("❌ Cannot merge a branch into itself.")
89 raise typer.Exit(code=ExitCode.USER_ERROR)
90
91 ours_commit_id = get_head_commit_id(root, current_branch)
92 theirs_commit_id = get_head_commit_id(root, branch)
93
94 if theirs_commit_id is None:
95 typer.echo(f"❌ Branch '{branch}' has no commits.")
96 raise typer.Exit(code=ExitCode.USER_ERROR)
97
98 if ours_commit_id is None:
99 typer.echo("❌ Current branch has no commits.")
100 raise typer.Exit(code=ExitCode.USER_ERROR)
101
102 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
103
104 if base_commit_id == theirs_commit_id:
105 typer.echo("Already up to date.")
106 return
107
108 if base_commit_id == ours_commit_id and not no_ff:
109 theirs_commit = read_commit(root, theirs_commit_id)
110 if theirs_commit:
111 ff_snap = read_snapshot(root, theirs_commit.snapshot_id)
112 if ff_snap:
113 _restore_from_manifest(root, ff_snap.manifest)
114 try:
115 validate_branch_name(current_branch)
116 except ValueError as exc:
117 typer.echo(f"❌ Current branch name is invalid: {exc}")
118 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
119 (root / ".muse" / "refs" / "heads" / current_branch).write_text(theirs_commit_id)
120 append_reflog(
121 root, current_branch, old_id=ours_commit_id, new_id=theirs_commit_id,
122 author="user",
123 operation=f"merge: fast-forward {sanitize_display(branch)} → {sanitize_display(current_branch)}",
124 )
125 typer.echo(f"Fast-forward to {theirs_commit_id[:8]}")
126 return
127
128 ours_manifest = get_head_snapshot_manifest(root, repo_id, current_branch) or {}
129 theirs_manifest = get_head_snapshot_manifest(root, repo_id, branch) or {}
130 base_manifest: dict[str, str] = {}
131 if base_commit_id:
132 base_commit = read_commit(root, base_commit_id)
133 if base_commit:
134 base_snap = read_snapshot(root, base_commit.snapshot_id)
135 if base_snap:
136 base_manifest = base_snap.manifest
137
138 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain)
139 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain)
140 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain)
141
142 # Prefer operation-level merge when the plugin supports it.
143 # Produces finer-grained conflict detection (sub-file / note level).
144 # Falls back to file-level merge() for plugins without this capability.
145 if isinstance(plugin, StructuredMergePlugin):
146 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
147 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
148 result = plugin.merge_ops(
149 base_snap_obj,
150 ours_snap_obj,
151 theirs_snap_obj,
152 ours_delta["ops"],
153 theirs_delta["ops"],
154 repo_root=root,
155 )
156 logger.debug(
157 "merge: used operation-level merge (%s); %d conflict(s)",
158 type(plugin).__name__,
159 len(result.conflicts),
160 )
161 else:
162 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
163
164 # Report any .museattributes auto-resolutions.
165 if result.applied_strategies:
166 for p, strategy in sorted(result.applied_strategies.items()):
167 if strategy == "dimension-merge":
168 dim_detail = result.dimension_reports.get(p, {})
169 dim_summary = ", ".join(
170 f"{d}={v}" for d, v in sorted(dim_detail.items())
171 )
172 typer.echo(f" ✔ dimension-merge: {p} ({dim_summary})")
173 elif strategy != "manual":
174 typer.echo(f" ✔ [{strategy}] {p}")
175
176 if not result.is_clean:
177 write_merge_state(
178 root,
179 base_commit=base_commit_id or "",
180 ours_commit=ours_commit_id,
181 theirs_commit=theirs_commit_id,
182 conflict_paths=result.conflicts,
183 other_branch=branch,
184 )
185 typer.echo(f"❌ Merge conflict in {len(result.conflicts)} file(s):")
186 for p in sorted(result.conflicts):
187 typer.echo(f" CONFLICT (both modified): {p}")
188 typer.echo('\nFix conflicts and run "muse commit" to complete the merge.')
189 raise typer.Exit(code=ExitCode.USER_ERROR)
190
191 merged_manifest = result.merged["files"]
192 _restore_from_manifest(root, merged_manifest)
193
194 snapshot_id = compute_snapshot_id(merged_manifest)
195 committed_at = datetime.datetime.now(datetime.timezone.utc)
196 merge_message = message or f"Merge branch '{branch}' into {current_branch}"
197 commit_id = compute_commit_id(
198 parent_ids=[ours_commit_id, theirs_commit_id],
199 snapshot_id=snapshot_id,
200 message=merge_message,
201 committed_at_iso=committed_at.isoformat(),
202 )
203
204 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest))
205 write_commit(root, CommitRecord(
206 commit_id=commit_id,
207 repo_id=repo_id,
208 branch=current_branch,
209 snapshot_id=snapshot_id,
210 message=merge_message,
211 committed_at=committed_at,
212 parent_commit_id=ours_commit_id,
213 parent2_commit_id=theirs_commit_id,
214 ))
215 try:
216 validate_branch_name(current_branch)
217 except ValueError as exc:
218 typer.echo(f"❌ Current branch name is invalid: {exc}")
219 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
220 (root / ".muse" / "refs" / "heads" / current_branch).write_text(commit_id)
221
222 append_reflog(
223 root, current_branch, old_id=ours_commit_id, new_id=commit_id,
224 author="user",
225 operation=f"merge: {sanitize_display(branch)} into {sanitize_display(current_branch)}",
226 )
227
228 typer.echo(f"Merged '{sanitize_display(branch)}' into '{sanitize_display(current_branch)}' ({commit_id[:8]})")