gabriel / muse public
fetch.py python
572 lines 19.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """muse fetch — download commits, snapshots, and objects from a remote.
2
3 Fetches the latest state of a remote branch without touching the local branch
4 HEAD or working tree. After a successful fetch:
5
6 - All new commits, snapshots, and objects from the remote are stored locally.
7 - The remote tracking pointer ``.muse/remotes/<remote>/<branch>`` is updated.
8
9 Use ``muse pull`` to fetch *and* merge into the current branch, or run
10 ``muse merge`` after fetching to integrate on your own schedule.
11
12 Flags
13 -----
14 ``--all``
15 Fetch every configured remote instead of just one. When combined with
16 ``--branch``, that branch is fetched from every remote.
17
18 ``--prune / -p``
19 After fetching, delete local remote-tracking refs (pointers under
20 ``.muse/remotes/<remote>/``) for branches that no longer exist on the
21 remote. Mirrors ``git fetch --prune``.
22
23 ``--dry-run / -n``
24 Show what would be fetched without writing anything.
25
26 ``--tags``
27 Also fetch tags from the remote (default behaviour when tags exist).
28
29 ``--no-tags``
30 Do not fetch tags from the remote.
31
32 ``--format {text,json}`` / ``--json``
33 Emit a machine-readable JSON object on stdout instead of human text.
34 Human-readable diagnostics always go to stderr regardless of format.
35
36 JSON output schema
37 ------------------
38 Always emits a single JSON object on stdout::
39
40 {
41 "results": [
42 {
43 "remote": "<name>",
44 "branch": "<branch>",
45 "status": "fetched | up_to_date | dry_run | branch_missing",
46 "commits_received": <N>,
47 "objects_written": <N>,
48 "head": "<commit-id> | null",
49 "pruned": ["<remote>/<branch>", ...],
50 "dry_run": false
51 }
52 ],
53 "dry_run": false
54 }
55
56 Exit codes::
57
58 0 — success (fetched, up_to_date, dry_run, or branch_missing + prune)
59 1 — remote not configured, network error, or no remotes when using --all
60 """
61
62 from __future__ import annotations
63
64 import argparse
65 import json
66 import logging
67 import pathlib
68 import sys
69 import time
70 from collections.abc import Callable
71 from typing import TYPE_CHECKING, TypedDict
72
73 from muse.cli.config import (
74 get_signing_identity,
75 get_remote,
76 get_remote_head,
77 list_remotes,
78 set_remote_head,
79 )
80 from muse.core.envelope import EnvelopeJson, make_envelope
81 from muse.core.errors import ExitCode
82 from muse.core.object_store import write_object
83 from muse.core.pack import ObjectPayload, apply_mpack
84 from muse.core.repo import require_repo
85 from muse.core.store import get_all_commits, read_current_branch
86 from muse.core.timing import start_timer
87 from muse.core.transport import TransportError, make_transport, negotiate_have
88 from muse.core.validation import sanitize_display
89 from muse.core._types import BranchHeads, short_id
90
91 logger = logging.getLogger(__name__)
92
93
94 class _RemoteResultJson(TypedDict):
95 """Per-remote/branch fetch result nested inside :class:`_FetchJson`.
96
97 Fields
98 ------
99 remote Remote name (e.g. ``"origin"``, ``"local"``).
100 branch Branch name fetched from the remote.
101 status Outcome string — one of:
102 ``"fetched"`` (new data received),
103 ``"up_to_date"`` (remote matches local),
104 ``"dry_run"`` (no writes performed),
105 ``"branch_missing"`` (branch not found on remote).
106 commits_received Number of new commit records written to local storage.
107 objects_written Number of new content-addressed objects (blobs) written.
108 head Remote commit ID that the branch tip points to after the
109 fetch, or ``None`` when nothing was fetched.
110 pruned List of ``"<remote>/<branch>"`` strings for each ref that
111 existed locally but is no longer present on the remote.
112 dry_run True when the fetch was simulated — no objects were written.
113 """
114
115 remote: str
116 branch: str
117 status: str
118 commits_received: int
119 objects_written: int
120 head: str | None
121 pruned: list[str]
122 dry_run: bool
123
124
125 class _FetchJson(EnvelopeJson):
126 """JSON output for ``muse fetch --json``.
127
128 Inherits the 6 standard envelope fields from :class:`~muse.core.envelope.EnvelopeJson`.
129
130 Fields
131 ------
132 results One result entry per remote/branch combination fetched;
133 see :class:`_RemoteResultJson`.
134 dry_run True when no objects were written (``--dry-run`` was passed).
135 """
136
137 results: list[_RemoteResultJson]
138 dry_run: bool
139
140
141 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
142 """Register the ``muse fetch`` subcommand and all its flags."""
143 parser = subparsers.add_parser(
144 "fetch",
145 help="Download commits, snapshots, and objects from a remote.",
146 description=__doc__,
147 formatter_class=argparse.RawDescriptionHelpFormatter,
148 )
149 parser.add_argument(
150 "remote",
151 nargs="?",
152 default="origin",
153 help="Remote name to fetch from (default: origin). Ignored when --all is set.",
154 )
155 parser.add_argument(
156 "--branch", "-b",
157 default=None,
158 help=(
159 "Remote branch to fetch (default: current branch). "
160 "When combined with --all, this branch is fetched from every remote."
161 ),
162 )
163 parser.add_argument(
164 "--all",
165 action="store_true",
166 default=False,
167 help="Fetch all configured remotes.",
168 )
169 parser.add_argument(
170 "--prune", "-p",
171 action="store_true",
172 default=False,
173 help=(
174 "Remove local remote-tracking refs for branches that no longer exist "
175 "on the remote (mirrors git fetch --prune)."
176 ),
177 )
178 parser.add_argument(
179 "--dry-run", "-n",
180 action="store_true",
181 default=False,
182 dest="dry_run",
183 help="Show what would be fetched without writing any objects or tracking refs.",
184 )
185 # Tag handling flags — reserved for future use when tag storage is added.
186 tag_group = parser.add_mutually_exclusive_group()
187 tag_group.add_argument(
188 "--tags",
189 action="store_true",
190 default=None,
191 dest="tags",
192 help="Fetch tags from the remote (default).",
193 )
194 tag_group.add_argument(
195 "--no-tags",
196 action="store_false",
197 dest="tags",
198 help="Do not fetch tags from the remote.",
199 )
200 fmt_group = parser.add_mutually_exclusive_group()
201 fmt_group.add_argument(
202 "--format",
203 choices=["text", "json"],
204 default="text",
205 dest="format",
206 help="Output format: 'text' (default) or 'json'.",
207 )
208 fmt_group.add_argument(
209 "--json",
210 action="store_const",
211 const="json",
212 dest="format",
213 help="Shorthand for --format json.",
214 )
215 parser.set_defaults(func=run)
216
217
218 def _stale_ref_names(
219 root: pathlib.Path,
220 remote: str,
221 live_branch_heads: BranchHeads,
222 ) -> list[str]:
223 """Return branch names whose local tracking refs are absent from *live_branch_heads*.
224
225 Branch names may contain slashes (e.g. ``feat/my-thing``), so the refs are
226 stored as nested files under ``.muse/remotes/<remote>/``. We walk the tree
227 recursively and compute the relative path from ``refs_dir`` to get the full
228 branch name to compare against *live_branch_heads*.
229
230 Symlinks inside the refs directory are skipped to prevent path-traversal
231 attacks via a malicious remote name or branch name.
232 """
233 refs_dir = root / ".muse" / "remotes" / remote
234 if not refs_dir.is_dir():
235 return []
236 stale: list[str] = []
237 for ref_file in refs_dir.rglob("*"):
238 if ref_file.is_symlink() or not ref_file.is_file():
239 continue
240 branch_name = str(ref_file.relative_to(refs_dir))
241 if branch_name not in live_branch_heads:
242 stale.append(branch_name)
243 return stale
244
245
246 def _prune_stale_refs(
247 root: pathlib.Path,
248 remote: str,
249 live_branch_heads: BranchHeads,
250 *,
251 dry_run: bool,
252 ) -> list[str]:
253 """Prune stale remote-tracking refs, returning the list of pruned branch names.
254
255 Walks ``.muse/remotes/<remote>/`` recursively (branch names may contain
256 slashes and are stored as nested paths) and, unless *dry_run* is True,
257 deletes any file whose relative path is not a key in *live_branch_heads*.
258 Prints a ``- [deleted]`` or ``Would prune`` line for each, mirroring
259 ``git fetch --prune`` output. All output goes to stderr so stdout stays
260 clean for structured JSON.
261 """
262 refs_dir = root / ".muse" / "remotes" / remote
263 pruned: list[str] = []
264 for branch_name in sorted(_stale_ref_names(root, remote, live_branch_heads)):
265 safe_ref = f"{sanitize_display(remote)}/{sanitize_display(branch_name)}"
266 if dry_run:
267 print(f" Would prune {safe_ref}", file=sys.stderr)
268 else:
269 ref_file = refs_dir / branch_name
270 ref_file.unlink()
271 # Remove empty parent directories left behind (e.g. feat/ after feat/my-thing).
272 for parent in ref_file.parents:
273 if parent == refs_dir:
274 break
275 try:
276 parent.rmdir()
277 except OSError:
278 break
279 logger.debug("🗑 Pruned stale tracking ref %s/%s", remote, branch_name)
280 print(f" - [deleted] {safe_ref}", file=sys.stderr)
281 pruned.append(f"{remote}/{branch_name}")
282 return pruned
283
284
285 def _fetch_one(
286 root: pathlib.Path,
287 remote: str,
288 branch: str,
289 *,
290 prune: bool,
291 dry_run: bool,
292 fmt: str = "text",
293 elapsed: Callable[[], float],
294 ) -> _RemoteResultJson:
295 """Fetch a single remote/branch pair.
296
297 Returns a :class:`_RemoteResultJson` describing the outcome. Raises
298 ``SystemExit`` on unrecoverable errors (unknown remote, network failure).
299 Writes nothing when *dry_run* is True.
300
301 MWP negotiation (depth-limited ``have`` list) is used to minimise the
302 amount of history sent to the server, matching the behaviour of
303 ``muse pull``.
304 """
305 result: _RemoteResultJson = {
306 "remote": remote,
307 "branch": branch,
308 "status": "fetched",
309 "commits_received": 0,
310 "objects_written": 0,
311 "head": None,
312 "pruned": [],
313 "dry_run": dry_run,
314 }
315
316 url = get_remote(remote, root)
317 if url is None:
318 if fmt == "json":
319 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.USER_ERROR), **{
320 "error": "remote_not_configured",
321 "remote": remote,
322 "message": f"remote '{remote}' is not configured",
323 "hint": f"muse remote add {remote} <url>",
324 }}))
325 print(
326 f"❌ Remote '{sanitize_display(remote)}' is not configured.",
327 file=sys.stderr,
328 )
329 print(
330 f" Add it with: muse remote add {sanitize_display(remote)} <url>",
331 file=sys.stderr,
332 )
333 raise SystemExit(ExitCode.USER_ERROR)
334
335 token = get_signing_identity(root, remote_url=url)
336 transport = make_transport(url)
337
338 try:
339 info = transport.fetch_remote_info(url, token)
340 except TransportError as exc:
341 if fmt == "json":
342 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.INTERNAL_ERROR), **{
343 "error": "remote_unreachable",
344 "remote": remote,
345 "message": str(exc),
346 }}))
347 print(
348 f"❌ Cannot reach remote '{sanitize_display(remote)}': "
349 f"{sanitize_display(str(exc))}",
350 file=sys.stderr,
351 )
352 raise SystemExit(ExitCode.INTERNAL_ERROR)
353
354 remote_commit_id = info["branch_heads"].get(branch)
355 if remote_commit_id is None:
356 if prune:
357 # The branch we were tracking is gone from the remote.
358 # Prune it (and any other stale refs) then return cleanly —
359 # this mirrors `git fetch --prune` behaviour where a deleted
360 # upstream branch produces " - [deleted] remote/branch" rather
361 # than an error.
362 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=dry_run)
363 result["status"] = "branch_missing"
364 result["pruned"] = pruned
365 return result
366 available = sorted(info["branch_heads"])
367 if fmt == "json":
368 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.USER_ERROR), **{
369 "error": "branch_not_found",
370 "remote": remote,
371 "branch": branch,
372 "available": available,
373 "message": f"branch '{branch}' does not exist on remote '{remote}'",
374 }}))
375 print(
376 f"❌ Branch '{sanitize_display(branch)}' does not exist on "
377 f"remote '{sanitize_display(remote)}'.",
378 file=sys.stderr,
379 )
380 print(f" Available branches: {', '.join(sanitize_display(b) for b in available)}", file=sys.stderr)
381 raise SystemExit(ExitCode.USER_ERROR)
382
383 already_known = get_remote_head(remote, branch, root)
384 if already_known == remote_commit_id:
385 print(
386 f"✅ {sanitize_display(remote)}/{sanitize_display(branch)} "
387 f"is already up to date ({short_id(remote_commit_id)})",
388 file=sys.stderr,
389 )
390 if prune and not dry_run:
391 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=False)
392 result["pruned"] = pruned
393 result["status"] = "up_to_date"
394 result["head"] = remote_commit_id
395 return result
396
397 if dry_run:
398 print(
399 f" Would fetch {sanitize_display(remote)}/{sanitize_display(branch)} "
400 f"→ {short_id(remote_commit_id)}",
401 file=sys.stderr,
402 )
403 if prune:
404 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=True)
405 result["pruned"] = pruned
406 result["status"] = "dry_run"
407 result["head"] = remote_commit_id
408 return result
409
410 # ── MWP negotiation: find minimal have-list before fetching ──────────────
411 all_local = [c.commit_id for c in get_all_commits(root)]
412 t0_negotiate = time.perf_counter()
413 try:
414 have_for_fetch = negotiate_have(
415 transport, url, token, [remote_commit_id], all_local
416 )
417 except TransportError:
418 logger.debug("fetch: negotiate not supported — using full have list")
419 have_for_fetch = all_local
420 t_negotiate = time.perf_counter() - t0_negotiate
421
422 print(f"Fetching {sanitize_display(remote)}/{sanitize_display(branch)} …", file=sys.stderr)
423
424 # ── MWP single-POST fetch/stream ──────────────────────────────────────────
425 objects_written: int = 0
426 wire_bytes: int = 0
427
428 def _on_object(obj: ObjectPayload) -> None:
429 nonlocal objects_written, wire_bytes
430 raw = obj["content"]
431 if raw:
432 wire_bytes += len(raw)
433 if write_object(root, obj["object_id"], raw):
434 objects_written += 1
435
436 t0_stream = time.perf_counter()
437 try:
438 stream_result = transport.fetch_stream(
439 url, token,
440 want=[remote_commit_id],
441 have=have_for_fetch,
442 on_object=_on_object,
443 )
444 except TransportError as exc:
445 if fmt == "json":
446 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.INTERNAL_ERROR), **{
447 "error": "fetch_failed",
448 "remote": remote,
449 "branch": branch,
450 "message": str(exc),
451 }}))
452 print(f"❌ Fetch failed: {sanitize_display(str(exc))}", file=sys.stderr)
453 raise SystemExit(ExitCode.INTERNAL_ERROR)
454 t_stream = time.perf_counter() - t0_stream
455
456 apply_result = apply_mpack(root, {
457 "commits": stream_result["commits"],
458 "snapshots": stream_result["snapshots"],
459 })
460 set_remote_head(remote, branch, remote_commit_id, root)
461
462 commits_received: int = apply_result["commits_written"]
463 wire_kib = wire_bytes / 1024
464 print(
465 f"[stream] negotiate: {t_negotiate:.2f}s "
466 f"fetch/stream: {t_stream:.2f}s "
467 f"objects: {stream_result['objects_received']} "
468 f"commits: {commits_received}",
469 file=sys.stderr,
470 )
471 print(
472 f"[stream] wrote {objects_written} object(s) ({wire_kib:.1f} KiB wire) "
473 f"TOTAL: {t_negotiate + t_stream:.2f}s",
474 file=sys.stderr,
475 )
476 print(
477 f"✅ Fetched {commits_received} commit(s), "
478 f"{objects_written} new object(s) "
479 f"from {sanitize_display(remote)}/{sanitize_display(branch)} "
480 f"({short_id(remote_commit_id)})",
481 file=sys.stderr,
482 )
483
484 if prune:
485 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=False)
486 result["pruned"] = pruned
487
488 result["commits_received"] = commits_received
489 result["objects_written"] = objects_written
490 result["head"] = remote_commit_id
491 return result
492
493
494 def run(args: argparse.Namespace) -> None:
495 """Download commits, snapshots, and objects from a remote.
496
497 Updates remote tracking pointers but does NOT change local HEAD or the
498 working tree. Run ``muse pull`` to fetch and merge in one step.
499 ``--all`` fetches every configured remote; ``--prune`` deletes stale
500 remote-tracking refs after a successful fetch.
501
502 Agent quickstart
503 ----------------
504 ::
505
506 muse fetch local --json
507 muse fetch local main --json
508 muse fetch --all --json
509 muse fetch local --prune --json
510
511 JSON fields
512 -----------
513 results List of per-remote result objects: ``remote``, ``branch``,
514 ``new_commits``, ``objects_fetched``, ``ok``, ``error``.
515 dry_run ``true`` if ``--dry-run`` was passed (no writes occurred).
516
517 Exit codes
518 ----------
519 0 Fetch complete (all remotes succeeded).
520 1 One or more remotes failed, no remotes configured, or bad arguments.
521 2 Not inside a Muse repository.
522 """
523 elapsed = start_timer()
524 root = require_repo()
525 current_branch = read_current_branch(root)
526 dry_run: bool = args.dry_run
527 prune: bool = args.prune
528 fmt: str = args.format
529
530 if dry_run:
531 print("(dry run — no objects or refs will be written)", file=sys.stderr)
532
533 results: list[_RemoteResultJson] = []
534
535 if args.all:
536 remotes = list_remotes(root)
537 if not remotes:
538 if fmt == "json":
539 print(json.dumps({**make_envelope(elapsed, exit_code=ExitCode.USER_ERROR), **{
540 "error": "no_remotes",
541 "message": "no remotes configured",
542 "hint": "muse remote add <name> <url>",
543 }}))
544 print("❌ No remotes configured.", file=sys.stderr)
545 print(" Add one with: muse remote add <name> <url>", file=sys.stderr)
546 raise SystemExit(ExitCode.USER_ERROR)
547 # --branch with --all: fetch the specified branch from every remote.
548 branch: str = args.branch or current_branch
549 for remote_cfg in remotes:
550 result = _fetch_one(
551 root,
552 remote_cfg["name"],
553 branch,
554 prune=prune,
555 dry_run=dry_run,
556 fmt=fmt,
557 elapsed=elapsed,
558 )
559 results.append(result)
560 else:
561 remote: str = args.remote
562 # Use the explicitly passed branch, or fall back to the current local branch.
563 # Do NOT use get_upstream() here — that returns the remote *name*, not branch.
564 branch_single: str = args.branch or current_branch
565 result = _fetch_one(root, remote, branch_single, prune=prune, dry_run=dry_run, fmt=fmt, elapsed=elapsed)
566 results.append(result)
567
568 if fmt == "json":
569 print(json.dumps({**make_envelope(elapsed), **{
570 "results": results,
571 "dry_run": dry_run,
572 }}))
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago