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