gabriel / muse public
fetch.py python
457 lines 15.5 KB
Raw
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 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 from typing import TYPE_CHECKING, 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.errors import ExitCode
79 from muse.core.pack import apply_pack
80 from muse.core.repo import require_repo
81 from muse.core.store import get_all_commits, read_current_branch
82 from muse.core.transport import TransportError, make_transport, negotiate_have
83 from muse.core.validation import sanitize_display
84 from muse.core._types import BranchHeads
85
86 if TYPE_CHECKING:
87 from muse.core.transport import HttpTransport, LocalFileTransport
88
89 logger = logging.getLogger(__name__)
90
91
92 class _RemoteResultJson(TypedDict):
93 """Per-remote/branch result inside a ``muse fetch --json`` response."""
94
95 remote: str
96 branch: str
97 status: str # "fetched" | "up_to_date" | "dry_run" | "branch_missing"
98 commits_received: int
99 objects_written: int
100 head: str | None # remote commit-id after the fetch, null when not fetched
101 pruned: list[str] # "<remote>/<branch>" strings for each pruned ref
102 dry_run: bool
103
104
105 class _FetchJson(TypedDict):
106 """Top-level JSON object emitted by ``muse fetch --json``."""
107
108 results: list[_RemoteResultJson]
109 dry_run: bool
110
111
112 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
113 """Register the ``muse fetch`` subcommand and all its flags."""
114 parser = subparsers.add_parser(
115 "fetch",
116 help="Download commits, snapshots, and objects from a remote.",
117 description=__doc__,
118 formatter_class=argparse.RawDescriptionHelpFormatter,
119 )
120 parser.add_argument(
121 "remote",
122 nargs="?",
123 default="origin",
124 help="Remote name to fetch from (default: origin). Ignored when --all is set.",
125 )
126 parser.add_argument(
127 "--branch", "-b",
128 default=None,
129 help=(
130 "Remote branch to fetch (default: current branch). "
131 "When combined with --all, this branch is fetched from every remote."
132 ),
133 )
134 parser.add_argument(
135 "--all",
136 action="store_true",
137 default=False,
138 help="Fetch all configured remotes.",
139 )
140 parser.add_argument(
141 "--prune", "-p",
142 action="store_true",
143 default=False,
144 help=(
145 "Remove local remote-tracking refs for branches that no longer exist "
146 "on the remote (mirrors git fetch --prune)."
147 ),
148 )
149 parser.add_argument(
150 "--dry-run", "-n",
151 action="store_true",
152 default=False,
153 dest="dry_run",
154 help="Show what would be fetched without writing any objects or tracking refs.",
155 )
156 # Tag handling flags — reserved for future use when tag storage is added.
157 tag_group = parser.add_mutually_exclusive_group()
158 tag_group.add_argument(
159 "--tags",
160 action="store_true",
161 default=None,
162 dest="tags",
163 help="Fetch tags from the remote (default).",
164 )
165 tag_group.add_argument(
166 "--no-tags",
167 action="store_false",
168 dest="tags",
169 help="Do not fetch tags from the remote.",
170 )
171 fmt_group = parser.add_mutually_exclusive_group()
172 fmt_group.add_argument(
173 "--format",
174 choices=["text", "json"],
175 default="text",
176 dest="format",
177 help="Output format: 'text' (default) or 'json'.",
178 )
179 fmt_group.add_argument(
180 "--json",
181 action="store_const",
182 const="json",
183 dest="format",
184 help="Shorthand for --format json.",
185 )
186 parser.set_defaults(func=run)
187
188
189 def _stale_ref_names(
190 root: pathlib.Path,
191 remote: str,
192 live_branch_heads: BranchHeads,
193 ) -> list[str]:
194 """Return branch names whose local tracking refs are absent from *live_branch_heads*.
195
196 Branch names may contain slashes (e.g. ``feat/my-thing``), so the refs are
197 stored as nested files under ``.muse/remotes/<remote>/``. We walk the tree
198 recursively and compute the relative path from ``refs_dir`` to get the full
199 branch name to compare against *live_branch_heads*.
200
201 Symlinks inside the refs directory are skipped to prevent path-traversal
202 attacks via a malicious remote name or branch name.
203 """
204 refs_dir = root / ".muse" / "remotes" / remote
205 if not refs_dir.is_dir():
206 return []
207 stale: list[str] = []
208 for ref_file in refs_dir.rglob("*"):
209 if ref_file.is_symlink() or not ref_file.is_file():
210 continue
211 branch_name = str(ref_file.relative_to(refs_dir))
212 if branch_name not in live_branch_heads:
213 stale.append(branch_name)
214 return stale
215
216
217 def _prune_stale_refs(
218 root: pathlib.Path,
219 remote: str,
220 live_branch_heads: BranchHeads,
221 *,
222 dry_run: bool,
223 ) -> list[str]:
224 """Prune stale remote-tracking refs, returning the list of pruned branch names.
225
226 Walks ``.muse/remotes/<remote>/`` recursively (branch names may contain
227 slashes and are stored as nested paths) and, unless *dry_run* is True,
228 deletes any file whose relative path is not a key in *live_branch_heads*.
229 Prints a ``- [deleted]`` or ``Would prune`` line for each, mirroring
230 ``git fetch --prune`` output. All output goes to stderr so stdout stays
231 clean for structured JSON.
232 """
233 refs_dir = root / ".muse" / "remotes" / remote
234 pruned: list[str] = []
235 for branch_name in sorted(_stale_ref_names(root, remote, live_branch_heads)):
236 safe_ref = f"{sanitize_display(remote)}/{sanitize_display(branch_name)}"
237 if dry_run:
238 print(f" Would prune {safe_ref}", file=sys.stderr)
239 else:
240 ref_file = refs_dir / branch_name
241 ref_file.unlink()
242 # Remove empty parent directories left behind (e.g. feat/ after feat/my-thing).
243 for parent in ref_file.parents:
244 if parent == refs_dir:
245 break
246 try:
247 parent.rmdir()
248 except OSError:
249 break
250 logger.debug("🗑 Pruned stale tracking ref %s/%s", remote, branch_name)
251 print(f" - [deleted] {safe_ref}", file=sys.stderr)
252 pruned.append(f"{remote}/{branch_name}")
253 return pruned
254
255
256 def _fetch_one(
257 root: pathlib.Path,
258 remote: str,
259 branch: str,
260 *,
261 prune: bool,
262 dry_run: bool,
263 ) -> _RemoteResultJson:
264 """Fetch a single remote/branch pair.
265
266 Returns a :class:`_RemoteResultJson` describing the outcome. Raises
267 ``SystemExit`` on unrecoverable errors (unknown remote, network failure).
268 Writes nothing when *dry_run* is True.
269
270 MWP negotiation (depth-limited ``have`` list) is used to minimise the
271 amount of history sent to the server, matching the behaviour of
272 ``muse pull``.
273 """
274 result: _RemoteResultJson = {
275 "remote": remote,
276 "branch": branch,
277 "status": "fetched",
278 "commits_received": 0,
279 "objects_written": 0,
280 "head": None,
281 "pruned": [],
282 "dry_run": dry_run,
283 }
284
285 url = get_remote(remote, root)
286 if url is None:
287 print(
288 f"❌ Remote '{sanitize_display(remote)}' is not configured.",
289 file=sys.stderr,
290 )
291 print(
292 f" Add it with: muse remote add {sanitize_display(remote)} <url>",
293 file=sys.stderr,
294 )
295 raise SystemExit(ExitCode.USER_ERROR)
296
297 token = get_signing_identity(root, remote_url=url)
298 transport = make_transport(url)
299
300 try:
301 info = transport.fetch_remote_info(url, token)
302 except TransportError as exc:
303 print(
304 f"❌ Cannot reach remote '{sanitize_display(remote)}': "
305 f"{sanitize_display(str(exc))}",
306 file=sys.stderr,
307 )
308 raise SystemExit(ExitCode.INTERNAL_ERROR)
309
310 remote_commit_id = info["branch_heads"].get(branch)
311 if remote_commit_id is None:
312 if prune:
313 # The branch we were tracking is gone from the remote.
314 # Prune it (and any other stale refs) then return cleanly —
315 # this mirrors `git fetch --prune` behaviour where a deleted
316 # upstream branch produces " - [deleted] remote/branch" rather
317 # than an error.
318 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=dry_run)
319 result["status"] = "branch_missing"
320 result["pruned"] = pruned
321 return result
322 available = ", ".join(
323 sanitize_display(b) for b in sorted(info["branch_heads"])
324 )
325 print(
326 f"❌ Branch '{sanitize_display(branch)}' does not exist on "
327 f"remote '{sanitize_display(remote)}'.",
328 file=sys.stderr,
329 )
330 print(f" Available branches: {available}", file=sys.stderr)
331 raise SystemExit(ExitCode.USER_ERROR)
332
333 already_known = get_remote_head(remote, branch, root)
334 if already_known == remote_commit_id:
335 print(
336 f"✅ {sanitize_display(remote)}/{sanitize_display(branch)} "
337 f"is already up to date ({remote_commit_id[:8]})",
338 file=sys.stderr,
339 )
340 if prune and not dry_run:
341 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=False)
342 result["pruned"] = pruned
343 result["status"] = "up_to_date"
344 result["head"] = remote_commit_id
345 return result
346
347 if dry_run:
348 print(
349 f" Would fetch {sanitize_display(remote)}/{sanitize_display(branch)} "
350 f"→ {remote_commit_id[:8]}",
351 file=sys.stderr,
352 )
353 if prune:
354 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=True)
355 result["pruned"] = pruned
356 result["status"] = "dry_run"
357 result["head"] = remote_commit_id
358 return result
359
360 # ── MWP negotiation: find minimal have-list before fetching ──────────────
361 all_local = [c.commit_id for c in get_all_commits(root)]
362 try:
363 have_for_fetch = negotiate_have(
364 transport, url, token, [remote_commit_id], all_local
365 )
366 except TransportError:
367 logger.debug("negotiate not supported — falling back to full have list")
368 have_for_fetch = all_local
369
370 print(f"Fetching {sanitize_display(remote)}/{sanitize_display(branch)} …", file=sys.stderr)
371
372 try:
373 bundle = transport.fetch_pack(
374 url, token, want=[remote_commit_id], have=have_for_fetch
375 )
376 except TransportError as exc:
377 print(f"❌ Fetch failed: {sanitize_display(str(exc))}", file=sys.stderr)
378 raise SystemExit(ExitCode.INTERNAL_ERROR)
379
380 apply_result = apply_pack(root, bundle)
381 set_remote_head(remote, branch, remote_commit_id, root)
382
383 commits_received: int = apply_result["commits_written"]
384 objects_written: int = apply_result["objects_written"]
385 print(
386 f"✅ Fetched {commits_received} commit(s), "
387 f"{objects_written} new object(s) "
388 f"from {sanitize_display(remote)}/{sanitize_display(branch)} "
389 f"({remote_commit_id[:8]})",
390 file=sys.stderr,
391 )
392
393 if prune:
394 pruned = _prune_stale_refs(root, remote, info["branch_heads"], dry_run=False)
395 result["pruned"] = pruned
396
397 result["commits_received"] = commits_received
398 result["objects_written"] = objects_written
399 result["head"] = remote_commit_id
400 return result
401
402
403 def run(args: argparse.Namespace) -> None:
404 """Download commits, snapshots, and objects from a remote.
405
406 Updates the remote tracking pointer but does NOT change the local branch
407 HEAD or working tree. Run ``muse pull`` to fetch and merge in one step.
408
409 When ``--all`` is passed every configured remote is fetched; the positional
410 ``remote`` argument is ignored. When ``--branch`` is also given, that
411 specific branch is fetched from every remote.
412
413 When ``--prune`` is passed, stale remote-tracking refs (branches that no
414 longer exist on the remote) are deleted after a successful fetch.
415
416 JSON output (``--format json`` / ``--json``) always goes to stdout;
417 human-readable progress and errors always go to stderr.
418 """
419 root = require_repo()
420 current_branch = read_current_branch(root)
421 dry_run: bool = args.dry_run
422 prune: bool = args.prune
423 fmt: str = args.format
424
425 if dry_run:
426 print("(dry run — no objects or refs will be written)", file=sys.stderr)
427
428 results: list[_RemoteResultJson] = []
429
430 if args.all:
431 remotes = list_remotes(root)
432 if not remotes:
433 print("❌ No remotes configured.", file=sys.stderr)
434 print(" Add one with: muse remote add <name> <url>", file=sys.stderr)
435 raise SystemExit(ExitCode.USER_ERROR)
436 # --branch with --all: fetch the specified branch from every remote.
437 branch: str = args.branch or current_branch
438 for remote_cfg in remotes:
439 result = _fetch_one(
440 root,
441 remote_cfg["name"],
442 branch,
443 prune=prune,
444 dry_run=dry_run,
445 )
446 results.append(result)
447 else:
448 remote: str = args.remote
449 # Use the explicitly passed branch, or fall back to the current local branch.
450 # Do NOT use get_upstream() here — that returns the remote *name*, not branch.
451 branch_single: str = args.branch or current_branch
452 result = _fetch_one(root, remote, branch_single, prune=prune, dry_run=dry_run)
453 results.append(result)
454
455 if fmt == "json":
456 output: _FetchJson = {"results": results, "dry_run": dry_run}
457 print(json.dumps(output))
File History 4 commits
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago