cgcardona / muse public
pull.py python
276 lines 10.1 KB
189a2e45 feat: three-tier CLI architecture — plumbing, core porcelain, semantic … Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """muse pull — fetch from a remote and merge into the current branch.
2
3 Combines ``muse fetch`` and ``muse merge`` in a single command:
4
5 1. Downloads commits, snapshots, and objects from the remote.
6 2. Updates the remote tracking pointer.
7 3. Performs a three-way merge of the remote branch HEAD into the current branch.
8
9 If the remote branch is already an ancestor of the local HEAD (fast-forward),
10 the local branch ref and working tree are advanced without a merge commit.
11
12 Pass ``--no-merge`` to stop after the fetch step (equivalent to ``muse fetch``).
13 """
14
15 from __future__ import annotations
16
17 import datetime
18 import json
19 import logging
20 import pathlib
21 import shutil
22
23 import typer
24
25 from muse.cli.config import get_auth_token, get_remote, get_remote_head, get_upstream, set_remote_head
26 from muse.core.errors import ExitCode
27 from muse.core.merge_engine import find_merge_base, write_merge_state
28 from muse.core.object_store import restore_object
29 from muse.core.pack import apply_pack
30 from muse.core.repo import require_repo
31 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
32 from muse.core.store import (
33 CommitRecord,
34 SnapshotRecord,
35 get_all_commits,
36 get_head_commit_id,
37 get_head_snapshot_manifest,
38 read_commit,
39 read_snapshot,
40 write_commit,
41 write_snapshot,
42 )
43 from muse.core.transport import HttpTransport, TransportError
44 from muse.domain import SnapshotManifest, StructuredMergePlugin
45 from muse.plugins.registry import read_domain, resolve_plugin
46
47 logger = logging.getLogger(__name__)
48
49 app = typer.Typer()
50
51
52 def _current_branch(root: pathlib.Path) -> str:
53 """Return the current branch name from ``.muse/HEAD``."""
54 head_ref = (root / ".muse" / "HEAD").read_text().strip()
55 return head_ref.removeprefix("refs/heads/").strip()
56
57
58 def _read_repo_id(root: pathlib.Path) -> str:
59 """Return the repository UUID from ``.muse/repo.json``."""
60 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
61
62
63 def _restore_from_manifest(root: pathlib.Path, manifest: dict[str, str]) -> None:
64 """Rebuild ``muse-work/`` to exactly match *manifest*."""
65 workdir = root / "muse-work"
66 if workdir.exists():
67 shutil.rmtree(workdir)
68 workdir.mkdir()
69 for rel_path, object_id in manifest.items():
70 restore_object(root, object_id, workdir / rel_path)
71
72
73 @app.callback(invoke_without_command=True)
74 def pull(
75 ctx: typer.Context,
76 remote: str = typer.Argument(
77 "origin", help="Remote name to pull from (default: origin)."
78 ),
79 branch: str | None = typer.Option(
80 None, "--branch", "-b", help="Remote branch to pull (default: tracked branch or current branch)."
81 ),
82 no_merge: bool = typer.Option(
83 False, "--no-merge", help="Only fetch; do not merge into the current branch."
84 ),
85 message: str | None = typer.Option(
86 None, "-m", "--message", help="Override the merge commit message."
87 ),
88 ) -> None:
89 """Fetch from a remote and merge into the current branch.
90
91 Equivalent to running ``muse fetch`` followed by ``muse merge``.
92 Pass ``--no-merge`` to stop after the fetch step.
93 """
94 root = require_repo()
95
96 url = get_remote(remote, root)
97 if url is None:
98 typer.echo(f"❌ Remote '{remote}' is not configured.")
99 typer.echo(f" Add it with: muse remote add {remote} <url>")
100 raise typer.Exit(code=ExitCode.USER_ERROR)
101
102 token = get_auth_token(root)
103 current_branch = _current_branch(root)
104 target_branch = branch or get_upstream(current_branch, root) or current_branch
105
106 transport = HttpTransport()
107
108 # ── Fetch ────────────────────────────────────────────────────────────────
109 try:
110 info = transport.fetch_remote_info(url, token)
111 except TransportError as exc:
112 typer.echo(f"❌ Cannot reach remote '{remote}': {exc}")
113 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
114
115 remote_commit_id = info["branch_heads"].get(target_branch)
116 if remote_commit_id is None:
117 typer.echo(f"❌ Branch '{target_branch}' does not exist on remote '{remote}'.")
118 raise typer.Exit(code=ExitCode.USER_ERROR)
119
120 local_commit_ids = [c.commit_id for c in get_all_commits(root)]
121 typer.echo(f"Fetching {remote}/{target_branch} …")
122
123 try:
124 bundle = transport.fetch_pack(
125 url, token, want=[remote_commit_id], have=local_commit_ids
126 )
127 except TransportError as exc:
128 typer.echo(f"❌ Fetch failed: {exc}")
129 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)
130
131 apply_result = apply_pack(root, bundle)
132 set_remote_head(remote, target_branch, remote_commit_id, root)
133 commits_received = len(bundle.get("commits") or [])
134 typer.echo(
135 f"✅ Fetched {commits_received} commit(s), {apply_result['objects_written']} new object(s) "
136 f"from {remote}/{target_branch} ({remote_commit_id[:8]})"
137 )
138
139 if no_merge:
140 return
141
142 # ── Merge ────────────────────────────────────────────────────────────────
143 repo_id = _read_repo_id(root)
144 ours_commit_id = get_head_commit_id(root, current_branch)
145 theirs_commit_id = remote_commit_id
146
147 if ours_commit_id is None:
148 # No local commits yet — just advance HEAD to the remote commit.
149 (root / ".muse" / "refs" / "heads" / current_branch).write_text(
150 theirs_commit_id
151 )
152 theirs_commit = read_commit(root, theirs_commit_id)
153 if theirs_commit:
154 snap = read_snapshot(root, theirs_commit.snapshot_id)
155 if snap:
156 _restore_from_manifest(root, snap.manifest)
157 typer.echo(f"✅ Initialised {current_branch} at {theirs_commit_id[:8]}")
158 return
159
160 if ours_commit_id == theirs_commit_id:
161 typer.echo("Already up to date.")
162 return
163
164 base_commit_id = find_merge_base(root, ours_commit_id, theirs_commit_id)
165
166 if base_commit_id == theirs_commit_id:
167 typer.echo("Already up to date.")
168 return
169
170 # Fast-forward: remote is a direct descendant of local HEAD.
171 if base_commit_id == ours_commit_id:
172 theirs_commit = read_commit(root, theirs_commit_id)
173 if theirs_commit:
174 snap = read_snapshot(root, theirs_commit.snapshot_id)
175 if snap:
176 _restore_from_manifest(root, snap.manifest)
177 (root / ".muse" / "refs" / "heads" / current_branch).write_text(
178 theirs_commit_id
179 )
180 typer.echo(
181 f"Fast-forward {current_branch} to {theirs_commit_id[:8]} "
182 f"({remote}/{target_branch})"
183 )
184 return
185
186 # Three-way merge.
187 domain = read_domain(root)
188 plugin = resolve_plugin(root)
189
190 ours_manifest = get_head_snapshot_manifest(root, repo_id, current_branch) or {}
191 theirs_commit = read_commit(root, theirs_commit_id)
192 theirs_manifest: dict[str, str] = {}
193 if theirs_commit:
194 theirs_snap = read_snapshot(root, theirs_commit.snapshot_id)
195 if theirs_snap:
196 theirs_manifest = dict(theirs_snap.manifest)
197
198 base_manifest: dict[str, str] = {}
199 if base_commit_id:
200 base_commit = read_commit(root, base_commit_id)
201 if base_commit:
202 base_snap = read_snapshot(root, base_commit.snapshot_id)
203 if base_snap:
204 base_manifest = dict(base_snap.manifest)
205
206 base_snap_obj = SnapshotManifest(files=base_manifest, domain=domain)
207 ours_snap_obj = SnapshotManifest(files=ours_manifest, domain=domain)
208 theirs_snap_obj = SnapshotManifest(files=theirs_manifest, domain=domain)
209
210 if isinstance(plugin, StructuredMergePlugin):
211 ours_delta = plugin.diff(base_snap_obj, ours_snap_obj, repo_root=root)
212 theirs_delta = plugin.diff(base_snap_obj, theirs_snap_obj, repo_root=root)
213 result = plugin.merge_ops(
214 base_snap_obj,
215 ours_snap_obj,
216 theirs_snap_obj,
217 ours_delta["ops"],
218 theirs_delta["ops"],
219 repo_root=root,
220 )
221 else:
222 result = plugin.merge(base_snap_obj, ours_snap_obj, theirs_snap_obj, repo_root=root)
223
224 if result.applied_strategies:
225 for p, strategy in sorted(result.applied_strategies.items()):
226 if strategy != "manual":
227 typer.echo(f" ✔ [{strategy}] {p}")
228
229 if not result.is_clean:
230 write_merge_state(
231 root,
232 base_commit=base_commit_id or "",
233 ours_commit=ours_commit_id,
234 theirs_commit=theirs_commit_id,
235 conflict_paths=result.conflicts,
236 other_branch=f"{remote}/{target_branch}",
237 )
238 typer.echo(f"❌ Merge conflict in {len(result.conflicts)} file(s):")
239 for p in sorted(result.conflicts):
240 typer.echo(f" CONFLICT (both modified): {p}")
241 typer.echo('\nFix conflicts and run "muse commit" to complete the merge.')
242 raise typer.Exit(code=ExitCode.USER_ERROR)
243
244 merged_manifest = result.merged["files"]
245 _restore_from_manifest(root, merged_manifest)
246
247 snapshot_id = compute_snapshot_id(merged_manifest)
248 committed_at = datetime.datetime.now(datetime.timezone.utc)
249 merge_message = (
250 message
251 or f"Merge {remote}/{target_branch} into {current_branch}"
252 )
253 commit_id = compute_commit_id(
254 parent_ids=[ours_commit_id, theirs_commit_id],
255 snapshot_id=snapshot_id,
256 message=merge_message,
257 committed_at_iso=committed_at.isoformat(),
258 )
259 write_snapshot(root, SnapshotRecord(snapshot_id=snapshot_id, manifest=merged_manifest))
260 write_commit(
261 root,
262 CommitRecord(
263 commit_id=commit_id,
264 repo_id=repo_id,
265 branch=current_branch,
266 snapshot_id=snapshot_id,
267 message=merge_message,
268 committed_at=committed_at,
269 parent_commit_id=ours_commit_id,
270 parent2_commit_id=theirs_commit_id,
271 ),
272 )
273 (root / ".muse" / "refs" / "heads" / current_branch).write_text(commit_id)
274 typer.echo(
275 f"✅ Merged {remote}/{target_branch} into {current_branch} ({commit_id[:8]})"
276 )