cgcardona / muse public
fetch.py python
404 lines 13.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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))