gabriel / muse public
push.py python
1,241 lines 49.8 KB
Raw
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