fetch.py
python
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