fetch.py
python
| 1 | """muse fetch — download remote refs without modifying local branches. |
| 2 | |
| 3 | Fetch algorithm |
| 4 | --------------- |
| 5 | 1. Resolve repo root and read ``repo_id`` from ``.muse/repo.json``. |
| 6 | 2. Resolve remote(s) to fetch from ``--all`` → all remotes in ``.muse/config.toml``, |
| 7 | or the single ``--remote`` name (default: ``origin``). |
| 8 | 3. For each remote, POST to ``<remote_url>/fetch`` with the list of branches |
| 9 | to fetch (empty list = all branches). |
| 10 | 4. Store returned remote-tracking refs in ``.muse/remotes/<remote>/<branch>`` |
| 11 | without touching local branches or muse-work/. |
| 12 | 5. If ``--prune`` is active, remove any ``.muse/remotes/<remote>/<branch>`` |
| 13 | files whose branch no longer exists on the remote. |
| 14 | 6. Print a per-branch report line modelled on git's output format:: |
| 15 | |
| 16 | From origin: + abc1234 feature/guitar -> origin/feature/guitar (new branch) |
| 17 | |
| 18 | Exit codes: |
| 19 | 0 — success (all remotes fetched without error) |
| 20 | 1 — user error (no remote configured, bad args) |
| 21 | 2 — not a Muse repository |
| 22 | 3 — network / server error |
| 23 | """ |
| 24 | from __future__ import annotations |
| 25 | |
| 26 | import asyncio |
| 27 | import json |
| 28 | import logging |
| 29 | import pathlib |
| 30 | |
| 31 | import httpx |
| 32 | import typer |
| 33 | |
| 34 | from maestro.muse_cli._repo import require_repo |
| 35 | from maestro.muse_cli.config import ( |
| 36 | get_remote, |
| 37 | get_remote_head, |
| 38 | list_remotes, |
| 39 | set_remote_head, |
| 40 | ) |
| 41 | from maestro.muse_cli.errors import ExitCode |
| 42 | from maestro.muse_cli.hub_client import ( |
| 43 | FetchBranchInfo, |
| 44 | FetchRequest, |
| 45 | FetchResponse, |
| 46 | MuseHubClient, |
| 47 | ) |
| 48 | |
| 49 | logger = logging.getLogger(__name__) |
| 50 | |
| 51 | app = typer.Typer() |
| 52 | |
| 53 | _NO_REMOTE_MSG = ( |
| 54 | "No remote named 'origin'. " |
| 55 | "Run `muse remote add origin <url>` to configure one." |
| 56 | ) |
| 57 | |
| 58 | _NO_REMOTES_MSG = ( |
| 59 | "No remotes configured. " |
| 60 | "Run `muse remote add <name> <url>` to add one." |
| 61 | ) |
| 62 | |
| 63 | |
| 64 | # --------------------------------------------------------------------------- |
| 65 | # Remote-tracking ref filesystem helpers |
| 66 | # --------------------------------------------------------------------------- |
| 67 | |
| 68 | |
| 69 | def _list_local_remote_tracking_branches( |
| 70 | remote_name: str, |
| 71 | root: pathlib.Path, |
| 72 | ) -> list[str]: |
| 73 | """Return branch names for which local remote-tracking refs exist. |
| 74 | |
| 75 | Recursively walks ``.muse/remotes/<remote_name>/`` and returns the |
| 76 | relative path of every *file* found, which corresponds to the branch |
| 77 | name (including namespace prefixes such as ``feature/groove``). |
| 78 | Returns an empty list when the directory does not yet exist. |
| 79 | |
| 80 | Branch names containing ``/`` are stored as nested directories by |
| 81 | :func:`~maestro.muse_cli.config.set_remote_head`, so a simple |
| 82 | ``iterdir()`` is insufficient — a recursive walk is required. |
| 83 | |
| 84 | Args: |
| 85 | remote_name: Remote name (e.g. ``"origin"``). |
| 86 | root: Repository root. |
| 87 | |
| 88 | Returns: |
| 89 | Sorted list of branch name strings (relative paths). |
| 90 | """ |
| 91 | remotes_dir = root / ".muse" / "remotes" / remote_name |
| 92 | if not remotes_dir.is_dir(): |
| 93 | return [] |
| 94 | return sorted( |
| 95 | str(p.relative_to(remotes_dir)) |
| 96 | for p in remotes_dir.rglob("*") |
| 97 | if p.is_file() |
| 98 | ) |
| 99 | |
| 100 | |
| 101 | def _remove_remote_tracking_ref( |
| 102 | remote_name: str, |
| 103 | branch: str, |
| 104 | root: pathlib.Path, |
| 105 | ) -> None: |
| 106 | """Delete the local remote-tracking pointer for *remote_name*/*branch*. |
| 107 | |
| 108 | Silently ignores missing files — pruning is idempotent. |
| 109 | |
| 110 | Args: |
| 111 | remote_name: Remote name (e.g. ``"origin"``). |
| 112 | branch: Branch name to prune. |
| 113 | root: Repository root. |
| 114 | """ |
| 115 | pointer = root / ".muse" / "remotes" / remote_name / branch |
| 116 | if pointer.is_file(): |
| 117 | pointer.unlink() |
| 118 | logger.debug("✅ Pruned stale ref %s/%s", remote_name, branch) |
| 119 | |
| 120 | |
| 121 | # --------------------------------------------------------------------------- |
| 122 | # Fetch report formatting |
| 123 | # --------------------------------------------------------------------------- |
| 124 | |
| 125 | |
| 126 | def _format_fetch_line( |
| 127 | remote_name: str, |
| 128 | info: FetchBranchInfo, |
| 129 | ) -> str: |
| 130 | """Format a single fetch report line in git-style output. |
| 131 | |
| 132 | Example output:: |
| 133 | |
| 134 | From origin: + abc1234 feature/guitar -> origin/feature/guitar (new branch) |
| 135 | From origin: + def5678 main -> origin/main |
| 136 | |
| 137 | Args: |
| 138 | remote_name: The remote that was fetched from. |
| 139 | info: Branch info returned by the Hub. |
| 140 | |
| 141 | Returns: |
| 142 | A human-readable status line. |
| 143 | """ |
| 144 | short_id = info["head_commit_id"][:8] |
| 145 | branch = info["branch"] |
| 146 | suffix = " (new branch)" if info["is_new"] else "" |
| 147 | return f"From {remote_name}: + {short_id} {branch} -> {remote_name}/{branch}{suffix}" |
| 148 | |
| 149 | |
| 150 | # --------------------------------------------------------------------------- |
| 151 | # Single-remote fetch core |
| 152 | # --------------------------------------------------------------------------- |
| 153 | |
| 154 | |
| 155 | async def _fetch_remote_async( |
| 156 | *, |
| 157 | root: pathlib.Path, |
| 158 | remote_name: str, |
| 159 | branches: list[str], |
| 160 | prune: bool, |
| 161 | ) -> int: |
| 162 | """Fetch refs from a single remote and update local remote-tracking pointers. |
| 163 | |
| 164 | Does NOT touch local branches or muse-work/. |
| 165 | |
| 166 | Args: |
| 167 | root: Repository root path. |
| 168 | remote_name: Name of the remote to fetch from (e.g. ``"origin"``). |
| 169 | branches: Specific branches to fetch. Empty list means all branches. |
| 170 | prune: When ``True``, remove stale local remote-tracking refs after |
| 171 | fetching. |
| 172 | |
| 173 | Returns: |
| 174 | Number of branches updated (new or moved). |
| 175 | |
| 176 | Raises: |
| 177 | :class:`typer.Exit`: On unrecoverable errors (network, config, server). |
| 178 | """ |
| 179 | remote_url = get_remote(remote_name, root) |
| 180 | if not remote_url: |
| 181 | if remote_name == "origin": |
| 182 | typer.echo(_NO_REMOTE_MSG) |
| 183 | else: |
| 184 | typer.echo( |
| 185 | f"No remote named '{remote_name}'. " |
| 186 | "Run `muse remote add` to configure it." |
| 187 | ) |
| 188 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 189 | |
| 190 | fetch_request = FetchRequest(branches=branches) |
| 191 | |
| 192 | try: |
| 193 | async with MuseHubClient(base_url=remote_url, repo_root=root) as hub: |
| 194 | response = await hub.post("/fetch", json=fetch_request) |
| 195 | |
| 196 | if response.status_code != 200: |
| 197 | typer.echo( |
| 198 | f"❌ Hub rejected fetch (HTTP {response.status_code}): {response.text}" |
| 199 | ) |
| 200 | logger.error( |
| 201 | "❌ muse fetch failed: HTTP %d — %s", |
| 202 | response.status_code, |
| 203 | response.text, |
| 204 | ) |
| 205 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 206 | |
| 207 | except typer.Exit: |
| 208 | raise |
| 209 | except httpx.TimeoutException: |
| 210 | typer.echo(f"❌ Fetch timed out connecting to {remote_url}") |
| 211 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 212 | except httpx.HTTPError as exc: |
| 213 | typer.echo(f"❌ Network error during fetch: {exc}") |
| 214 | logger.error("❌ muse fetch network error: %s", exc, exc_info=True) |
| 215 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 216 | |
| 217 | # ── Parse response ─────────────────────────────────────────────────── |
| 218 | raw_body: object = response.json() |
| 219 | if not isinstance(raw_body, dict): |
| 220 | typer.echo("❌ Hub returned unexpected fetch response shape.") |
| 221 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 222 | |
| 223 | raw_branches = raw_body.get("branches", []) |
| 224 | if not isinstance(raw_branches, list): |
| 225 | raw_branches = [] |
| 226 | |
| 227 | fetch_response: FetchResponse = FetchResponse( |
| 228 | branches=[ |
| 229 | FetchBranchInfo( |
| 230 | branch=str(b.get("branch", "")), |
| 231 | head_commit_id=str(b.get("head_commit_id", "")), |
| 232 | is_new=bool(b.get("is_new", False)), |
| 233 | ) |
| 234 | for b in raw_branches |
| 235 | if isinstance(b, dict) |
| 236 | ] |
| 237 | ) |
| 238 | |
| 239 | # ── Determine which branches are new locally ────────────────────────── |
| 240 | # Override is_new from the Hub: the Hub may not know whether we have a |
| 241 | # local tracking ref. We always check ourselves. |
| 242 | updated_count = 0 |
| 243 | remote_branch_names: set[str] = set() |
| 244 | |
| 245 | for branch_info in fetch_response["branches"]: |
| 246 | branch = branch_info["branch"] |
| 247 | head_id = branch_info["head_commit_id"] |
| 248 | if not branch or not head_id: |
| 249 | continue |
| 250 | |
| 251 | remote_branch_names.add(branch) |
| 252 | |
| 253 | # Determine newness from local state, not the Hub's hint |
| 254 | existing_local_head = get_remote_head(remote_name, branch, root) |
| 255 | is_new = existing_local_head is None |
| 256 | branch_info = FetchBranchInfo( |
| 257 | branch=branch, |
| 258 | head_commit_id=head_id, |
| 259 | is_new=is_new, |
| 260 | ) |
| 261 | |
| 262 | # Only update (and count) if the remote HEAD actually moved |
| 263 | if existing_local_head != head_id: |
| 264 | set_remote_head(remote_name, branch, head_id, root) |
| 265 | updated_count += 1 |
| 266 | typer.echo(_format_fetch_line(remote_name, branch_info)) |
| 267 | else: |
| 268 | logger.debug("✅ %s/%s already up to date [%s]", remote_name, branch, head_id[:8]) |
| 269 | |
| 270 | # ── Prune stale refs ────────────────────────────────────────────────── |
| 271 | if prune: |
| 272 | local_branches = _list_local_remote_tracking_branches(remote_name, root) |
| 273 | for local_branch in local_branches: |
| 274 | if local_branch not in remote_branch_names: |
| 275 | _remove_remote_tracking_ref(remote_name, local_branch, root) |
| 276 | typer.echo( |
| 277 | f"✂️ Pruned {remote_name}/{local_branch} " |
| 278 | "(no longer exists on remote)" |
| 279 | ) |
| 280 | |
| 281 | return updated_count |
| 282 | |
| 283 | |
| 284 | # --------------------------------------------------------------------------- |
| 285 | # Multi-remote fetch entry point |
| 286 | # --------------------------------------------------------------------------- |
| 287 | |
| 288 | |
| 289 | async def _fetch_async( |
| 290 | *, |
| 291 | root: pathlib.Path, |
| 292 | remote_name: str, |
| 293 | fetch_all: bool, |
| 294 | prune: bool, |
| 295 | branches: list[str], |
| 296 | ) -> None: |
| 297 | """Orchestrate fetch across one or all remotes. |
| 298 | |
| 299 | Args: |
| 300 | root: Repository root path. |
| 301 | remote_name: Remote to fetch from (ignored when ``fetch_all`` is ``True``). |
| 302 | fetch_all: When ``True``, fetch from every remote in ``.muse/config.toml``. |
| 303 | prune: When ``True``, remove stale remote-tracking refs after fetching. |
| 304 | branches: Specific branches to request. Empty = all branches. |
| 305 | |
| 306 | Raises: |
| 307 | :class:`typer.Exit`: On any unrecoverable error. |
| 308 | """ |
| 309 | if fetch_all: |
| 310 | remotes = list_remotes(root) |
| 311 | if not remotes: |
| 312 | typer.echo(_NO_REMOTES_MSG) |
| 313 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 314 | |
| 315 | total_updated = 0 |
| 316 | for remote_cfg in remotes: |
| 317 | r_name = remote_cfg["name"] |
| 318 | count = await _fetch_remote_async( |
| 319 | root=root, |
| 320 | remote_name=r_name, |
| 321 | branches=branches, |
| 322 | prune=prune, |
| 323 | ) |
| 324 | total_updated += count |
| 325 | |
| 326 | if total_updated == 0: |
| 327 | typer.echo("✅ Everything up to date — all remotes are current.") |
| 328 | else: |
| 329 | typer.echo(f"✅ Fetched {total_updated} branch update(s) across all remotes.") |
| 330 | else: |
| 331 | count = await _fetch_remote_async( |
| 332 | root=root, |
| 333 | remote_name=remote_name, |
| 334 | branches=branches, |
| 335 | prune=prune, |
| 336 | ) |
| 337 | if count == 0: |
| 338 | typer.echo(f"✅ {remote_name} is already up to date.") |
| 339 | |
| 340 | logger.info("✅ muse fetch complete") |
| 341 | |
| 342 | |
| 343 | # --------------------------------------------------------------------------- |
| 344 | # Typer command |
| 345 | # --------------------------------------------------------------------------- |
| 346 | |
| 347 | |
| 348 | @app.callback(invoke_without_command=True) |
| 349 | def fetch( |
| 350 | ctx: typer.Context, |
| 351 | remote: str = typer.Option( |
| 352 | "origin", |
| 353 | "--remote", |
| 354 | help="Remote name to fetch from.", |
| 355 | ), |
| 356 | fetch_all: bool = typer.Option( |
| 357 | False, |
| 358 | "--all", |
| 359 | help="Fetch from all configured remotes.", |
| 360 | ), |
| 361 | prune: bool = typer.Option( |
| 362 | False, |
| 363 | "--prune", |
| 364 | "-p", |
| 365 | help="Remove local remote-tracking refs that no longer exist on the remote.", |
| 366 | ), |
| 367 | branch: list[str] = typer.Option( |
| 368 | [], |
| 369 | "--branch", |
| 370 | "-b", |
| 371 | help="Branch to fetch (repeatable). Defaults to all branches.", |
| 372 | ), |
| 373 | ) -> None: |
| 374 | """Fetch refs and objects from remote without merging. |
| 375 | |
| 376 | Updates ``.muse/remotes/<remote>/<branch>`` tracking pointers to reflect |
| 377 | the current state of the remote without modifying the local branch or |
| 378 | muse-work/. Use ``muse pull`` to fetch AND merge in one step. |
| 379 | |
| 380 | Examples:: |
| 381 | |
| 382 | muse fetch |
| 383 | muse fetch --all |
| 384 | muse fetch --prune |
| 385 | muse fetch --remote staging --branch main --branch feature/bass-v2 |
| 386 | """ |
| 387 | root = require_repo() |
| 388 | |
| 389 | try: |
| 390 | asyncio.run( |
| 391 | _fetch_async( |
| 392 | root=root, |
| 393 | remote_name=remote, |
| 394 | fetch_all=fetch_all, |
| 395 | prune=prune, |
| 396 | branches=list(branch), |
| 397 | ) |
| 398 | ) |
| 399 | except typer.Exit: |
| 400 | raise |
| 401 | except Exception as exc: |
| 402 | typer.echo(f"❌ muse fetch failed: {exc}") |
| 403 | logger.error("❌ muse fetch unexpected error: %s", exc, exc_info=True) |
| 404 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |