pull.py
python
| 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 | ) |