gabriel / muse public
push.py python
940 lines 39.2 KB
Raw
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