push.py
python
sha256:35855b7bbce81b93612c655e23587bdebbb5fc09856ff5cbf5cd5b195d0547d4
merge: dev → main (rc11, urllib migration, object store inv…
Sonnet 4.6
patch
49 days ago
| 1 | """``muse push`` — upload local commits, snapshots, and objects to a remote. |
| 2 | |
| 3 | MWP push protocol |
| 4 | ----------------- |
| 5 | |
| 6 | ``muse push`` uses the Muse Wire Protocol (MWP) for all remotes. |
| 7 | |
| 8 | **Phase 0 — ref discovery:** |
| 9 | ``GET {url}/refs`` returns current branch heads. This cheap call also |
| 10 | establishes the ``have`` anchors used in commit negotiation. |
| 11 | |
| 12 | **Phase 1 — object deduplication (MWP):** |
| 13 | ``POST {url}/filter-objects`` accepts the full list of object IDs the |
| 14 | client intends to push. The server returns only the *missing* subset. |
| 15 | For incremental pushes this reduces the object payload to near-zero. |
| 16 | |
| 17 | **Phase 2 — large-object presign (MWP):** |
| 18 | Objects above :data:`~muse.core.transport.LARGE_OBJECT_THRESHOLD` (64 KB) |
| 19 | are uploaded directly to S3/R2 via presigned PUT URLs — they never transit |
| 20 | the API server. ``local://`` remotes return all IDs in ``inline`` and |
| 21 | fall back to the pack body automatically. |
| 22 | |
| 23 | **Phase 3 — parallel object upload:** |
| 24 | Remaining (small) objects are batched into chunks of |
| 25 | :data:`~muse.core.transport.CHUNK_OBJECTS` and uploaded in parallel using |
| 26 | ``concurrent.futures.ThreadPoolExecutor`` (``--workers``, default 16). |
| 27 | |
| 28 | **Phase 4 — commit push:** |
| 29 | A single ``POST {url}/push`` carries commits and snapshots with an empty |
| 30 | ``objects`` list (blobs are already on the remote after Phases 1-3). |
| 31 | |
| 32 | Fast-forward check |
| 33 | ------------------ |
| 34 | |
| 35 | By default, ``muse push`` requires the remote branch to be an ancestor of the |
| 36 | local branch (a fast-forward update). If the remote has diverged, the push is |
| 37 | rejected with exit code 1. Pass ``--force`` to bypass this check. |
| 38 | |
| 39 | Upstream tracking |
| 40 | ----------------- |
| 41 | |
| 42 | Pass ``-u`` / ``--set-upstream`` on first push to record the tracking |
| 43 | relationship between the local branch and the remote branch so that future |
| 44 | ``muse pull`` and ``muse push`` invocations can resolve the remote automatically. |
| 45 | |
| 46 | JSON output (``--format json`` / ``--json``) schema:: |
| 47 | |
| 48 | { |
| 49 | "status": "pushed | up_to_date | dry_run | deleted", |
| 50 | "remote": "<name>", |
| 51 | "branch": "<branch>", |
| 52 | "head": "<sha256> | null", |
| 53 | "commits_sent": <N>, |
| 54 | "objects_sent": <N>, |
| 55 | "force": false, |
| 56 | "dry_run": false |
| 57 | } |
| 58 | |
| 59 | Exit codes:: |
| 60 | |
| 61 | 0 — success (pushed, up_to_date, deleted, or dry_run) |
| 62 | 1 — remote not configured, branch has no commits, push rejected, |
| 63 | authentication failure, network error |
| 64 | """ |
| 65 | |
| 66 | from __future__ import annotations |
| 67 | |
| 68 | import argparse |
| 69 | import json |
| 70 | import logging |
| 71 | import pathlib |
| 72 | import platform |
| 73 | import subprocess |
| 74 | import sys |
| 75 | import urllib.error |
| 76 | import urllib.request |
| 77 | from concurrent.futures import Future, ThreadPoolExecutor, as_completed |
| 78 | from typing import TypedDict |
| 79 | |
| 80 | from muse.cli.config import ( |
| 81 | delete_remote_head, |
| 82 | get_signing_identity, |
| 83 | get_remote, |
| 84 | get_remote_head, |
| 85 | get_remote_pack_origin, |
| 86 | set_remote_head, |
| 87 | set_upstream, |
| 88 | ) |
| 89 | from muse.core.errors import ExitCode |
| 90 | from muse.core.object_store import read_object |
| 91 | from muse.core.pack import ( |
| 92 | ObjectPayload, |
| 93 | PackBundle, |
| 94 | PushResult, |
| 95 | RemoteInfo, |
| 96 | build_pack, |
| 97 | build_pack_from_walk, |
| 98 | collect_object_ids, |
| 99 | collect_object_ids_from_walk, |
| 100 | stream_object_chunks, |
| 101 | walk_commits, |
| 102 | ) |
| 103 | from muse.core.repo import require_repo |
| 104 | from muse.core.store import commit_exists, get_head_commit_id, read_commit, read_current_branch |
| 105 | from muse.core.compression import compress_zlib, compute_delta |
| 106 | from muse.core.transport import ( |
| 107 | CHUNK_COMMITS, |
| 108 | CHUNK_OBJECTS, |
| 109 | MuseTransport, |
| 110 | TransportError, |
| 111 | make_transport, |
| 112 | ) |
| 113 | from muse.core.validation import sanitize_display |
| 114 | |
| 115 | logger = logging.getLogger(__name__) |
| 116 | |
| 117 | # Number of retries for transient presigned-URL failures (S3/R2 503, 429). |
| 118 | _PRESIGN_RETRIES: int = 3 |
| 119 | |
| 120 | # Objects larger than this threshold use the presigned PUT path (direct to R2). |
| 121 | # Objects at or below this threshold use the pack endpoint (batched API POSTs). |
| 122 | # |
| 123 | # Rationale: direct R2 PUT bypasses Cloudflare entirely, which is worthwhile |
| 124 | # when the body is large enough to saturate available bandwidth. For small |
| 125 | # objects the overhead is dominated by TLS handshake latency (N handshakes for |
| 126 | # N objects), not transfer time. The pack endpoint costs M = ceil(N/1000) |
| 127 | # handshakes instead of N — a 1 000× reduction for the common case. |
| 128 | # |
| 129 | # 1 MB is the crossover: below this, handshake cost dominates (use pack); |
| 130 | # above this, transfer time dominates (use presign). |
| 131 | PACK_OBJECT_THRESHOLD: int = 1_000_000 # 1 MB |
| 132 | |
| 133 | # Maximum objects per pack POST and maximum total bytes per pack. |
| 134 | # Server-side limits are PACK_MAX_OBJECTS=1_000 and PACK_MAX_BYTES=50_000_000. |
| 135 | # The client uses a more conservative 250-object limit per pack so that each |
| 136 | # server-side batch of R2 puts (concurrent via asyncio.gather) completes well |
| 137 | # within the 60 s nginx upstream timeout. Staging data: 342 objects ≈ 27 s, |
| 138 | # so 250 objects ≈ 20 s → 3× safety margin. Larger repos simply use more packs. |
| 139 | PACK_MAX_OBJECTS: int = 250 |
| 140 | PACK_MAX_BYTES: int = 50_000_000 # 50 MB |
| 141 | |
| 142 | # Maximum concurrent pack POST requests. All packs are submitted in parallel |
| 143 | # up to this limit. 8 workers is enough to cover a 2 000-file repo in one |
| 144 | # wave (8 × 250 = 2 000). Higher values provide diminishing returns since |
| 145 | # the bottleneck shifts to the server-side R2 connection pool (100 slots). |
| 146 | _PACK_WORKERS: int = 8 |
| 147 | |
| 148 | |
| 149 | class _PushJson(TypedDict): |
| 150 | """Stable JSON schema for push output.""" |
| 151 | |
| 152 | status: str |
| 153 | remote: str |
| 154 | branch: str |
| 155 | head: str | None |
| 156 | commits_sent: int |
| 157 | objects_sent: int |
| 158 | force: bool |
| 159 | dry_run: bool |
| 160 | |
| 161 | |
| 162 | def _upload_chunk( |
| 163 | transport: MuseTransport, |
| 164 | url: str, |
| 165 | token: str | None, |
| 166 | chunk: list[ObjectPayload], |
| 167 | chunk_num: int, |
| 168 | total_chunks: int, |
| 169 | ) -> tuple[int, int]: |
| 170 | """Upload one chunk of objects and return ``(stored, skipped)``. |
| 171 | |
| 172 | Progress messages go to **stderr** so they don't pollute JSON stdout. |
| 173 | """ |
| 174 | print( |
| 175 | f" Uploading objects chunk {chunk_num}/{total_chunks} " |
| 176 | f"({len(chunk)} object(s)) …", |
| 177 | file=sys.stderr, |
| 178 | ) |
| 179 | resp = transport.push_objects(url, token, chunk) |
| 180 | return resp["stored"], resp["skipped"] |
| 181 | |
| 182 | |
| 183 | def _upload_presigned(oid: str, url: str, raw: bytes, retries: int = _PRESIGN_RETRIES) -> None: |
| 184 | """PUT *raw* bytes directly to a presigned S3/R2 URL (MWP Phase 2). |
| 185 | |
| 186 | Dispatches to a curl-based implementation on macOS to work around an |
| 187 | OpenSSL 3.6.x / Python 3.14 TLS 1.3 bug (SSLV3_ALERT_BAD_RECORD_MAC) |
| 188 | that only affects Homebrew Python on macOS. Linux (staging/prod Docker) |
| 189 | uses urllib directly — different OpenSSL build, no bug. |
| 190 | |
| 191 | Retries ``retries`` times on transient errors (HTTP 429/5xx, network). |
| 192 | Raises on the last exception if all retries are exhausted. |
| 193 | """ |
| 194 | if platform.system() == "Darwin": |
| 195 | _upload_presigned_curl(oid, url, raw, retries) |
| 196 | else: |
| 197 | _upload_presigned_urllib(oid, url, raw, retries) |
| 198 | |
| 199 | |
| 200 | def _upload_presigned_curl(oid: str, url: str, raw: bytes, retries: int) -> None: |
| 201 | """macOS: PUT via system curl (SecureTransport/LibreSSL — not Homebrew OpenSSL).""" |
| 202 | import time as _time |
| 203 | |
| 204 | last_exc: Exception | None = None |
| 205 | for attempt in range(1, retries + 1): |
| 206 | result = subprocess.run( |
| 207 | [ |
| 208 | "curl", "--silent", "--show-error", "--fail-with-body", |
| 209 | "--request", "PUT", |
| 210 | "--data-binary", "@-", |
| 211 | "--header", f"Content-Length: {len(raw)}", |
| 212 | "--max-time", "300", |
| 213 | url, |
| 214 | ], |
| 215 | input=raw, |
| 216 | capture_output=True, |
| 217 | ) |
| 218 | if result.returncode == 0: |
| 219 | return |
| 220 | # curl exit 22 = HTTP error (--fail-with-body); others = network/SSL |
| 221 | err_msg = result.stderr.decode(errors="replace").strip() |
| 222 | exc = RuntimeError(f"presigned upload failed for {oid[:8]}: {err_msg}") |
| 223 | last_exc = exc |
| 224 | if attempt < retries: |
| 225 | _time.sleep(2 ** (attempt - 1)) |
| 226 | else: |
| 227 | break |
| 228 | |
| 229 | raise last_exc or RuntimeError(f"presigned upload failed for {oid[:8]}") |
| 230 | |
| 231 | |
| 232 | def _upload_presigned_urllib(oid: str, url: str, raw: bytes, retries: int) -> None: |
| 233 | """Linux: PUT via urllib (works correctly on Docker/prod OpenSSL builds).""" |
| 234 | import time as _time |
| 235 | |
| 236 | last_exc: Exception | None = None |
| 237 | for attempt in range(1, retries + 1): |
| 238 | try: |
| 239 | req = urllib.request.Request(url=url, data=raw, method="PUT") |
| 240 | with urllib.request.urlopen(req, timeout=300): |
| 241 | pass |
| 242 | return |
| 243 | except urllib.error.HTTPError as exc: |
| 244 | last_exc = exc |
| 245 | if exc.code not in (429, 500, 502, 503, 504): |
| 246 | raise |
| 247 | wait = 2 ** (attempt - 1) |
| 248 | logger.warning( |
| 249 | "⚠️ Presigned upload for %s returned %d — retry %d/%d in %ds", |
| 250 | oid[:8], exc.code, attempt, retries, wait, |
| 251 | ) |
| 252 | _time.sleep(wait) |
| 253 | except urllib.error.URLError as exc: |
| 254 | last_exc = exc |
| 255 | if attempt < retries: |
| 256 | _time.sleep(1) |
| 257 | else: |
| 258 | break |
| 259 | |
| 260 | raise last_exc or RuntimeError(f"presigned upload failed for {oid[:8]}") |
| 261 | |
| 262 | |
| 263 | def _encode_object( |
| 264 | oid: str, |
| 265 | raw: bytes, |
| 266 | path: str, |
| 267 | oid_to_base: dict[str, str], |
| 268 | root: pathlib.Path, |
| 269 | ) -> ObjectPayload: |
| 270 | """Compress or delta-encode one object for wire transmission. |
| 271 | |
| 272 | Tries Tier 2 (delta+zlib) first if a base ID is available. Falls back to |
| 273 | Tier 1 (plain zlib) when the delta is not profitable or the base is absent. |
| 274 | Always returns a valid :class:`ObjectPayload` with ``encoding`` set. |
| 275 | |
| 276 | Args: |
| 277 | oid: SHA-256 object ID of the target object. |
| 278 | raw: Raw (uncompressed) object bytes. |
| 279 | path: Repository path (used only for the payload's ``path`` field). |
| 280 | oid_to_base: Mapping of oid → base_oid from the server's filter-objects response. |
| 281 | root: Local repo root for reading base object bytes. |
| 282 | |
| 283 | Returns: |
| 284 | :class:`ObjectPayload` with ``encoding``, ``path``, and optionally ``base_id`` set. |
| 285 | """ |
| 286 | base_id = oid_to_base.get(oid) |
| 287 | if base_id: |
| 288 | base_raw = read_object(root, base_id) |
| 289 | if base_raw is not None: |
| 290 | try: |
| 291 | delta = compute_delta(base_raw, raw) |
| 292 | return ObjectPayload( |
| 293 | object_id=oid, |
| 294 | content=delta, |
| 295 | path=path, |
| 296 | encoding="delta+zlib", |
| 297 | base_id=base_id, |
| 298 | ) |
| 299 | except ValueError: |
| 300 | pass # delta not profitable — fall through to plain zlib |
| 301 | |
| 302 | return ObjectPayload( |
| 303 | object_id=oid, |
| 304 | content=compress_zlib(raw), |
| 305 | path=path, |
| 306 | encoding="zlib", |
| 307 | ) |
| 308 | |
| 309 | |
| 310 | def _push_objects_as_packs( |
| 311 | transport: MuseTransport, |
| 312 | url: str, |
| 313 | signing: object, |
| 314 | object_ids: list[str], |
| 315 | root: pathlib.Path, |
| 316 | oid_to_path: dict[str, str] | None = None, |
| 317 | oid_to_base: dict[str, str] | None = None, |
| 318 | pack_origin: str | None = None, |
| 319 | ) -> tuple[int, int]: |
| 320 | """Upload *object_ids* via the pack endpoint in parallel (MWP Phase 2). |
| 321 | |
| 322 | Objects are zlib-compressed (Tier 1) or delta+zlib-encoded (Tier 2) before |
| 323 | batching. Batches of ≤ :data:`PACK_MAX_OBJECTS` items and ≤ :data:`PACK_MAX_BYTES` |
| 324 | total are submitted simultaneously using a :class:`~concurrent.futures.ThreadPoolExecutor` |
| 325 | with up to ``_PACK_WORKERS`` workers. |
| 326 | |
| 327 | GitHub-level approach: every pack flies concurrently rather than waiting |
| 328 | for the previous one to complete. For a 2 000-file repo with 8 packs of |
| 329 | 250 objects, the server processes all 2 000 objects simultaneously through |
| 330 | the global R2 semaphore (100 slots), yielding ~1.5 s instead of ~10 s serial. |
| 331 | |
| 332 | Args: |
| 333 | transport: Active :class:`~muse.core.transport.MuseTransport` instance. |
| 334 | url: Remote repository URL (used for path extraction). |
| 335 | signing: Ed25519 signing identity (passed through to ``push_object_pack``). |
| 336 | object_ids: List of object IDs to upload (must all be ≤ 1 MB each). |
| 337 | root: Local repository root. |
| 338 | oid_to_path: ``{oid: path}`` map used to populate the ``path`` field on each |
| 339 | payload (from the walk result, optional). |
| 340 | oid_to_base: ``{oid: base_oid}`` map from the server's filter-objects response |
| 341 | for Tier 2 delta encoding (optional). |
| 342 | pack_origin: Optional origin override for pack uploads (e.g. a Cloudflare |
| 343 | Worker URL). When set, the URL origin is replaced with this value |
| 344 | while preserving the ``/{owner}/{slug}`` path from *url*. |
| 345 | |
| 346 | Returns: |
| 347 | ``(total_stored, total_skipped)`` accumulated across all pack requests. |
| 348 | |
| 349 | Raises: |
| 350 | :class:`~muse.core.transport.TransportError` on HTTP 4xx/5xx or network failure. |
| 351 | """ |
| 352 | import urllib.parse as _up |
| 353 | if pack_origin: |
| 354 | # Replace the origin (scheme + netloc) while keeping the path (/{owner}/{slug}). |
| 355 | _parsed = _up.urlparse(url) |
| 356 | _origin = _up.urlparse(pack_origin) |
| 357 | pack_url = _up.urlunparse(_origin._replace( |
| 358 | path=_parsed.path, query="", fragment="" |
| 359 | )) |
| 360 | else: |
| 361 | pack_url = url |
| 362 | |
| 363 | _oid_to_path: dict[str, str] = oid_to_path or {} |
| 364 | _oid_to_base: dict[str, str] = oid_to_base or {} |
| 365 | |
| 366 | # ── Step 1: build all packs in memory ──────────────────────────────────── |
| 367 | batches: list[list[ObjectPayload]] = [] |
| 368 | current_batch: list[ObjectPayload] = [] |
| 369 | current_bytes = 0 |
| 370 | |
| 371 | for oid in object_ids: |
| 372 | raw = read_object(root, oid) or b"" |
| 373 | payload = _encode_object(oid, raw, _oid_to_path.get(oid, ""), _oid_to_base, root) |
| 374 | wire_size = len(payload["content"]) |
| 375 | if current_batch and ( |
| 376 | len(current_batch) >= PACK_MAX_OBJECTS |
| 377 | or current_bytes + wire_size > PACK_MAX_BYTES |
| 378 | ): |
| 379 | batches.append(current_batch) |
| 380 | current_batch = [] |
| 381 | current_bytes = 0 |
| 382 | current_batch.append(payload) |
| 383 | current_bytes += wire_size |
| 384 | |
| 385 | if current_batch: |
| 386 | batches.append(current_batch) |
| 387 | |
| 388 | if not batches: |
| 389 | return 0, 0 |
| 390 | |
| 391 | total_batches = len(batches) |
| 392 | |
| 393 | # ── Step 2: submit all packs in parallel ────────────────────────────────── |
| 394 | pack_workers = min(_PACK_WORKERS, total_batches) |
| 395 | |
| 396 | def _send_pack(pack_num: int, pack: list[ObjectPayload]) -> tuple[int, int]: |
| 397 | wire_bytes = sum(len(o["content"]) for o in pack) |
| 398 | print( |
| 399 | f" Pack {pack_num}/{total_batches} ({len(pack)} object(s), " |
| 400 | f"{wire_bytes // 1024} KiB) …", |
| 401 | file=sys.stderr, |
| 402 | ) |
| 403 | resp = transport.push_object_pack(pack_url, signing, pack) |
| 404 | return resp["stored"], resp["skipped"] |
| 405 | |
| 406 | total_stored = 0 |
| 407 | total_skipped = 0 |
| 408 | |
| 409 | with ThreadPoolExecutor(max_workers=pack_workers) as pool: |
| 410 | pack_futures: list[Future[tuple[int, int]]] = [ |
| 411 | pool.submit(_send_pack, i + 1, pack) |
| 412 | for i, pack in enumerate(batches) |
| 413 | ] |
| 414 | for fut in as_completed(pack_futures): |
| 415 | stored, skipped = fut.result() # re-raises TransportError on failure |
| 416 | total_stored += stored |
| 417 | total_skipped += skipped |
| 418 | |
| 419 | return total_stored, total_skipped |
| 420 | |
| 421 | |
| 422 | def _push_mwp( |
| 423 | transport: MuseTransport, |
| 424 | url: str, |
| 425 | token: str | None, |
| 426 | root: pathlib.Path, |
| 427 | local_head: str, |
| 428 | have: list[str], |
| 429 | branch: str, |
| 430 | force: bool, |
| 431 | max_workers: int = 16, |
| 432 | branch_have: list[str] | None = None, |
| 433 | send_local_head: bool = True, |
| 434 | pack_origin: str | None = None, |
| 435 | ) -> tuple[PushResult, int, int]: |
| 436 | """Full MWP push: dedup negotiation → presign → parallel upload → commit push. |
| 437 | |
| 438 | MWP phases executed here: |
| 439 | |
| 440 | - Phase 1 — ``POST /filter-objects``: discover which objects are missing. |
| 441 | - Phase 2 — ``POST /presign``: get presigned PUT URLs for large objects. |
| 442 | - Phase 3 — parallel direct-to-storage upload for large objects. |
| 443 | - Phase 4 — parallel chunked ``POST /push/objects`` for small objects. |
| 444 | - Phase 5 — ``POST /push`` with commits + snapshots, empty objects list. |
| 445 | |
| 446 | All progress messages go to **stderr** so ``--format json`` stdout stays clean. |
| 447 | |
| 448 | *have* is the wide set used for **object deduplication** (Phase 1) — it |
| 449 | should include every ref the remote has on any branch so blobs are never |
| 450 | re-uploaded unnecessarily. |
| 451 | |
| 452 | *branch_have* is the narrow set used for the **commit bundle boundary** |
| 453 | (Phase 5). It must contain only the remote HEAD of the target branch so |
| 454 | the BFS walks all the way back to a commit whose parent is the remote |
| 455 | branch tip. Without this separation, an intermediate branch (e.g. dev) |
| 456 | sitting between the target branch's remote HEAD and the local tip can |
| 457 | short-circuit the BFS and produce a bundle the server cannot verify as a |
| 458 | fast-forward. |
| 459 | """ |
| 460 | # ── Phase 1: object deduplication ──────────────────────────────────────── |
| 461 | # Single BFS walk — result is shared with pack building in Phase 4 so we |
| 462 | # never traverse the commit graph twice in the same push. |
| 463 | # Use the full `have` list here — every remote ref the server might have |
| 464 | # on any branch is a potential source of already-present blobs. |
| 465 | import time as _pt |
| 466 | _t0 = _pt.perf_counter() |
| 467 | dedup_walk = walk_commits(root, [local_head], have=have) |
| 468 | _t1 = _pt.perf_counter() |
| 469 | all_candidate_ids = collect_object_ids_from_walk(dedup_walk) |
| 470 | _t2 = _pt.perf_counter() |
| 471 | print(f" [perf] walk_commits: {_t1-_t0:.3f}s collect_object_ids: {_t2-_t1:.3f}s ({len(all_candidate_ids)} objects)", file=sys.stderr) |
| 472 | try: |
| 473 | filter_result = transport.filter_objects( |
| 474 | url, token, all_candidate_ids, dedup_walk["oid_to_path"] |
| 475 | ) |
| 476 | _t3 = _pt.perf_counter() |
| 477 | print(f" [perf] filter_objects: {_t3-_t2:.3f}s", file=sys.stderr) |
| 478 | missing_ids: set[str] = set(filter_result["missing"]) |
| 479 | oid_to_base: dict[str, str] = filter_result["bases"] |
| 480 | skipped_count = len(all_candidate_ids) - len(missing_ids) |
| 481 | if skipped_count: |
| 482 | print( |
| 483 | f" Object dedup: {skipped_count} already on remote, " |
| 484 | f"{len(missing_ids)} to upload.", |
| 485 | file=sys.stderr, |
| 486 | ) |
| 487 | if oid_to_base: |
| 488 | print(f" Delta bases: {len(oid_to_base)} object(s) will use delta+zlib encoding.", file=sys.stderr) |
| 489 | except TransportError as _filter_exc: |
| 490 | if _filter_exc.status_code == 404: |
| 491 | # Server predates filter-objects endpoint — send everything. |
| 492 | logger.debug("filter-objects not supported (404), falling back to full upload") |
| 493 | missing_ids = set(all_candidate_ids) |
| 494 | oid_to_base = {} |
| 495 | else: |
| 496 | # Any other error (502 mid-startup, connection refused, timeout) must |
| 497 | # not silently fall back to uploading every object — that causes |
| 498 | # cascading OOM crashes on small servers. Fail loudly instead. |
| 499 | raise |
| 500 | |
| 501 | # ── Phase 2: split missing objects into large (presign) and small (pack) ── |
| 502 | # |
| 503 | # Design goal: web-scale with millions of agents per second, no bottlenecks. |
| 504 | # |
| 505 | # OLD approach: presign ALL N objects → N TLS handshakes from client to R2. |
| 506 | # For 7 000 objects this was 281 s on macOS (OpenSSL 3.6.x TLS 1.3 bug |
| 507 | # multiplied by N connections). |
| 508 | # |
| 509 | # NEW approach: |
| 510 | # Small objects (≤ 1 MB): pack endpoint → M = ceil(N/1000) batched POSTs |
| 511 | # through Cloudflare → MuseHub API. 7 000 objects = 7 POSTs. No |
| 512 | # direct R2 connections so the macOS TLS bug does not apply. |
| 513 | # Large objects (> 1 MB): presigned PUT → direct to R2. Per-object |
| 514 | # handshake overhead is negligible vs body transfer time at this size. |
| 515 | # |
| 516 | # Fallback hierarchy: |
| 517 | # 1. pack endpoint → if 404 (old server), fall back to presign for all. |
| 518 | # 2. presign → if not supported (local://), inline → push_objects. |
| 519 | presigned_ids: list[str] = [] # objects uploaded directly to R2 |
| 520 | inline_ids: set[str] = set() # objects that must go through push_objects |
| 521 | total_stored = 0 |
| 522 | total_skipped = 0 |
| 523 | |
| 524 | _t_p2 = _pt.perf_counter() |
| 525 | all_missing_list = list(missing_ids) |
| 526 | |
| 527 | # Read sizes to classify: large → presign, small → pack. |
| 528 | # Objects are read once here; presign reads them again (acceptable trade-off |
| 529 | # — avoids holding all bytes in RAM simultaneously). |
| 530 | large_ids: list[str] = [] # > PACK_OBJECT_THRESHOLD — direct R2 PUT |
| 531 | small_ids: list[str] = [] # ≤ PACK_OBJECT_THRESHOLD — pack endpoint POST |
| 532 | for oid in all_missing_list: |
| 533 | raw = read_object(root, oid) |
| 534 | if raw is None or len(raw) <= PACK_OBJECT_THRESHOLD: |
| 535 | small_ids.append(oid) |
| 536 | else: |
| 537 | large_ids.append(oid) |
| 538 | |
| 539 | # ── Phase 2a: pack endpoint for small objects ───────────────────────────── |
| 540 | # M = ceil(N / 1000) round-trips replaces N individual TLS handshakes. |
| 541 | pack_fallback = False # True if server lacks pack endpoint (→ use presign) |
| 542 | if small_ids: |
| 543 | print( |
| 544 | f" Uploading {len(small_ids)} small object(s) via pack endpoint …", |
| 545 | file=sys.stderr, |
| 546 | ) |
| 547 | try: |
| 548 | _t_pack = _pt.perf_counter() |
| 549 | pack_stored, pack_skipped = _push_objects_as_packs( |
| 550 | transport, url, token, small_ids, root, |
| 551 | oid_to_path=dedup_walk["oid_to_path"], |
| 552 | oid_to_base=oid_to_base, |
| 553 | pack_origin=pack_origin, |
| 554 | ) |
| 555 | _t_pack_end = _pt.perf_counter() |
| 556 | total_stored += pack_stored |
| 557 | total_skipped += pack_skipped |
| 558 | print( |
| 559 | f" [perf] push/object-pack: {_t_pack_end-_t_pack:.3f}s " |
| 560 | f"({len(small_ids)} objects, {pack_stored} stored, {pack_skipped} skipped)", |
| 561 | file=sys.stderr, |
| 562 | ) |
| 563 | except TransportError as _pack_exc: |
| 564 | if _pack_exc.status_code == 404: |
| 565 | # Server predates push/object-pack (old deployment) — treat all |
| 566 | # small objects as large and fall back to the presign path. |
| 567 | logger.debug( |
| 568 | "push/object-pack not supported (404), " |
| 569 | "falling back to presign for all objects" |
| 570 | ) |
| 571 | large_ids = all_missing_list # send everything via presign |
| 572 | small_ids = [] |
| 573 | pack_fallback = True |
| 574 | else: |
| 575 | raise |
| 576 | |
| 577 | # ── Phase 2b: presign large objects (direct R2 PUT) ────────────────────── |
| 578 | # When the backend is S3/R2, large objects get presigned PUT URLs for |
| 579 | # direct client→R2 upload, bypassing Cloudflare entirely. |
| 580 | # When the backend is local://, presign returns everything as inline. |
| 581 | presigned_map: dict[str, str] = {} |
| 582 | if large_ids: |
| 583 | try: |
| 584 | for batch_start in range(0, max(1, len(large_ids)), CHUNK_OBJECTS): |
| 585 | batch = large_ids[batch_start : batch_start + CHUNK_OBJECTS] |
| 586 | presign_resp = transport.presign_objects(url, token, batch, "put") |
| 587 | presigned_map.update(presign_resp["presigned"]) |
| 588 | inline_ids.update(presign_resp["inline"]) |
| 589 | except TransportError: |
| 590 | logger.debug("presign not supported — all objects go through push_objects") |
| 591 | presigned_map = {} |
| 592 | inline_ids = set(large_ids) |
| 593 | |
| 594 | if presigned_map: |
| 595 | # ── Phase 3: parallel direct-to-R2 uploads ──────────────────────────── |
| 596 | presigned_ids = [oid for oid in large_ids if oid in presigned_map] |
| 597 | print( |
| 598 | f" Uploading {len(presigned_ids)} large object(s) directly to R2 …", |
| 599 | file=sys.stderr, |
| 600 | ) |
| 601 | sizes: dict[str, int] = {} |
| 602 | presign_first_exc: Exception | None = None |
| 603 | |
| 604 | # Process in batches of CHUNK_OBJECTS to bound peak RAM usage. |
| 605 | with ThreadPoolExecutor(max_workers=max_workers) as pool: |
| 606 | for batch_start in range(0, max(1, len(presigned_ids)), CHUNK_OBJECTS): |
| 607 | batch_ids = presigned_ids[batch_start : batch_start + CHUNK_OBJECTS] |
| 608 | batch_futs: dict[Future[None], str] = {} |
| 609 | for oid in batch_ids: |
| 610 | raw = read_object(root, oid) or b"" |
| 611 | sizes[oid] = len(raw) |
| 612 | batch_futs[pool.submit( |
| 613 | _upload_presigned, oid, presigned_map[oid], raw |
| 614 | )] = oid |
| 615 | for fut in as_completed(batch_futs): |
| 616 | try: |
| 617 | fut.result() |
| 618 | except Exception as exc: |
| 619 | logger.error("❌ Presigned upload failed for %s: %s", |
| 620 | batch_futs[fut][:8], exc) |
| 621 | if presign_first_exc is None: |
| 622 | presign_first_exc = exc |
| 623 | |
| 624 | if presign_first_exc is not None: |
| 625 | raise presign_first_exc |
| 626 | |
| 627 | # ── Phase 3b: confirm uploaded objects with server ───────────────────── |
| 628 | total_registered = 0 |
| 629 | total_conf_skipped = 0 |
| 630 | for batch_start in range(0, max(1, len(presigned_ids)), CHUNK_OBJECTS): |
| 631 | batch_ids = presigned_ids[batch_start : batch_start + CHUNK_OBJECTS] |
| 632 | batch_sizes = {oid: sizes[oid] for oid in batch_ids if oid in sizes} |
| 633 | conf = transport.confirm_objects(url, token, batch_ids, batch_sizes) |
| 634 | total_registered += conf["registered"] |
| 635 | total_conf_skipped += conf["skipped"] |
| 636 | logger.info( |
| 637 | "✅ Confirmed %d large object(s) with server (%d already registered)", |
| 638 | total_registered, total_conf_skipped, |
| 639 | ) |
| 640 | |
| 641 | _t_p2_end = _pt.perf_counter() |
| 642 | print( |
| 643 | f" [perf] object upload total: {_t_p2_end-_t_p2:.3f}s " |
| 644 | f"({len(small_ids)} via pack, {len(presigned_ids)} via R2, {len(inline_ids)} inline)", |
| 645 | file=sys.stderr, |
| 646 | ) |
| 647 | |
| 648 | # ── Build pack (commits + snapshots; no object bytes) ───────────────────── |
| 649 | # The bundle carries commits + snapshots only; object bytes are already |
| 650 | # on the remote (via pack endpoint or presign) or are in inline_ids (local://). |
| 651 | # Pass only inline_ids as the object boundary so bytes are not double-sent. |
| 652 | _branch_have = branch_have or [] |
| 653 | _t_bp = _pt.perf_counter() |
| 654 | if set(_branch_have) == set(have): |
| 655 | bundle = build_pack_from_walk(root, dedup_walk, only_objects=inline_ids) |
| 656 | else: |
| 657 | branch_walk = walk_commits(root, [local_head], have=_branch_have) |
| 658 | bundle = build_pack_from_walk(root, branch_walk, only_objects=inline_ids) |
| 659 | _t_bp_end = _pt.perf_counter() |
| 660 | print(f" [perf] build_pack: {_t_bp_end-_t_bp:.3f}s", file=sys.stderr) |
| 661 | |
| 662 | # ── Phase 4: push_objects fallback (local:// backend only) ─────────────── |
| 663 | # Only runs when presign returned all objects as inline (no presign support). |
| 664 | # On S3/R2 backends inline_ids is empty after Phase 2b and this is a no-op. |
| 665 | # On local:// backends all objects come back as inline regardless of size. |
| 666 | inline_object_ids: list[str] = sorted(inline_ids) |
| 667 | total_inline = len(inline_object_ids) |
| 668 | chunk_num = 0 |
| 669 | |
| 670 | _t_upload = _pt.perf_counter() |
| 671 | if total_inline > 0: |
| 672 | total_chunks = (total_inline + CHUNK_OBJECTS - 1) // CHUNK_OBJECTS |
| 673 | |
| 674 | with ThreadPoolExecutor(max_workers=max_workers) as pool: |
| 675 | futures: dict[Future[tuple[int, int]], int] = {} |
| 676 | first_exc: Exception | None = None |
| 677 | |
| 678 | for chunk in stream_object_chunks(root, inline_object_ids, CHUNK_OBJECTS): |
| 679 | chunk_num += 1 |
| 680 | fut = pool.submit( |
| 681 | _upload_chunk, transport, url, token, chunk, chunk_num, total_chunks |
| 682 | ) |
| 683 | futures[fut] = chunk_num |
| 684 | |
| 685 | for fut in as_completed(futures): |
| 686 | try: |
| 687 | stored, skipped = fut.result() |
| 688 | total_stored += stored |
| 689 | total_skipped += skipped |
| 690 | except Exception as exc: |
| 691 | logger.error("❌ Chunk upload failed: %s", exc) |
| 692 | if first_exc is None: |
| 693 | first_exc = exc |
| 694 | |
| 695 | if first_exc is not None: |
| 696 | raise first_exc |
| 697 | |
| 698 | logger.info( |
| 699 | "✅ push/objects complete: %d stored, %d skipped across %d chunk(s)", |
| 700 | total_stored, total_skipped, chunk_num, |
| 701 | ) |
| 702 | |
| 703 | _t_upload_end = _pt.perf_counter() |
| 704 | print( |
| 705 | f" [perf] push_objects (inline fallback): {_t_upload_end-_t_upload:.3f}s" |
| 706 | f" ({total_inline} objects)", |
| 707 | file=sys.stderr, |
| 708 | ) |
| 709 | |
| 710 | # ── Phase 5: push commits + snapshots (chunked) ─────────────────────────── |
| 711 | # Sending 900+ commits with their snapshot manifests in one HTTP request |
| 712 | # body can easily exceed 50 MB, causing server-side body-read timeouts. |
| 713 | # We chunk commits (oldest-first) so each request stays well under 5 MB. |
| 714 | # Intermediate chunks use force=True (safe — they are known ancestors of |
| 715 | # the final HEAD we already verified). Only the last chunk uses the caller's |
| 716 | # force flag and sends local_head so the server finalises the branch ref. |
| 717 | all_commits = bundle.get("commits") or [] |
| 718 | all_snapshots = bundle.get("snapshots") or [] |
| 719 | branch_heads = bundle.get("branch_heads") |
| 720 | |
| 721 | # Build snapshot lookup by snapshot_id for fast per-chunk assembly. |
| 722 | snap_by_id: dict[str, object] = {} |
| 723 | for s in all_snapshots: |
| 724 | sid = s.snapshot_id if hasattr(s, "snapshot_id") else (s.get("snapshot_id") if isinstance(s, dict) else None) |
| 725 | if sid: |
| 726 | snap_by_id[sid] = s |
| 727 | |
| 728 | # walk_commits returns newest-first (BFS from HEAD). Reverse so we push |
| 729 | # oldest commits first — each chunk is a valid fast-forward of the previous. |
| 730 | commits_oldest_first = list(reversed(all_commits)) |
| 731 | commits_count = len(commits_oldest_first) |
| 732 | |
| 733 | print( |
| 734 | f" Pushing {commits_count} commit(s) " |
| 735 | f"and {len(all_snapshots)} snapshot(s) …", |
| 736 | file=sys.stderr, |
| 737 | ) |
| 738 | |
| 739 | total_chunks = max(1, (commits_count + CHUNK_COMMITS - 1) // CHUNK_COMMITS) |
| 740 | result: PushResult = "pushed" |
| 741 | _t_pp = _pt.perf_counter() |
| 742 | |
| 743 | for chunk_idx in range(0, max(1, commits_count), CHUNK_COMMITS): |
| 744 | chunk_commits = commits_oldest_first[chunk_idx : chunk_idx + CHUNK_COMMITS] |
| 745 | is_last = (chunk_idx + CHUNK_COMMITS >= commits_count) |
| 746 | |
| 747 | # Include only snapshots referenced by commits in this chunk. |
| 748 | chunk_snap_ids: set[str] = set() |
| 749 | for c in chunk_commits: |
| 750 | sid = c.snapshot_id if hasattr(c, "snapshot_id") else (c.get("snapshot_id") if isinstance(c, dict) else None) |
| 751 | if sid: |
| 752 | chunk_snap_ids.add(sid) |
| 753 | chunk_snaps = [snap_by_id[sid] for sid in chunk_snap_ids if sid in snap_by_id] |
| 754 | |
| 755 | chunk_bundle: PackBundle = { |
| 756 | "commits": chunk_commits, |
| 757 | "snapshots": chunk_snaps, |
| 758 | "objects": [], |
| 759 | } |
| 760 | if branch_heads and is_last: |
| 761 | chunk_bundle["branch_heads"] = branch_heads |
| 762 | |
| 763 | chunk_num = chunk_idx // CHUNK_COMMITS + 1 |
| 764 | if total_chunks > 1: |
| 765 | print(f" Chunk {chunk_num}/{total_chunks} ({len(chunk_commits)} commits) …", file=sys.stderr) |
| 766 | |
| 767 | chunk_force = True if not is_last else force |
| 768 | chunk_local_head = (local_head if send_local_head else None) if is_last else None |
| 769 | result = transport.push_pack(url, token, chunk_bundle, branch, chunk_force, local_head=chunk_local_head) |
| 770 | |
| 771 | _t_pp_end = _pt.perf_counter() |
| 772 | print(f" [perf] push_pack ({total_chunks} chunk(s)): {_t_pp_end-_t_pp:.3f}s ({commits_count} commits, {len(all_snapshots)} snapshots)", file=sys.stderr) |
| 773 | print(f" [perf] TOTAL: {_t_pp_end-_t0:.3f}s", file=sys.stderr) |
| 774 | total_objects = len(presigned_ids) + total_inline |
| 775 | return result, commits_count, total_objects |
| 776 | |
| 777 | |
| 778 | def _fetch_remote_info_safe( |
| 779 | transport: MuseTransport, |
| 780 | url: str, |
| 781 | token: str | None, |
| 782 | ) -> RemoteInfo | None: |
| 783 | """Call ``GET /refs`` on the remote and return its current branch heads. |
| 784 | |
| 785 | Returns ``None`` on any transport error so callers can fall back |
| 786 | gracefully instead of aborting the whole push. |
| 787 | """ |
| 788 | try: |
| 789 | return transport.fetch_remote_info(url, token) |
| 790 | except TransportError: |
| 791 | return None |
| 792 | |
| 793 | |
| 794 | def _all_known_have_anchors(root: pathlib.Path) -> list[str]: |
| 795 | """Return every commit ID cached in any remote's tracking refs. |
| 796 | |
| 797 | When pushing a new branch (or to a remote with no local tracking cache), |
| 798 | these commits are our best guess at what the remote already holds. Any |
| 799 | remote the user has previously pushed to shares commit ancestry with other |
| 800 | remotes — using all cached heads as ``have`` anchors ensures ``build_pack`` |
| 801 | only transmits the delta since the nearest shared ancestor. |
| 802 | |
| 803 | Symlinks inside ``.muse/remotes/`` are skipped rather than followed to |
| 804 | prevent path-traversal attacks. Unreadable files are silently skipped |
| 805 | so a corrupted tracking ref doesn't abort the push. |
| 806 | """ |
| 807 | remotes_dir = root / ".muse" / "remotes" |
| 808 | if not remotes_dir.is_dir(): |
| 809 | return [] |
| 810 | heads: list[str] = [] |
| 811 | for ref_file in remotes_dir.rglob("*"): |
| 812 | if ref_file.is_symlink() or not ref_file.is_file(): |
| 813 | continue |
| 814 | try: |
| 815 | commit_id = ref_file.read_text(encoding="utf-8", errors="ignore").strip() |
| 816 | except OSError: |
| 817 | continue |
| 818 | if commit_id: |
| 819 | heads.append(commit_id) |
| 820 | return heads |
| 821 | |
| 822 | |
| 823 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 824 | """Register the ``muse push`` subcommand and all its flags.""" |
| 825 | parser = subparsers.add_parser( |
| 826 | "push", |
| 827 | help="Upload local commits, snapshots, and objects to a remote.", |
| 828 | description=__doc__, |
| 829 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 830 | ) |
| 831 | parser.add_argument( |
| 832 | "remote", nargs="?", default="origin", |
| 833 | help="Remote name to push to (default: origin).", |
| 834 | ) |
| 835 | parser.add_argument( |
| 836 | "branch_pos", nargs="?", default=None, metavar="BRANCH", |
| 837 | help="Branch to push (default: current branch). Same as --branch.", |
| 838 | ) |
| 839 | parser.add_argument( |
| 840 | "--branch", "-b", default=None, dest="branch_flag", |
| 841 | help="Branch to push (default: current branch).", |
| 842 | ) |
| 843 | parser.add_argument( |
| 844 | "-u", "--set-upstream", action="store_true", dest="set_upstream_flag", |
| 845 | help="Record upstream tracking for this branch.", |
| 846 | ) |
| 847 | parser.add_argument( |
| 848 | "--force", action="store_true", |
| 849 | help="Force push even if the remote has diverged.", |
| 850 | ) |
| 851 | parser.add_argument( |
| 852 | "--force-with-lease", action="store_true", dest="force_with_lease", |
| 853 | help=( |
| 854 | "Force push only if the remote branch has not advanced since the last fetch. " |
| 855 | "Safer than --force: rejects the push if someone else has pushed in the meantime." |
| 856 | ), |
| 857 | ) |
| 858 | parser.add_argument( |
| 859 | "--delete", "-d", action="store_true", dest="delete_branch", |
| 860 | help="Delete the named branch on the remote.", |
| 861 | ) |
| 862 | parser.add_argument( |
| 863 | "-n", "--dry-run", action="store_true", |
| 864 | help="Compute what would be pushed without sending any data.", |
| 865 | ) |
| 866 | parser.add_argument( |
| 867 | "--workers", type=int, default=16, metavar="N", |
| 868 | help="Number of parallel upload workers (default: 16).", |
| 869 | ) |
| 870 | parser.add_argument( |
| 871 | "--format", "-f", default="text", dest="fmt", |
| 872 | help="Output format: text or json.", |
| 873 | ) |
| 874 | parser.add_argument( |
| 875 | "--json", action="store_const", const="json", dest="fmt", |
| 876 | help="Shorthand for --format json.", |
| 877 | ) |
| 878 | parser.set_defaults(func=run) |
| 879 | |
| 880 | |
| 881 | def run(args: argparse.Namespace) -> None: |
| 882 | """Upload local commits, snapshots, and objects to a remote. |
| 883 | |
| 884 | Requires the remote to be a fast-forward of the local branch unless |
| 885 | ``--force`` is specified. |
| 886 | |
| 887 | All progress and error messages go to **stderr**. ``--format json`` |
| 888 | (or ``--json``) emits a single JSON object on stdout for agent pipelines. |
| 889 | |
| 890 | ``--dry-run`` computes the pack that *would* be sent using local tracking |
| 891 | refs as have-anchors and exits 0 without connecting to the remote. |
| 892 | |
| 893 | JSON schema:: |
| 894 | |
| 895 | { |
| 896 | "status": "pushed | up_to_date | dry_run | deleted", |
| 897 | "remote": "<remote_name>", |
| 898 | "branch": "<branch>", |
| 899 | "head": "<sha256> | null", |
| 900 | "commits_sent": <N>, |
| 901 | "objects_sent": <N>, |
| 902 | "force": false, |
| 903 | "dry_run": false |
| 904 | } |
| 905 | |
| 906 | Exit codes:: |
| 907 | |
| 908 | 0 — success |
| 909 | 1 — remote not configured, branch has no commits, push rejected, |
| 910 | authentication failure, network error, invalid format |
| 911 | """ |
| 912 | remote: str = args.remote |
| 913 | branch: str | None = ( |
| 914 | getattr(args, "branch_flag", None) or getattr(args, "branch_pos", None) |
| 915 | ) |
| 916 | set_upstream_flag: bool = args.set_upstream_flag |
| 917 | force: bool = args.force |
| 918 | force_with_lease: bool = getattr(args, "force_with_lease", False) |
| 919 | if force_with_lease and not force: |
| 920 | # --force-with-lease implies force behaviour but with a safety check. |
| 921 | force = True |
| 922 | delete_branch: bool = getattr(args, "delete_branch", False) |
| 923 | dry_run: bool = getattr(args, "dry_run", False) |
| 924 | max_workers: int = max(1, getattr(args, "workers", 16)) |
| 925 | fmt: str = getattr(args, "fmt", "text") |
| 926 | |
| 927 | if fmt not in ("text", "json"): |
| 928 | print( |
| 929 | f"❌ Unknown --format '{sanitize_display(fmt)}'. Choose text or json.", |
| 930 | file=sys.stderr, |
| 931 | ) |
| 932 | raise SystemExit(ExitCode.USER_ERROR) |
| 933 | |
| 934 | root = require_repo() |
| 935 | |
| 936 | url = get_remote(remote, root) |
| 937 | pack_origin = get_remote_pack_origin(remote, root) |
| 938 | if url is None: |
| 939 | print( |
| 940 | f"❌ Remote '{sanitize_display(remote)}' is not configured.\n" |
| 941 | f" Add it with: muse remote add {sanitize_display(remote)} <url>", |
| 942 | file=sys.stderr, |
| 943 | ) |
| 944 | raise SystemExit(ExitCode.USER_ERROR) |
| 945 | |
| 946 | # ── DELETE MODE ─────────────────────────────────────────────────────────── |
| 947 | if delete_branch: |
| 948 | current_branch = read_current_branch(root) |
| 949 | del_branch = branch or current_branch |
| 950 | if not del_branch: |
| 951 | print( |
| 952 | "❌ Specify a branch to delete: muse push <remote> --delete <branch>", |
| 953 | file=sys.stderr, |
| 954 | ) |
| 955 | raise SystemExit(ExitCode.USER_ERROR) |
| 956 | |
| 957 | if dry_run: |
| 958 | msg = f"Would delete {sanitize_display(remote)}/{sanitize_display(del_branch)}" |
| 959 | if fmt == "json": |
| 960 | print(json.dumps(_PushJson( |
| 961 | status="dry_run", |
| 962 | remote=remote, |
| 963 | branch=del_branch, |
| 964 | head=None, |
| 965 | commits_sent=0, |
| 966 | objects_sent=0, |
| 967 | force=force, |
| 968 | dry_run=True, |
| 969 | ))) |
| 970 | else: |
| 971 | print(msg) |
| 972 | return |
| 973 | |
| 974 | token = get_signing_identity(root, remote_url=url) |
| 975 | transport = make_transport(url) |
| 976 | print( |
| 977 | f"Deleting {sanitize_display(remote)}/{sanitize_display(del_branch)} …", |
| 978 | file=sys.stderr, |
| 979 | ) |
| 980 | already_gone = False |
| 981 | try: |
| 982 | transport.delete_branch_remote(url, token, del_branch) |
| 983 | except TransportError as exc: |
| 984 | if exc.status_code == 404: |
| 985 | already_gone = True |
| 986 | elif exc.status_code == 409: |
| 987 | print( |
| 988 | f"❌ Cannot delete the default branch '{sanitize_display(del_branch)}'.", |
| 989 | file=sys.stderr, |
| 990 | ) |
| 991 | raise SystemExit(ExitCode.USER_ERROR) |
| 992 | elif exc.status_code == 403: |
| 993 | print( |
| 994 | "❌ Permission denied — only the repo owner may delete branches.", |
| 995 | file=sys.stderr, |
| 996 | ) |
| 997 | raise SystemExit(ExitCode.USER_ERROR) |
| 998 | else: |
| 999 | print(f"❌ Delete failed: {sanitize_display(str(exc))}", file=sys.stderr) |
| 1000 | raise SystemExit(ExitCode.USER_ERROR) |
| 1001 | |
| 1002 | pruned = delete_remote_head(remote, del_branch, root) |
| 1003 | if fmt == "json": |
| 1004 | print(json.dumps(_PushJson( |
| 1005 | status="deleted", |
| 1006 | remote=remote, |
| 1007 | branch=del_branch, |
| 1008 | head=None, |
| 1009 | commits_sent=0, |
| 1010 | objects_sent=0, |
| 1011 | force=force, |
| 1012 | dry_run=False, |
| 1013 | ))) |
| 1014 | else: |
| 1015 | if already_gone: |
| 1016 | note = " (already absent on remote)" |
| 1017 | if pruned: |
| 1018 | note += ", tracking ref pruned" |
| 1019 | print(f"✅ {sanitize_display(remote)}/{sanitize_display(del_branch)}{note}") |
| 1020 | else: |
| 1021 | prune_note = " (tracking ref pruned)" if pruned else "" |
| 1022 | print( |
| 1023 | f"✅ Deleted remote branch " |
| 1024 | f"{sanitize_display(remote)}/{sanitize_display(del_branch)}{prune_note}" |
| 1025 | ) |
| 1026 | return |
| 1027 | |
| 1028 | # ── NORMAL PUSH ────────────────────────────────────────────────────────── |
| 1029 | current_branch = read_current_branch(root) |
| 1030 | push_branch = branch or current_branch |
| 1031 | |
| 1032 | local_head = get_head_commit_id(root, push_branch) |
| 1033 | if local_head is None: |
| 1034 | print( |
| 1035 | f"❌ Branch '{sanitize_display(push_branch)}' has no commits to push.", |
| 1036 | file=sys.stderr, |
| 1037 | ) |
| 1038 | raise SystemExit(ExitCode.USER_ERROR) |
| 1039 | |
| 1040 | # ── DRY-RUN: compute pack locally, no network calls ─────────────────────── |
| 1041 | if dry_run: |
| 1042 | have: list[str] = [ |
| 1043 | c for c in _all_known_have_anchors(root) |
| 1044 | if c != local_head and commit_exists(root, c) |
| 1045 | ] |
| 1046 | dry_branch_have: list[str] = [] |
| 1047 | _cached = get_remote_head(remote, push_branch, root) |
| 1048 | if _cached and commit_exists(root, _cached): |
| 1049 | dry_branch_have = [_cached] |
| 1050 | dry_bundle = build_pack(root, [local_head], have=dry_branch_have) |
| 1051 | dry_commits = len(dry_bundle.get("commits") or []) |
| 1052 | dry_objects = len(collect_object_ids(root, [local_head], have=have)) |
| 1053 | if fmt == "json": |
| 1054 | print(json.dumps(_PushJson( |
| 1055 | status="dry_run", |
| 1056 | remote=remote, |
| 1057 | branch=push_branch, |
| 1058 | head=local_head, |
| 1059 | commits_sent=dry_commits, |
| 1060 | objects_sent=dry_objects, |
| 1061 | force=force, |
| 1062 | dry_run=True, |
| 1063 | ))) |
| 1064 | else: |
| 1065 | print( |
| 1066 | f"Would push {dry_commits} commit(s) and ~{dry_objects} object(s) " |
| 1067 | f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} (dry run)" |
| 1068 | ) |
| 1069 | return |
| 1070 | |
| 1071 | transport = make_transport(url) |
| 1072 | token = get_signing_identity(root, remote_url=url) |
| 1073 | |
| 1074 | # GET /refs: live authoritative have-anchors + up-to-date check. |
| 1075 | remote_info = _fetch_remote_info_safe(transport, url, token) |
| 1076 | remote_branch_heads = remote_info["branch_heads"] if remote_info else {} |
| 1077 | |
| 1078 | # Resolve effective pack_origin: server-advertised value takes priority |
| 1079 | # over the local config override so clients need zero configuration when |
| 1080 | # the server has a Worker deployed. |
| 1081 | pack_origin = ( |
| 1082 | remote_info.get("pack_origin") if remote_info else None |
| 1083 | ) or pack_origin |
| 1084 | |
| 1085 | candidate_have = list(remote_branch_heads.values()) + _all_known_have_anchors(root) |
| 1086 | have = [ |
| 1087 | c for c in candidate_have |
| 1088 | if c != local_head and commit_exists(root, c) |
| 1089 | ] |
| 1090 | |
| 1091 | if remote_info is not None: |
| 1092 | remote_head: str | None = remote_branch_heads.get(push_branch) |
| 1093 | else: |
| 1094 | remote_head = get_remote_head(remote, push_branch, root) |
| 1095 | |
| 1096 | # ── FORCE-WITH-LEASE safety check ──────────────────────────────────────── |
| 1097 | # Reject the push if the live remote HEAD has advanced beyond what we last |
| 1098 | # fetched — i.e., someone else pushed after our last fetch/pull. |
| 1099 | if force_with_lease and remote_head is not None: |
| 1100 | cached_head = get_remote_head(remote, push_branch, root) |
| 1101 | if cached_head != remote_head: |
| 1102 | print( |
| 1103 | f"❌ Push rejected (--force-with-lease): remote " |
| 1104 | f"'{sanitize_display(remote)}/{sanitize_display(push_branch)}' has advanced " |
| 1105 | f"since your last fetch.\n" |
| 1106 | f" Run 'muse fetch' to update your tracking refs, then retry.", |
| 1107 | file=sys.stderr, |
| 1108 | ) |
| 1109 | raise SystemExit(ExitCode.USER_ERROR) |
| 1110 | |
| 1111 | if remote_head == local_head: |
| 1112 | if fmt == "json": |
| 1113 | print(json.dumps(_PushJson( |
| 1114 | status="up_to_date", |
| 1115 | remote=remote, |
| 1116 | branch=push_branch, |
| 1117 | head=local_head, |
| 1118 | commits_sent=0, |
| 1119 | objects_sent=0, |
| 1120 | force=force, |
| 1121 | dry_run=False, |
| 1122 | ))) |
| 1123 | else: |
| 1124 | print( |
| 1125 | f"Everything up to date. " |
| 1126 | f"Remote {sanitize_display(remote)}/{sanitize_display(push_branch)} " |
| 1127 | f"is already at {local_head[:8]}." |
| 1128 | ) |
| 1129 | return |
| 1130 | |
| 1131 | print( |
| 1132 | f"Pushing {sanitize_display(push_branch)} → " |
| 1133 | f"{sanitize_display(remote)}/{sanitize_display(push_branch)} …", |
| 1134 | file=sys.stderr, |
| 1135 | ) |
| 1136 | |
| 1137 | # `branch_have` is the commit-graph boundary for build_pack (Phase 5). |
| 1138 | # Normally it contains only the target branch's remote HEAD: using broader |
| 1139 | # remote refs risks stopping the BFS at an "intermediate" branch that sits |
| 1140 | # between the target's remote HEAD and the local tip, producing a bundle |
| 1141 | # the server cannot verify as a fast-forward. |
| 1142 | # |
| 1143 | # Merge commits are the exception. The merge commit's first parent IS the |
| 1144 | # target branch's remote HEAD (one hop), so there is nothing "between" it |
| 1145 | # and local HEAD on P1's chain. The second parent (P2) may have a long |
| 1146 | # ancestry that the server already has on another branch. Without stop |
| 1147 | # anchors on P2's chain, the BFS walks every ancestor of P2 — potentially |
| 1148 | # thousands of commits the remote already stores — overwhelming the server. |
| 1149 | # |
| 1150 | # Fix: for merge commits, add all known remote branch heads to branch_have. |
| 1151 | # The BFS stops at the nearest already-remote commit on P2's chain instead |
| 1152 | # of walking back to the root of the repository. |
| 1153 | branch_have: list[str] = ( |
| 1154 | [remote_head] if remote_head and commit_exists(root, remote_head) else [] |
| 1155 | ) |
| 1156 | local_commit = read_commit(root, local_head) |
| 1157 | if local_commit and local_commit.parent2_commit_id: |
| 1158 | # No commit_exists() guard here: the BFS stops when a parent commit_id |
| 1159 | # is in `seen` — it never tries to read a commit whose ID is already |
| 1160 | # in the seen set. So the anchor works even if the commit isn't in |
| 1161 | # the local object store (e.g. a remote branch that was never fetched). |
| 1162 | for ref_head in remote_branch_heads.values(): |
| 1163 | if ref_head not in branch_have: |
| 1164 | branch_have.append(ref_head) |
| 1165 | |
| 1166 | try: |
| 1167 | result, commits_sent, objects_sent = _push_mwp( |
| 1168 | transport, url, token, root, local_head, have, push_branch, force, |
| 1169 | max_workers=max_workers, |
| 1170 | branch_have=branch_have, |
| 1171 | pack_origin=pack_origin, |
| 1172 | ) |
| 1173 | except TransportError as exc: |
| 1174 | if exc.status_code == 404: |
| 1175 | print( |
| 1176 | f"❌ Repository not found on remote '{sanitize_display(remote)}'.\n" |
| 1177 | f" The remote server returned 404 for this repo.\n" |
| 1178 | f"\n" |
| 1179 | f" If this is a new repo, create it on the server first:\n" |
| 1180 | f" muse hub repo create (if using MuseHub)\n" |
| 1181 | f"\n" |
| 1182 | f" If the repo exists, verify your remote URL:\n" |
| 1183 | f" muse remote -v\n" |
| 1184 | f"\n" |
| 1185 | f" If you are authenticated, run: muse auth whoami", |
| 1186 | file=sys.stderr, |
| 1187 | ) |
| 1188 | elif exc.status_code == 401: |
| 1189 | print( |
| 1190 | f"❌ Authentication required — remote '{sanitize_display(remote)}' returned 401.\n" |
| 1191 | f" Log in first: muse auth register", |
| 1192 | file=sys.stderr, |
| 1193 | ) |
| 1194 | elif exc.status_code == 409: |
| 1195 | print( |
| 1196 | f"❌ Push rejected — remote " |
| 1197 | f"'{sanitize_display(remote)}/{sanitize_display(push_branch)}' has diverged.\n" |
| 1198 | f" Pull first (muse pull) or use --force to override.", |
| 1199 | file=sys.stderr, |
| 1200 | ) |
| 1201 | else: |
| 1202 | print(f"❌ Push failed: {sanitize_display(str(exc))}", file=sys.stderr) |
| 1203 | raise SystemExit(ExitCode.USER_ERROR) |
| 1204 | |
| 1205 | if not result["ok"]: |
| 1206 | print( |
| 1207 | f"❌ Push rejected by remote: {sanitize_display(str(result['message']))}", |
| 1208 | file=sys.stderr, |
| 1209 | ) |
| 1210 | raise SystemExit(ExitCode.USER_ERROR) |
| 1211 | |
| 1212 | updated_head = result["branch_heads"].get(push_branch, local_head) |
| 1213 | set_remote_head(remote, push_branch, updated_head, root) |
| 1214 | |
| 1215 | if set_upstream_flag: |
| 1216 | set_upstream(push_branch, remote, root) |
| 1217 | print( |
| 1218 | f" Upstream set: {sanitize_display(push_branch)} → " |
| 1219 | f"{sanitize_display(remote)}/{sanitize_display(push_branch)}", |
| 1220 | file=sys.stderr, |
| 1221 | ) |
| 1222 | |
| 1223 | if fmt == "json": |
| 1224 | print(json.dumps(_PushJson( |
| 1225 | status="pushed", |
| 1226 | remote=remote, |
| 1227 | branch=push_branch, |
| 1228 | head=updated_head, |
| 1229 | commits_sent=commits_sent, |
| 1230 | objects_sent=objects_sent, |
| 1231 | force=force, |
| 1232 | dry_run=False, |
| 1233 | ))) |
| 1234 | else: |
| 1235 | print( |
| 1236 | f"✅ Pushed {commits_sent} commit(s), {objects_sent} object(s) " |
| 1237 | f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} " |
| 1238 | f"({updated_head[:8]})" |
| 1239 | ) |
| 1240 | # probe |
| 1241 | # probe2 |
File History
9 commits
sha256:35855b7bbce81b93612c655e23587bdebbb5fc09856ff5cbf5cd5b195d0547d4
merge: dev → main (rc11, urllib migration, object store inv…
Sonnet 4.6
patch
49 days ago
sha256:633dfa2940e97bf1a3d04996c772027a57d70d103f1693c96da04969613dba6c
fix: urllib migration regressions — force flag, job_id, Con…
Sonnet 4.6
minor
⚠
49 days ago
sha256:00cec040ce5f70bf8191d2ce6a9f308fbde553911068f0c303217f4eb6d4e775
fix: migrate httpx → urllib in transport.py and push.py; fi…
Sonnet 4.6
minor
⚠
49 days ago
sha256:b1447dbe2ef78eb6ec67b8ec4cc0e9c29472382f4390741d6ce069cdf5efa792
fix: branch_have uses all remote heads unconditionally (Pha…
Sonnet 4.6
patch
50 days ago
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2
fix: remove commit_exists filter from have anchors — server…
Sonnet 4.6
patch
50 days ago
sha256:7a59846a92918d24b441ef3821a51fa47e16feedc844f411204c853a120fce89
fix: use http.client for R2 PUT to avoid Content-Type auto-…
Sonnet 4.6
51 days ago
sha256:7e95b29f2d502ad5eccf2a57af4092763a2e705f1bf1569a8cb7e063b6e6d5bd
refactor: replace httpx with stdlib urllib in push path
Sonnet 4.6
minor
⚠
51 days ago
sha256:10f95c596dca410d2169ce7a0e8e0f4d958f04523eca23301aa33deeac2c6ab7
perf: remove per-item push debug logs from steps 1 and 2
Sonnet 4.6
52 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
52 days ago