push.py
python
sha256:35855b7bbce81b93612c655e23587bdebbb5fc09856ff5cbf5cd5b195d0547d4
merge: dev → main (rc11, urllib migration, object store inv…
Sonnet 4.6
patch
19 days ago
| 1 | """``muse push`` — upload local commits, snapshots, and objects to a remote. |
| 2 | |
| 3 | Push protocol — presigned mpack |
| 4 | --------------------------------- |
| 5 | |
| 6 | ``muse push`` uses a presigned mpack upload: |
| 7 | |
| 8 | **Phase 0 — ref discovery:** |
| 9 | ``GET {url}/refs`` returns current branch heads. This cheap call |
| 10 | establishes the ``have`` anchors used in commit deduplication. |
| 11 | |
| 12 | **Phase 1 — upload:** |
| 13 | Client builds a wire MPack (``b"MUSE"`` binary format), calls |
| 14 | ``POST {url}/push/mpack-presign`` to get a presigned R2 PUT URL, then |
| 15 | PUTs the mpack bytes directly to R2 — bypassing Cloudflare's 100 MB |
| 16 | body limit entirely. |
| 17 | |
| 18 | **Phase 2 — index:** |
| 19 | Client calls ``POST {url}/push/unpack-mpack`` with the mpack content-address. |
| 20 | Server reads the mpack from R2, indexes all commits/snapshots/objects, and |
| 21 | advances the branch pointer. |
| 22 | |
| 23 | Fast-forward check |
| 24 | ------------------ |
| 25 | |
| 26 | By default, ``muse push`` requires the remote branch to be an ancestor of the |
| 27 | local branch (a fast-forward update). If the remote has diverged, the push is |
| 28 | rejected with exit code 1. Pass ``--force`` to bypass this check. |
| 29 | |
| 30 | Upstream tracking |
| 31 | ----------------- |
| 32 | |
| 33 | Pass ``-u`` / ``--set-upstream`` on first push to record the tracking |
| 34 | relationship between the local branch and the remote branch so that future |
| 35 | ``muse pull`` and ``muse push`` invocations can resolve the remote automatically. |
| 36 | |
| 37 | JSON output (``--format json`` / ``--json``) schema:: |
| 38 | |
| 39 | { |
| 40 | "status": "pushed | up_to_date | dry_run | deleted", |
| 41 | "remote": "<name>", |
| 42 | "branch": "<branch>", |
| 43 | "head": "<sha256> | null", |
| 44 | "commits_sent": <N>, |
| 45 | "objects_sent": <N>, |
| 46 | "force": false, |
| 47 | "dry_run": false |
| 48 | } |
| 49 | |
| 50 | Exit codes:: |
| 51 | |
| 52 | 0 — success (pushed, up_to_date, deleted, or dry_run) |
| 53 | 1 — remote not configured (error lists all configured remotes so the |
| 54 | agent knows the exact names without a follow-up ``muse remote`` |
| 55 | call), branch has no commits, push rejected, authentication |
| 56 | failure, network error |
| 57 | """ |
| 58 | |
| 59 | import argparse |
| 60 | import json |
| 61 | import logging |
| 62 | import pathlib |
| 63 | import sys |
| 64 | import time as _time |
| 65 | from typing import TypedDict |
| 66 | |
| 67 | from muse.cli.config import ( |
| 68 | delete_remote_head, |
| 69 | get_signing_identity, |
| 70 | get_remote, |
| 71 | get_remote_head, |
| 72 | list_remotes, |
| 73 | set_remote_head, |
| 74 | set_upstream, |
| 75 | ) |
| 76 | from muse.core.types import split_id |
| 77 | from muse.core.paths import remotes_dir as _remotes_dir |
| 78 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 79 | from muse.core.errors import ExitCode |
| 80 | from muse.core.mpack import ( |
| 81 | PushResult, |
| 82 | RemoteInfo, |
| 83 | build_mpack_from_walk, |
| 84 | build_wire_mpack, |
| 85 | collect_blob_ids, |
| 86 | walk_commits, |
| 87 | ) |
| 88 | from muse.core.types import blob_id as _blob_id |
| 89 | from muse.core.refs import read_ref |
| 90 | from muse.core.repo import require_repo |
| 91 | from muse.core.timing import start_timer |
| 92 | from muse.core.refs import ( |
| 93 | get_head_commit_id, |
| 94 | read_current_branch, |
| 95 | ) |
| 96 | from muse.core.commits import ( |
| 97 | commit_exists, |
| 98 | read_commit, |
| 99 | ) |
| 100 | from muse.core.snapshots import read_snapshot as _read_snapshot |
| 101 | from muse.core.transport import ( |
| 102 | MuseTransport, |
| 103 | SigningIdentity, |
| 104 | TransportError, |
| 105 | _ignore_sigpipe, |
| 106 | make_transport, |
| 107 | ) |
| 108 | from muse.core.validation import sanitize_display |
| 109 | |
| 110 | logger = logging.getLogger(__name__) |
| 111 | |
| 112 | import asyncio as _asyncio # noqa: E402 |
| 113 | import ssl as _ssl # noqa: E402 |
| 114 | import httpx as _httpx # noqa: E402 |
| 115 | from muse.core.transport import _TIMEOUT_SECONDS as _PUSH_TIMEOUT # noqa: E402 |
| 116 | |
| 117 | PRESIGN_PUT_CONCURRENCY: int = 64 # max simultaneous presigned PUTs |
| 118 | |
| 119 | def _make_r2_client() -> "_httpx.AsyncClient": |
| 120 | """Return an httpx AsyncClient suitable for presigned R2/MinIO PUTs. |
| 121 | |
| 122 | Keepalive is enabled (default) so TCP connections are reused across the |
| 123 | batch rather than opening a fresh connection per object. |
| 124 | """ |
| 125 | return _httpx.AsyncClient( |
| 126 | timeout=_PUSH_TIMEOUT, |
| 127 | limits=_httpx.Limits(max_connections=PRESIGN_PUT_CONCURRENCY * 2), |
| 128 | ) |
| 129 | |
| 130 | |
| 131 | def _make_push_ssl_ctx() -> "_ssl.SSLContext": |
| 132 | ctx = _ssl.create_default_context() |
| 133 | ctx.options |= _ssl.OP_NO_TICKET |
| 134 | return ctx |
| 135 | |
| 136 | class _PushJson(EnvelopeJson): |
| 137 | """Stable JSON schema for push output.""" |
| 138 | |
| 139 | status: str |
| 140 | remote: str |
| 141 | branch: str |
| 142 | head: str | None |
| 143 | commits_sent: int |
| 144 | objects_sent: int |
| 145 | force: bool |
| 146 | dry_run: bool |
| 147 | |
| 148 | |
| 149 | |
| 150 | def _push_mpack( |
| 151 | transport: MuseTransport, |
| 152 | url: str, |
| 153 | signing: SigningIdentity | None, |
| 154 | root: pathlib.Path, |
| 155 | local_head: str, |
| 156 | have: list[str], |
| 157 | branch: str, |
| 158 | force: bool, |
| 159 | branch_have: list[str] | None = None, |
| 160 | ) -> tuple[PushResult, int, int]: |
| 161 | """Push commits to a remote via the mpack presign protocol. |
| 162 | |
| 163 | Walks commits from *local_head* (excluding anything reachable from |
| 164 | *branch_have*), builds a single mpack, PUTs it to MinIO via a presigned |
| 165 | URL, then POSTs to push/unpack-mpack to index and advance the branch pointer. |
| 166 | |
| 167 | Returns: |
| 168 | ``(PushResult, commits_sent, objects_sent)`` |
| 169 | """ |
| 170 | _t0 = _time.perf_counter() |
| 171 | _branch_have = branch_have or [] |
| 172 | |
| 173 | # ── 1. BFS walk — client-side commit + object dedup ────────────────────── |
| 174 | commit_walk = walk_commits(root, [local_head], have=_branch_have) |
| 175 | _t1 = _time.perf_counter() |
| 176 | |
| 177 | all_commits_raw = commit_walk.get("commits") or [] |
| 178 | commits_oldest_first = [ |
| 179 | c.to_dict() if hasattr(c, "to_dict") else c |
| 180 | for c in reversed(all_commits_raw) |
| 181 | ] |
| 182 | |
| 183 | all_blob_ids: list[str] = commit_walk["all_blob_ids"] |
| 184 | n_commits = len(commits_oldest_first) |
| 185 | n_objects = len(all_blob_ids) |
| 186 | |
| 187 | print(f"[PUSH step 1] new_commits = {n_commits}", file=sys.stderr) |
| 188 | |
| 189 | # ── 2. Per-commit: snapshot delta + structured_delta + object collection ── |
| 190 | _commits_oldest = list(reversed(all_commits_raw)) |
| 191 | _snapshot_deltas_log = commit_walk.get("snapshot_deltas") or [] |
| 192 | _blobs_to_send: set[str] = set(all_blob_ids) |
| 193 | _have_blobs: set[str] = commit_walk.get("have_blobs") or set() |
| 194 | _manifest_blobs: set[str] = commit_walk.get("manifest_blobs") or set() |
| 195 | _accumulated_blobs: set[str] = set() |
| 196 | |
| 197 | # Seed _prev_manifest from the parent snapshot of the first new commit. |
| 198 | _prev_manifest: dict[str, str] = {} |
| 199 | if _commits_oldest: |
| 200 | _first_commit = _commits_oldest[0] |
| 201 | _first_parent_id = ( |
| 202 | _first_commit.parent_commit_id |
| 203 | if hasattr(_first_commit, "parent_commit_id") |
| 204 | else (_first_commit.get("parent_commit_id") if isinstance(_first_commit, dict) else None) |
| 205 | ) |
| 206 | if _first_parent_id: |
| 207 | _parent_commit = read_commit(root, _first_parent_id) |
| 208 | if _parent_commit is not None: |
| 209 | _parent_snap = _read_snapshot(root, _parent_commit.snapshot_id) |
| 210 | if _parent_snap is not None: |
| 211 | _prev_manifest = dict(_parent_snap.manifest) |
| 212 | |
| 213 | print(f"[PUSH step 2] have_blobs={len(_have_blobs)} manifest_blobs={len(_manifest_blobs)} blobs_to_send={len(_blobs_to_send)}", file=sys.stderr) |
| 214 | |
| 215 | for _commit_rec, _delta in zip(_commits_oldest, _snapshot_deltas_log): |
| 216 | _snap_id_log = _delta.get("snapshot_id") or "" |
| 217 | _child_snap = _read_snapshot(root, _snap_id_log) if _snap_id_log else None |
| 218 | _child_manifest: dict[str, str] = dict(_child_snap.manifest) if _child_snap else {} |
| 219 | _delta_upsert: dict[str, str] = { |
| 220 | k: v for k, v in _child_manifest.items() if _prev_manifest.get(k) != v |
| 221 | } |
| 222 | _delta_remove: list[str] = [k for k in _prev_manifest if k not in _child_manifest] |
| 223 | _added = {p: _delta_upsert[p] for p in _delta_upsert if p not in _prev_manifest} |
| 224 | _modified = {p: _delta_upsert[p] for p in _delta_upsert if p in _prev_manifest} |
| 225 | for _oid in set(_delta_upsert.values()): |
| 226 | if _oid not in _accumulated_blobs and _oid in _blobs_to_send: |
| 227 | _accumulated_blobs.add(_oid) |
| 228 | _prev_manifest = _child_manifest |
| 229 | |
| 230 | import urllib.parse as _urlparse |
| 231 | _parsed_url = _urlparse.urlparse(url) |
| 232 | _is_loopback = _parsed_url.hostname in {"localhost", "127.0.0.1", "::1", "host.docker.internal"} |
| 233 | |
| 234 | _t4_start = _time.perf_counter() |
| 235 | |
| 236 | if _is_loopback: |
| 237 | _verify: "_ssl.SSLContext | bool" = False |
| 238 | _limits = _httpx.Limits() |
| 239 | else: |
| 240 | _ctx = _make_push_ssl_ctx() |
| 241 | _verify = _ctx |
| 242 | _limits = _httpx.Limits(max_keepalive_connections=0) |
| 243 | |
| 244 | async def _run_mpack_path() -> "PushResult": |
| 245 | import time as _tm |
| 246 | |
| 247 | mpack_presign_endpoint = f"{url.rstrip('/')}/push/mpack-presign" |
| 248 | unpack_mpack_endpoint = f"{url.rstrip('/')}/push/unpack-mpack" |
| 249 | |
| 250 | # Step 3: pack the walk result into a single mpack. |
| 251 | _t_pack = _tm.perf_counter() |
| 252 | mpack = build_mpack_from_walk(root, commit_walk, compress=True) |
| 253 | _n_commits_mpack = len(mpack.get("commits", [])) |
| 254 | _n_snapshots_mpack = len(mpack.get("snapshots", [])) |
| 255 | _n_blobs_mpack = len(mpack.get("blobs", [])) |
| 256 | |
| 257 | wire_bytes = build_wire_mpack(mpack) |
| 258 | mpack_key = _blob_id(wire_bytes) |
| 259 | |
| 260 | print(f"[PUSH step 3] pack into one mpack:", file=sys.stderr) |
| 261 | print(f"[PUSH step 3] {{ commits: {_n_commits_mpack}, snapshots: {_n_snapshots_mpack}, blobs: {_n_blobs_mpack} }}", file=sys.stderr) |
| 262 | print(f"[PUSH step 3] format=MUSE binary size={len(wire_bytes)} bytes", file=sys.stderr) |
| 263 | |
| 264 | print(f"[PUSH step 4] mpack_bytes = {len(wire_bytes)} bytes", file=sys.stderr) |
| 265 | print(f"[PUSH step 4] mpack_key = {mpack_key}", file=sys.stderr) |
| 266 | print(f"[PUSH step 4] size_bytes = {len(wire_bytes)}", file=sys.stderr) |
| 267 | |
| 268 | async with _httpx.AsyncClient( |
| 269 | http2=False, timeout=_PUSH_TIMEOUT, |
| 270 | verify=_verify, limits=_limits, |
| 271 | ) as _client: |
| 272 | presign_body = json.dumps( |
| 273 | {"mpack_key": mpack_key, "size_bytes": len(wire_bytes)}, |
| 274 | ).encode() |
| 275 | _presign_headers = dict(transport._build_request( |
| 276 | "POST", mpack_presign_endpoint, signing, presign_body, |
| 277 | extra_headers={"Content-Type": "application/json", "Accept": "application/json"}, |
| 278 | ).headers) |
| 279 | print(f"[PUSH step 5] POST /push/mpack-presign {{ mpack_key, size_bytes }}", file=sys.stderr) |
| 280 | bp_resp = await _client.post( |
| 281 | mpack_presign_endpoint, |
| 282 | content=presign_body, |
| 283 | headers=_presign_headers, |
| 284 | ) |
| 285 | if bp_resp.status_code >= 400: |
| 286 | raise TransportError( |
| 287 | f"push/mpack-presign: HTTP {bp_resp.status_code} — {bp_resp.text[:200]}", |
| 288 | bp_resp.status_code, |
| 289 | ) |
| 290 | bp_data = bp_resp.json() |
| 291 | upload_url: str = bp_data.get("upload_url") or bp_data.get("uploadUrl", "") |
| 292 | print(f"[PUSH step 5] → upload_url = {upload_url}", file=sys.stderr) |
| 293 | |
| 294 | # Pre-PUT integrity check — catch any mutation between step 3 and the PUT |
| 295 | _pre_put_hash = _blob_id(wire_bytes) |
| 296 | _pre_put_match = _pre_put_hash == mpack_key |
| 297 | print(f"[PUSH step 6] pre-PUT integrity check:", file=sys.stderr) |
| 298 | print(f"[PUSH step 6] len(wire_bytes) = {len(wire_bytes)}", file=sys.stderr) |
| 299 | print(f"[PUSH step 6] sha256(wire_bytes) = {_pre_put_hash}", file=sys.stderr) |
| 300 | print(f"[PUSH step 6] mpack_key = {mpack_key}", file=sys.stderr) |
| 301 | print(f"[PUSH step 6] match = {_pre_put_match} {'✓' if _pre_put_match else '✗ MISMATCH — bytes mutated between step 3 and PUT'}", file=sys.stderr) |
| 302 | |
| 303 | print(f"[PUSH step 6] PUT mpack_bytes → presigned_url", file=sys.stderr) |
| 304 | async with _make_r2_client() as _r2: |
| 305 | put_resp = await _r2.put(upload_url, content=wire_bytes) |
| 306 | print(f"[PUSH step 6] → HTTP {put_resp.status_code} response_len={len(put_resp.content)}", file=sys.stderr) |
| 307 | if put_resp.status_code >= 400: |
| 308 | raise TransportError( |
| 309 | f"mpack PUT: HTTP {put_resp.status_code} — {put_resp.text[:200]}", |
| 310 | put_resp.status_code, |
| 311 | ) |
| 312 | |
| 313 | unpack_body = json.dumps( |
| 314 | { |
| 315 | "mpack_key": mpack_key, |
| 316 | "branch": branch, |
| 317 | "head": local_head, |
| 318 | "commits_count": n_commits, |
| 319 | "blobs_count": n_objects, |
| 320 | "force": force, |
| 321 | }, |
| 322 | ).encode() |
| 323 | _unpack_headers = dict(transport._build_request( |
| 324 | "POST", unpack_mpack_endpoint, signing, unpack_body, |
| 325 | extra_headers={"Content-Type": "application/json", "Accept": "application/json"}, |
| 326 | ).headers) |
| 327 | print(f"[PUSH step 7] POST /push/unpack-mpack {{ mpack_key={mpack_key}, branch={branch}, head_commit_id={local_head}, force={force} }}", file=sys.stderr) |
| 328 | unpack_resp = await _client.post( |
| 329 | unpack_mpack_endpoint, |
| 330 | content=unpack_body, |
| 331 | headers=_unpack_headers, |
| 332 | ) |
| 333 | if unpack_resp.status_code >= 400: |
| 334 | raise TransportError( |
| 335 | f"push/unpack-mpack: HTTP {unpack_resp.status_code} — {unpack_resp.text[:200]}", |
| 336 | unpack_resp.status_code, |
| 337 | ) |
| 338 | unpack_data = unpack_resp.json() |
| 339 | print( |
| 340 | f"[PUSH step 7] → {{ blobs_written={unpack_data.get('blobs_in_mpack')}, " |
| 341 | f"blobs_skipped={unpack_data.get('blobs_skipped', 0)}, " |
| 342 | f"commits_written={unpack_data.get('commits_in_mpack')}, " |
| 343 | f"branch_head={unpack_data.get('head', '')} }}", |
| 344 | file=sys.stderr, |
| 345 | ) |
| 346 | |
| 347 | return PushResult( |
| 348 | ok=True, |
| 349 | message="ok", |
| 350 | branch_heads={branch: local_head}, |
| 351 | ) |
| 352 | |
| 353 | with _ignore_sigpipe(): |
| 354 | result = _asyncio.run(_run_mpack_path()) |
| 355 | |
| 356 | _t4 = _time.perf_counter() |
| 357 | |
| 358 | return result, n_commits, n_objects |
| 359 | |
| 360 | def _fetch_remote_info_safe( |
| 361 | transport: MuseTransport, |
| 362 | url: str, |
| 363 | token: str | None, |
| 364 | ) -> RemoteInfo | None: |
| 365 | """Call ``GET /refs`` on the remote and return its current branch heads. |
| 366 | |
| 367 | Returns ``None`` only for network-level failures (``status_code == 0``, |
| 368 | e.g. connection refused or DNS failure) so callers can fall back to |
| 369 | locally-cached tracking refs. |
| 370 | |
| 371 | Re-raises ``TransportError`` for all HTTP error codes (404, 401, 409, |
| 372 | 5xx) — those require user action and must not be silenced. |
| 373 | """ |
| 374 | try: |
| 375 | return transport.fetch_remote_info(url, token) |
| 376 | except TransportError as exc: |
| 377 | if exc.status_code == 0: |
| 378 | return None |
| 379 | raise |
| 380 | |
| 381 | def _is_valid_commit_id(value: str) -> bool: |
| 382 | """Return True if *value* is a well-formed sha256:<64-hex> commit ID. |
| 383 | |
| 384 | Filters out server sentinels like ``"init-sha256:<short>"`` that signal an |
| 385 | empty remote branch. These are not real commit IDs and must not be passed |
| 386 | to ``commit_exists`` or included in the ``have`` list. |
| 387 | """ |
| 388 | try: |
| 389 | split_id(value) |
| 390 | return True |
| 391 | except ValueError: |
| 392 | return False |
| 393 | |
| 394 | def _all_known_have_anchors(root: pathlib.Path) -> list[str]: |
| 395 | """Return every commit ID cached in any remote's tracking refs. |
| 396 | |
| 397 | When pushing a new branch (or to a remote with no local tracking cache), |
| 398 | these commits are our best guess at what the remote already holds. Any |
| 399 | remote the user has previously pushed to shares commit ancestry with other |
| 400 | remotes — using all cached heads as ``have`` anchors ensures ``build_mpack`` |
| 401 | only transmits the delta since the nearest shared ancestor. |
| 402 | |
| 403 | Symlinks inside ``.muse/remotes/`` are skipped rather than followed to |
| 404 | prevent path-traversal attacks. Unreadable files are silently skipped |
| 405 | so a corrupted tracking ref doesn't abort the push. |
| 406 | """ |
| 407 | remotes_dir = _remotes_dir(root) |
| 408 | if not remotes_dir.is_dir(): |
| 409 | return [] |
| 410 | heads: list[str] = [] |
| 411 | for ref_file in remotes_dir.rglob("*"): |
| 412 | if ref_file.is_symlink() or not ref_file.is_file(): |
| 413 | continue |
| 414 | commit_id = read_ref(ref_file) |
| 415 | if commit_id: |
| 416 | heads.append(commit_id) |
| 417 | return heads |
| 418 | |
| 419 | def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: |
| 420 | """Register the ``muse push`` subcommand and all its flags.""" |
| 421 | parser = subparsers.add_parser( |
| 422 | "push", |
| 423 | help="Upload local commits, snapshots, and objects to a remote.", |
| 424 | description=__doc__, |
| 425 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 426 | ) |
| 427 | parser.add_argument( |
| 428 | "remote", nargs="?", default="origin", |
| 429 | help="Remote name to push to (default: origin).", |
| 430 | ) |
| 431 | parser.add_argument( |
| 432 | "branch_pos", nargs="?", default=None, metavar="BRANCH", |
| 433 | help="Branch to push (default: current branch). Same as --branch.", |
| 434 | ) |
| 435 | parser.add_argument( |
| 436 | "--branch", "-b", default=None, dest="branch_flag", |
| 437 | help="Branch to push (default: current branch).", |
| 438 | ) |
| 439 | parser.add_argument( |
| 440 | "-u", "--set-upstream", action="store_true", dest="set_upstream_flag", |
| 441 | help="Record upstream tracking for this branch.", |
| 442 | ) |
| 443 | parser.add_argument( |
| 444 | "--force", "-f", action="store_true", |
| 445 | help="Force push even if the remote has diverged.", |
| 446 | ) |
| 447 | parser.add_argument( |
| 448 | "--force-with-lease", action="store_true", dest="force_with_lease", |
| 449 | help=( |
| 450 | "Force push only if the remote branch has not advanced since the last fetch. " |
| 451 | "Safer than --force: rejects the push if someone else has pushed in the meantime." |
| 452 | ), |
| 453 | ) |
| 454 | parser.add_argument( |
| 455 | "--delete", "-d", action="store_true", dest="delete_branch", |
| 456 | help="Delete the named branch on the remote.", |
| 457 | ) |
| 458 | parser.add_argument( |
| 459 | "-n", "--dry-run", action="store_true", |
| 460 | help="Compute what would be pushed without sending any data.", |
| 461 | ) |
| 462 | parser.add_argument( |
| 463 | "--workers", type=int, default=16, metavar="N", |
| 464 | help="Number of parallel upload workers (default: 16).", |
| 465 | ) |
| 466 | parser.add_argument( |
| 467 | "--json", "-j", action="store_true", dest="json_out", |
| 468 | help="Emit machine-readable JSON instead of human text.", |
| 469 | ) |
| 470 | parser.add_argument( |
| 471 | "--hub", default=None, metavar="URL", |
| 472 | help=( |
| 473 | "Resolve a hub URL to its configured remote name and use that remote. " |
| 474 | "Equivalent to finding the remote whose URL matches and using it as " |
| 475 | "the <remote> argument. " |
| 476 | "Example: --hub https://staging.musehub.ai" |
| 477 | ), |
| 478 | ) |
| 479 | parser.set_defaults(func=run) |
| 480 | |
| 481 | def run(args: argparse.Namespace) -> None: |
| 482 | """Upload local commits, snapshots, and objects to a remote. |
| 483 | |
| 484 | Requires the remote to be a fast-forward of the local branch unless |
| 485 | ``--force`` is specified. |
| 486 | |
| 487 | All progress and error messages go to **stderr**. ``--format json`` |
| 488 | (or ``--json``) emits a single JSON object on stdout for agent pipelines. |
| 489 | |
| 490 | ``--dry-run`` computes the pack that *would* be sent using local tracking |
| 491 | refs as have-anchors and exits 0 without connecting to the remote. |
| 492 | |
| 493 | JSON schema:: |
| 494 | |
| 495 | { |
| 496 | "status": "pushed | up_to_date | dry_run | deleted", |
| 497 | "remote": "<remote_name>", |
| 498 | "branch": "<branch>", |
| 499 | "head": "<sha256> | null", |
| 500 | "commits_sent": <N>, |
| 501 | "objects_sent": <N>, |
| 502 | "force": false, |
| 503 | "dry_run": false |
| 504 | } |
| 505 | |
| 506 | Exit codes:: |
| 507 | |
| 508 | 0 — success |
| 509 | 1 — remote not configured, branch has no commits, push rejected, |
| 510 | authentication failure, network error, invalid format |
| 511 | """ |
| 512 | elapsed = start_timer() |
| 513 | # ── --hub URL: resolve to a configured remote name ─────────────────────── |
| 514 | # Agents sometimes pass --hub <url> (the muse hub subcommand pattern) to |
| 515 | # muse push by mistake. Accept it gracefully: look up the remote whose URL |
| 516 | # matches and use it, or fail with a clear, actionable message. |
| 517 | # |
| 518 | # Positional re-interpretation: when --hub is given, the <remote> positional |
| 519 | # slot is irrelevant (the remote is determined by the URL). If only one |
| 520 | # positional was provided it landed in args.remote but was intended as the |
| 521 | # branch — move it to branch_pos so it is not silently discarded. |
| 522 | json_out: bool = args.json_out |
| 523 | hub_url: str | None = getattr(args, "hub", None) |
| 524 | if hub_url is not None: |
| 525 | root_for_hub = require_repo() |
| 526 | all_remotes = list_remotes(root_for_hub) |
| 527 | matched = next( |
| 528 | (r for r in all_remotes if r["url"].rstrip("/") == hub_url.rstrip("/")), |
| 529 | None, |
| 530 | ) |
| 531 | if matched is None: |
| 532 | remote_list = ", ".join(r["name"] for r in all_remotes) if all_remotes else "(none configured)" |
| 533 | if json_out: |
| 534 | print(json.dumps({"error": "remote_not_found", "hub_url": hub_url, "configured_remotes": remote_list})) |
| 535 | else: |
| 536 | print( |
| 537 | f"❌ No remote is configured for hub URL '{sanitize_display(hub_url)}'.\n" |
| 538 | f" Configured remotes: {remote_list}\n" |
| 539 | f" muse push uses remote names, not --hub URLs:\n" |
| 540 | f" muse push <remote> <branch>", |
| 541 | file=sys.stderr, |
| 542 | ) |
| 543 | raise SystemExit(ExitCode.USER_ERROR) |
| 544 | # If only one positional was supplied it consumed the <remote> slot but |
| 545 | # is actually the branch name — rescue it before overwriting args.remote. |
| 546 | if args.branch_pos is None and args.remote not in {r["name"] for r in all_remotes}: |
| 547 | args.branch_pos = args.remote |
| 548 | args.remote = matched["name"] |
| 549 | |
| 550 | remote: str = args.remote |
| 551 | branch: str | None = ( |
| 552 | getattr(args, "branch_flag", None) or getattr(args, "branch_pos", None) |
| 553 | ) |
| 554 | set_upstream_flag: bool = args.set_upstream_flag |
| 555 | force: bool = args.force |
| 556 | force_with_lease: bool = getattr(args, "force_with_lease", False) |
| 557 | if force_with_lease and not force: |
| 558 | # --force-with-lease implies force behaviour but with a safety check. |
| 559 | force = True |
| 560 | delete_branch: bool = getattr(args, "delete_branch", False) |
| 561 | dry_run: bool = getattr(args, "dry_run", False) |
| 562 | |
| 563 | root = require_repo() |
| 564 | |
| 565 | url = get_remote(remote, root) |
| 566 | if url is None: |
| 567 | all_remotes = list_remotes(root) |
| 568 | configured = ( |
| 569 | ", ".join(r["name"] for r in all_remotes) if all_remotes else "(none)" |
| 570 | ) |
| 571 | if json_out: |
| 572 | print(json.dumps({"error": "remote_not_configured", "remote": remote, "configured_remotes": configured})) |
| 573 | else: |
| 574 | print( |
| 575 | f"❌ Remote '{sanitize_display(remote)}' is not configured.\n" |
| 576 | f" Configured remotes: {configured}\n" |
| 577 | f" Add one with: muse remote add {sanitize_display(remote)} <url>", |
| 578 | file=sys.stderr, |
| 579 | ) |
| 580 | raise SystemExit(ExitCode.USER_ERROR) |
| 581 | |
| 582 | # ── DELETE MODE ─────────────────────────────────────────────────────────── |
| 583 | if delete_branch: |
| 584 | current_branch = read_current_branch(root) |
| 585 | del_branch = branch or current_branch |
| 586 | if not del_branch: |
| 587 | if json_out: |
| 588 | print(json.dumps({"error": "no_branch_specified", "message": "specify a branch to delete: muse push <remote> --delete <branch>"})) |
| 589 | else: |
| 590 | print( |
| 591 | "❌ Specify a branch to delete: muse push <remote> --delete <branch>", |
| 592 | file=sys.stderr, |
| 593 | ) |
| 594 | raise SystemExit(ExitCode.USER_ERROR) |
| 595 | |
| 596 | if dry_run: |
| 597 | msg = f"Would delete {sanitize_display(remote)}/{sanitize_display(del_branch)}" |
| 598 | if json_out: |
| 599 | print(json.dumps(_PushJson( |
| 600 | **make_envelope(elapsed), |
| 601 | status="dry_run", |
| 602 | remote=remote, |
| 603 | branch=del_branch, |
| 604 | head=None, |
| 605 | commits_sent=0, |
| 606 | objects_sent=0, |
| 607 | force=force, |
| 608 | dry_run=True, |
| 609 | ))) |
| 610 | else: |
| 611 | print(msg) |
| 612 | return |
| 613 | |
| 614 | token = get_signing_identity(root, remote_url=url) |
| 615 | transport = make_transport(url) |
| 616 | print( |
| 617 | f"Deleting {sanitize_display(remote)}/{sanitize_display(del_branch)} …", |
| 618 | file=sys.stderr, |
| 619 | ) |
| 620 | already_gone = False |
| 621 | try: |
| 622 | transport.delete_branch_remote(url, token, del_branch) |
| 623 | except TransportError as exc: |
| 624 | if exc.status_code == 404: |
| 625 | already_gone = True |
| 626 | elif exc.status_code == 409: |
| 627 | if json_out: |
| 628 | print(json.dumps({"error": "default_branch", "branch": del_branch, "message": f"cannot delete the default branch '{del_branch}'"})) |
| 629 | else: |
| 630 | print(f"❌ Cannot delete the default branch '{sanitize_display(del_branch)}'.", file=sys.stderr) |
| 631 | raise SystemExit(ExitCode.USER_ERROR) |
| 632 | elif exc.status_code == 403: |
| 633 | if json_out: |
| 634 | print(json.dumps({"error": "permission_denied", "message": "only the repo owner may delete branches"})) |
| 635 | else: |
| 636 | print("❌ Permission denied — only the repo owner may delete branches.", file=sys.stderr) |
| 637 | raise SystemExit(ExitCode.USER_ERROR) |
| 638 | else: |
| 639 | if json_out: |
| 640 | print(json.dumps({"error": "delete_failed", "message": str(exc)})) |
| 641 | else: |
| 642 | print(f"❌ Delete failed: {sanitize_display(str(exc))}", file=sys.stderr) |
| 643 | raise SystemExit(ExitCode.USER_ERROR) |
| 644 | |
| 645 | pruned = delete_remote_head(remote, del_branch, root) |
| 646 | if json_out: |
| 647 | print(json.dumps(_PushJson( |
| 648 | **make_envelope(elapsed), |
| 649 | status="deleted", |
| 650 | remote=remote, |
| 651 | branch=del_branch, |
| 652 | head=None, |
| 653 | commits_sent=0, |
| 654 | objects_sent=0, |
| 655 | force=force, |
| 656 | dry_run=False, |
| 657 | ))) |
| 658 | else: |
| 659 | if already_gone: |
| 660 | note = " (already absent on remote)" |
| 661 | if pruned: |
| 662 | note += ", tracking ref pruned" |
| 663 | print(f"✅ {sanitize_display(remote)}/{sanitize_display(del_branch)}{note}") |
| 664 | else: |
| 665 | prune_note = " (tracking ref pruned)" if pruned else "" |
| 666 | print( |
| 667 | f"✅ Deleted remote branch " |
| 668 | f"{sanitize_display(remote)}/{sanitize_display(del_branch)}{prune_note}" |
| 669 | ) |
| 670 | return |
| 671 | |
| 672 | # ── NORMAL PUSH ────────────────────────────────────────────────────────── |
| 673 | current_branch = read_current_branch(root) |
| 674 | push_branch = branch or current_branch |
| 675 | |
| 676 | local_head = get_head_commit_id(root, push_branch) |
| 677 | if local_head is None: |
| 678 | if json_out: |
| 679 | print(json.dumps({"error": "no_commits", "branch": push_branch, "message": f"branch '{push_branch}' has no commits to push"})) |
| 680 | else: |
| 681 | print(f"❌ Branch '{sanitize_display(push_branch)}' has no commits to push.", file=sys.stderr) |
| 682 | raise SystemExit(ExitCode.USER_ERROR) |
| 683 | |
| 684 | # ── DRY-RUN: compute pack locally, no network calls ─────────────────────── |
| 685 | if dry_run: |
| 686 | have: list[str] = [ |
| 687 | c for c in _all_known_have_anchors(root) |
| 688 | if c != local_head and commit_exists(root, c) |
| 689 | ] |
| 690 | dry_branch_have: list[str] = [] |
| 691 | _cached = get_remote_head(remote, push_branch, root) |
| 692 | if _cached and commit_exists(root, _cached): |
| 693 | dry_branch_have = [_cached] |
| 694 | dry_walk = walk_commits(root, [local_head], have=dry_branch_have) |
| 695 | dry_commits = len(dry_walk["commits"]) |
| 696 | dry_objects = len(collect_blob_ids(root, [local_head], have=have)) |
| 697 | if json_out: |
| 698 | print(json.dumps(_PushJson( |
| 699 | **make_envelope(elapsed), |
| 700 | status="dry_run", |
| 701 | remote=remote, |
| 702 | branch=push_branch, |
| 703 | head=local_head, |
| 704 | commits_sent=dry_commits, |
| 705 | objects_sent=dry_objects, |
| 706 | force=force, |
| 707 | dry_run=True, |
| 708 | ))) |
| 709 | else: |
| 710 | print( |
| 711 | f"Would push {dry_commits} commit(s) and ~{dry_objects} object(s) " |
| 712 | f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} (dry run)" |
| 713 | ) |
| 714 | return |
| 715 | |
| 716 | transport = make_transport(url) |
| 717 | token = get_signing_identity(root, remote_url=url) |
| 718 | |
| 719 | # ── STEP 0: discover remote state ──────────────────────────────────────── |
| 720 | print(f"[PUSH step 0] GET {url.rstrip('/')}/refs", file=sys.stderr) |
| 721 | try: |
| 722 | remote_info = _fetch_remote_info_safe(transport, url, token) |
| 723 | except TransportError as exc: |
| 724 | if json_out: |
| 725 | code_map = {404: "repository_not_found", 401: "auth_required"} |
| 726 | print(json.dumps({"error": code_map.get(exc.status_code, "remote_error"), "status_code": exc.status_code, "remote": remote, "branch": push_branch, "message": str(exc)})) |
| 727 | elif exc.status_code == 404: |
| 728 | print( |
| 729 | f"❌ Repository not found on remote '{sanitize_display(remote)}'.\n" |
| 730 | f" Create it first: muse hub repo create --name <repo-name>\n" |
| 731 | f" Then verify the remote URL: muse remote -v", |
| 732 | file=sys.stderr, |
| 733 | ) |
| 734 | elif exc.status_code == 401: |
| 735 | print( |
| 736 | f"❌ Authentication required — remote '{sanitize_display(remote)}' returned 401.\n" |
| 737 | f" Run: muse auth register", |
| 738 | file=sys.stderr, |
| 739 | ) |
| 740 | else: |
| 741 | print(f"❌ Remote error ({exc.status_code}): {exc}", file=sys.stderr) |
| 742 | raise SystemExit(ExitCode.USER_ERROR) |
| 743 | if remote_info is not None: |
| 744 | remote_branch_heads = remote_info["branch_heads"] |
| 745 | candidate_have = list(remote_branch_heads.values()) |
| 746 | _remote_head = remote_branch_heads.get(push_branch) or None |
| 747 | print(f"[PUSH step 0] → branch_heads = {remote_branch_heads}", file=sys.stderr) |
| 748 | print(f"[PUSH step 0] remote_head = {_remote_head or 'null'}", file=sys.stderr) |
| 749 | print(f"[PUSH step 0] have = {candidate_have}", file=sys.stderr) |
| 750 | else: |
| 751 | # live /refs unreachable (e.g. SSL failure on self-signed cert) — |
| 752 | # fall back to locally cached tracking refs for THIS remote only. |
| 753 | # Same-remote tracking refs are safe: they were written on the last |
| 754 | # successful push to this exact remote, so they represent objects |
| 755 | # the remote already holds. Cross-remote refs are NOT included. |
| 756 | remote_branch_heads = {} |
| 757 | remote_tracking_dir = _remotes_dir(root) / remote |
| 758 | candidate_have = [] |
| 759 | if remote_tracking_dir.is_dir(): |
| 760 | for _ref_file in remote_tracking_dir.rglob("*"): |
| 761 | if _ref_file.is_symlink() or not _ref_file.is_file(): |
| 762 | continue |
| 763 | _cached_id = read_ref(_ref_file) |
| 764 | if _cached_id: |
| 765 | candidate_have.append(_cached_id) |
| 766 | # have-anchors are what the SERVER already has — not what we have locally. |
| 767 | # commit_exists() must NOT be used here: if the remote has a commit we |
| 768 | # don't have locally (e.g. after a force-push or diverged history), the |
| 769 | # local walk stops naturally when it can't traverse past a missing commit. |
| 770 | # Filtering by commit_exists() causes have=[] and re-sends the full history. |
| 771 | have = [ |
| 772 | c for c in candidate_have |
| 773 | if c != local_head and _is_valid_commit_id(c) |
| 774 | ] |
| 775 | |
| 776 | if remote_info is not None: |
| 777 | remote_head: str | None = remote_branch_heads.get(push_branch) |
| 778 | else: |
| 779 | remote_head = get_remote_head(remote, push_branch, root) |
| 780 | |
| 781 | print(f"[PUSH step 1] walk local DAG → find commits not on remote (want - have)", file=sys.stderr) |
| 782 | print(f"[PUSH step 1] want = {local_head}", file=sys.stderr) |
| 783 | print(f"[PUSH step 1] remote_head = {remote_head or 'null'}", file=sys.stderr) |
| 784 | print(f"[PUSH step 1] have = {have}", file=sys.stderr) |
| 785 | |
| 786 | # ── FORCE-WITH-LEASE safety check ──────────────────────────────────────── |
| 787 | # Reject the push if the live remote HEAD has advanced beyond what we last |
| 788 | # fetched — i.e., someone else pushed after our last fetch/pull. |
| 789 | if force_with_lease and remote_head is not None: |
| 790 | cached_head = get_remote_head(remote, push_branch, root) |
| 791 | if cached_head != remote_head: |
| 792 | if json_out: |
| 793 | print(json.dumps({"error": "force_with_lease_rejected", "remote": remote, "branch": push_branch, "message": "remote has advanced since last fetch — run muse fetch first"})) |
| 794 | else: |
| 795 | print( |
| 796 | f"❌ Push rejected (--force-with-lease): remote " |
| 797 | f"'{sanitize_display(remote)}/{sanitize_display(push_branch)}' has advanced " |
| 798 | f"since your last fetch.\n" |
| 799 | f" Run 'muse fetch' to update your tracking refs, then retry.", |
| 800 | file=sys.stderr, |
| 801 | ) |
| 802 | raise SystemExit(ExitCode.USER_ERROR) |
| 803 | |
| 804 | if remote_head is None: |
| 805 | print(f"[PUSH step 1] remote_head is null → new_commits = all commits reachable from local tip", file=sys.stderr) |
| 806 | elif remote_head == local_head: |
| 807 | print(f"[PUSH step 1] remote_head == local_tip → nothing to push", file=sys.stderr) |
| 808 | else: |
| 809 | print(f"[PUSH step 1] new_commits = commits reachable from local tip, not reachable from remote_head", file=sys.stderr) |
| 810 | |
| 811 | if remote_head == local_head: |
| 812 | if json_out: |
| 813 | print(json.dumps(_PushJson( |
| 814 | **make_envelope(elapsed), |
| 815 | status="up_to_date", |
| 816 | remote=remote, |
| 817 | branch=push_branch, |
| 818 | head=local_head, |
| 819 | commits_sent=0, |
| 820 | objects_sent=0, |
| 821 | force=force, |
| 822 | dry_run=False, |
| 823 | ))) |
| 824 | else: |
| 825 | print( |
| 826 | f"Everything up to date. " |
| 827 | f"Remote {sanitize_display(remote)}/{sanitize_display(push_branch)} " |
| 828 | f"is already at {local_head}." |
| 829 | ) |
| 830 | return |
| 831 | |
| 832 | # `branch_have` is the commit-graph boundary for build_mpack (Phase 5). |
| 833 | # Normally it contains only the target branch's remote HEAD: using broader |
| 834 | # remote refs risks stopping the BFS at an "intermediate" branch that sits |
| 835 | # between the target's remote HEAD and the local tip, producing an mpack |
| 836 | # the server cannot verify as a fast-forward. |
| 837 | # |
| 838 | # Merge commits are the exception. The merge commit's first parent IS the |
| 839 | # target branch's remote HEAD (one hop), so there is nothing "between" it |
| 840 | # and local HEAD on P1's chain. The second parent (P2) may have a long |
| 841 | # ancestry that the server already has on another branch. Without stop |
| 842 | # anchors on P2's chain, the BFS walks every ancestor of P2 — potentially |
| 843 | # thousands of commits the remote already stores — overwhelming the server. |
| 844 | # |
| 845 | # Fix: for merge commits, add all known remote branch heads to branch_have. |
| 846 | # The BFS stops at the nearest already-remote commit on P2's chain instead |
| 847 | # of walking back to the root of the repository. |
| 848 | branch_have: list[str] = ( |
| 849 | [remote_head] |
| 850 | if remote_head and _is_valid_commit_id(remote_head) and commit_exists(root, remote_head) |
| 851 | else [] |
| 852 | ) |
| 853 | local_commit = read_commit(root, local_head) |
| 854 | if local_commit and local_commit.parent2_commit_id: |
| 855 | # No commit_exists() guard here: the BFS stops when a parent commit_id |
| 856 | # is in `seen` — it never tries to read a commit whose ID is already |
| 857 | # in the seen set. So the anchor works even if the commit isn't in |
| 858 | # the local object store (e.g. a remote branch that was never fetched). |
| 859 | for ref_head in remote_branch_heads.values(): |
| 860 | if ref_head not in branch_have: |
| 861 | branch_have.append(ref_head) |
| 862 | |
| 863 | try: |
| 864 | result, commits_sent, objects_sent = _push_mpack( |
| 865 | transport, url, token, root, local_head, have, push_branch, force, |
| 866 | branch_have=branch_have, |
| 867 | ) |
| 868 | except TransportError as exc: |
| 869 | if json_out: |
| 870 | code_map = {404: "repo_not_found", 401: "auth_required", 409: "diverged"} |
| 871 | print(json.dumps({"error": code_map.get(exc.status_code, "push_failed"), "status_code": exc.status_code, "remote": remote, "branch": push_branch, "message": str(exc)})) |
| 872 | elif exc.status_code == 404: |
| 873 | print( |
| 874 | f"❌ Repository not found on remote '{sanitize_display(remote)}'.\n" |
| 875 | f" The remote server returned 404 for this repo.\n" |
| 876 | f"\n" |
| 877 | f" If this is a new repo, create it on the server first:\n" |
| 878 | f" muse hub repo create (if using MuseHub)\n" |
| 879 | f"\n" |
| 880 | f" If the repo exists, verify your remote URL:\n" |
| 881 | f" muse remote -v\n" |
| 882 | f"\n" |
| 883 | f" If you are authenticated, run: muse auth whoami", |
| 884 | file=sys.stderr, |
| 885 | ) |
| 886 | elif exc.status_code == 401: |
| 887 | print( |
| 888 | f"❌ Authentication required — remote '{sanitize_display(remote)}' returned 401.\n" |
| 889 | f" Log in first: muse auth register", |
| 890 | file=sys.stderr, |
| 891 | ) |
| 892 | elif exc.status_code == 409: |
| 893 | print( |
| 894 | f"❌ Push rejected — remote " |
| 895 | f"'{sanitize_display(remote)}/{sanitize_display(push_branch)}' has diverged.\n" |
| 896 | f" Pull first (muse pull) or use --force to override.", |
| 897 | file=sys.stderr, |
| 898 | ) |
| 899 | else: |
| 900 | print(f"❌ Push failed: {sanitize_display(str(exc))}", file=sys.stderr) |
| 901 | raise SystemExit(ExitCode.USER_ERROR) |
| 902 | |
| 903 | if not result["ok"]: |
| 904 | if json_out: |
| 905 | print(json.dumps({"error": "push_rejected", "remote": remote, "branch": push_branch, "message": result["message"]})) |
| 906 | else: |
| 907 | print(f"❌ Push rejected by remote: {sanitize_display(str(result['message']))}", file=sys.stderr) |
| 908 | raise SystemExit(ExitCode.USER_ERROR) |
| 909 | |
| 910 | updated_head = result["branch_heads"].get(push_branch, local_head) |
| 911 | set_remote_head(remote, push_branch, updated_head, root) |
| 912 | |
| 913 | if set_upstream_flag: |
| 914 | set_upstream(push_branch, remote, root) |
| 915 | print( |
| 916 | f" Upstream set: {sanitize_display(push_branch)} → " |
| 917 | f"{sanitize_display(remote)}/{sanitize_display(push_branch)}", |
| 918 | file=sys.stderr, |
| 919 | ) |
| 920 | |
| 921 | if json_out: |
| 922 | print(json.dumps(_PushJson( |
| 923 | **make_envelope(elapsed), |
| 924 | status="pushed", |
| 925 | remote=remote, |
| 926 | branch=push_branch, |
| 927 | head=updated_head, |
| 928 | commits_sent=commits_sent, |
| 929 | objects_sent=objects_sent, |
| 930 | force=force, |
| 931 | dry_run=False, |
| 932 | ))) |
| 933 | else: |
| 934 | print( |
| 935 | f"✅ Pushed {commits_sent} commit(s), {objects_sent} object(s) " |
| 936 | f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} " |
| 937 | f"({updated_head})" |
| 938 | ) |
| 939 | # probe |
| 940 | # probe2 |
File History
9 commits
sha256:35855b7bbce81b93612c655e23587bdebbb5fc09856ff5cbf5cd5b195d0547d4
merge: dev → main (rc11, urllib migration, object store inv…
Sonnet 4.6
patch
19 days ago
sha256:633dfa2940e97bf1a3d04996c772027a57d70d103f1693c96da04969613dba6c
fix: urllib migration regressions — force flag, job_id, Con…
Sonnet 4.6
minor
⚠
19 days ago
sha256:00cec040ce5f70bf8191d2ce6a9f308fbde553911068f0c303217f4eb6d4e775
fix: migrate httpx → urllib in transport.py and push.py; fi…
Sonnet 4.6
minor
⚠
19 days ago
sha256:b1447dbe2ef78eb6ec67b8ec4cc0e9c29472382f4390741d6ce069cdf5efa792
fix: branch_have uses all remote heads unconditionally (Pha…
Sonnet 4.6
patch
20 days ago
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2
fix: remove commit_exists filter from have anchors — server…
Sonnet 4.6
patch
20 days ago
sha256:7a59846a92918d24b441ef3821a51fa47e16feedc844f411204c853a120fce89
fix: use http.client for R2 PUT to avoid Content-Type auto-…
Sonnet 4.6
21 days ago
sha256:7e95b29f2d502ad5eccf2a57af4092763a2e705f1bf1569a8cb7e063b6e6d5bd
refactor: replace httpx with stdlib urllib in push path
Sonnet 4.6
minor
⚠
21 days ago
sha256:10f95c596dca410d2169ce7a0e8e0f4d958f04523eca23301aa33deeac2c6ab7
perf: remove per-item push debug logs from steps 1 and 2
Sonnet 4.6
22 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
22 days ago