gabriel / muse public
push.py python
1,008 lines 42.2 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 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 collections.abc import Mapping
66 from typing import TypedDict
67
68 from muse.cli.config import (
69 delete_remote_head,
70 get_signing_identity,
71 get_remote,
72 get_remote_head,
73 get_config_value,
74 list_remotes,
75 set_remote_head,
76 set_upstream,
77 )
78 from muse.core.version_tags import list_version_tags
79 from muse.core.types import split_id
80 from muse.core.paths import remotes_dir as _remotes_dir
81 from muse.core.envelope import EnvelopeJson, make_envelope
82 from muse.core.errors import ExitCode
83 from muse.core.mpack import (
84 PushResult,
85 RemoteInfo,
86 build_mpack_from_walk,
87 build_wire_mpack,
88 collect_blob_ids,
89 walk_commits,
90 )
91 from muse.core.types import blob_id as _blob_id
92 from muse.core.refs import read_ref
93 from muse.core.repo import require_repo
94 from muse.core.timing import start_timer
95 from muse.core.refs import (
96 get_head_commit_id,
97 read_current_branch,
98 )
99 from muse.core.commits import (
100 commit_exists,
101 read_commit,
102 )
103 from muse.core.snapshots import read_snapshot as _read_snapshot
104 from muse.core.transport import (
105 MuseTransport,
106 SigningIdentity,
107 TransportError,
108 _ignore_sigpipe,
109 make_transport,
110 )
111 from muse.core.validation import sanitize_display
112
113 logger = logging.getLogger(__name__)
114
115 import http.client as _http_client # noqa: E402
116 import ssl as _ssl # noqa: E402
117 import urllib.request as _urllib_req # noqa: E402
118 import urllib.error as _urllib_err # noqa: E402
119 import urllib.parse as _urllib_parse # noqa: E402
120 import asyncio as _asyncio # noqa: E402
121 from muse.core.transport import _TIMEOUT_SECONDS as _PUSH_TIMEOUT # noqa: E402
122
123
124 def _urllib_put(url: str, data: bytes, *, timeout: int = _PUSH_TIMEOUT,
125 verify: bool = True) -> tuple[int, bytes]:
126 """PUT *data* to a presigned URL using http.client directly.
127
128 urllib.request adds Content-Type automatically which breaks S3/MinIO
129 presigned URL signatures (signed with empty Content-Type). http.client
130 gives us full control — we send only Content-Length.
131 """
132 parsed = _urllib_parse.urlparse(url)
133 host = parsed.netloc
134 path = parsed.path + ("?" + parsed.query if parsed.query else "")
135
136 if parsed.scheme == "https":
137 ctx = _ssl.create_default_context() if verify else _ssl.SSLContext(_ssl.PROTOCOL_TLS_CLIENT)
138 if not verify:
139 ctx.check_hostname = False
140 ctx.verify_mode = _ssl.CERT_NONE
141 conn: "_http_client.HTTPConnection" = _http_client.HTTPSConnection(
142 host, timeout=timeout, context=ctx
143 )
144 else:
145 conn = _http_client.HTTPConnection(host, timeout=timeout)
146
147 try:
148 conn.request("PUT", path, body=data, headers={"Content-Length": str(len(data))})
149 resp = conn.getresponse()
150 return resp.status, resp.read()
151 finally:
152 conn.close()
153
154
155 def _urllib_post(url: str, data: bytes, headers: Mapping[str, str],
156 *, timeout: int = _PUSH_TIMEOUT,
157 verify: bool = True) -> tuple[int, bytes]:
158 """POST *data* to *url* using stdlib urllib. Returns (status_code, body)."""
159 ctx = None if verify else _ssl.create_default_context()
160 if ctx is not None:
161 ctx.check_hostname = False
162 ctx.verify_mode = _ssl.CERT_NONE
163 req = _urllib_req.Request(url, data=data, headers=dict(headers), method="POST")
164 try:
165 with _urllib_req.urlopen(req, timeout=timeout, context=ctx) as resp:
166 return resp.status, resp.read()
167 except _urllib_err.HTTPError as exc:
168 return exc.code, exc.read()
169
170
171 class _PushJson(EnvelopeJson):
172 """Stable JSON schema for push output."""
173
174 status: str
175 remote: str
176 branch: str
177 head: str | None
178 commits_sent: int
179 objects_sent: int
180 force: bool
181 dry_run: bool
182
183
184 class _PushWithTagsJson(_PushJson):
185 """Extended push schema emitted when ``--tags`` is active."""
186
187 tags_pushed: int
188 tags_skipped: int
189
190
191
192 def _push_mpack(
193 transport: MuseTransport,
194 url: str,
195 signing: SigningIdentity | None,
196 root: pathlib.Path,
197 local_head: str,
198 have: list[str],
199 branch: str,
200 force: bool,
201 branch_have: list[str] | None = None,
202 ) -> tuple[PushResult, int, int]:
203 """Push commits to a remote via the mpack presign protocol.
204
205 Walks commits from *local_head* (excluding anything reachable from
206 *branch_have*), builds a single mpack, PUTs it to MinIO via a presigned
207 URL, then POSTs to push/unpack-mpack to index and advance the branch pointer.
208
209 Returns:
210 ``(PushResult, commits_sent, objects_sent)``
211 """
212 _t0 = _time.perf_counter()
213 _branch_have = branch_have or []
214
215 # ── 1. BFS walk — client-side commit + object dedup ──────────────────────
216 commit_walk = walk_commits(root, [local_head], have=_branch_have)
217 _t1 = _time.perf_counter()
218
219 all_commits_raw = commit_walk.get("commits") or []
220 commits_oldest_first = [
221 c.to_dict() if hasattr(c, "to_dict") else c
222 for c in reversed(all_commits_raw)
223 ]
224
225 all_blob_ids: list[str] = commit_walk["all_blob_ids"]
226 n_commits = len(commits_oldest_first)
227 n_objects = len(all_blob_ids)
228
229 print(f"[PUSH step 1] new_commits = {n_commits}", file=sys.stderr)
230
231 # ── 2. Per-commit: snapshot delta + structured_delta + object collection ──
232 _commits_oldest = list(reversed(all_commits_raw))
233 _snapshot_deltas_log = commit_walk.get("snapshot_deltas") or []
234 _blobs_to_send: set[str] = set(all_blob_ids)
235 _have_blobs: set[str] = commit_walk.get("have_blobs") or set()
236 _manifest_blobs: set[str] = commit_walk.get("manifest_blobs") or set()
237 _accumulated_blobs: set[str] = set()
238
239 # Seed _prev_manifest from the parent snapshot of the first new commit.
240 _prev_manifest: dict[str, str] = {}
241 if _commits_oldest:
242 _first_commit = _commits_oldest[0]
243 _first_parent_id = (
244 _first_commit.parent_commit_id
245 if hasattr(_first_commit, "parent_commit_id")
246 else (_first_commit.get("parent_commit_id") if isinstance(_first_commit, dict) else None)
247 )
248 if _first_parent_id:
249 _parent_commit = read_commit(root, _first_parent_id)
250 if _parent_commit is not None:
251 _parent_snap = _read_snapshot(root, _parent_commit.snapshot_id)
252 if _parent_snap is not None:
253 _prev_manifest = dict(_parent_snap.manifest)
254
255 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)
256
257 for _commit_rec, _delta in zip(_commits_oldest, _snapshot_deltas_log):
258 _snap_id_log = _delta.get("snapshot_id") or ""
259 _child_snap = _read_snapshot(root, _snap_id_log) if _snap_id_log else None
260 _child_manifest: dict[str, str] = dict(_child_snap.manifest) if _child_snap else {}
261 _delta_upsert: dict[str, str] = {
262 k: v for k, v in _child_manifest.items() if _prev_manifest.get(k) != v
263 }
264 _delta_remove: list[str] = [k for k in _prev_manifest if k not in _child_manifest]
265 _added = {p: _delta_upsert[p] for p in _delta_upsert if p not in _prev_manifest}
266 _modified = {p: _delta_upsert[p] for p in _delta_upsert if p in _prev_manifest}
267 for _oid in set(_delta_upsert.values()):
268 if _oid not in _accumulated_blobs and _oid in _blobs_to_send:
269 _accumulated_blobs.add(_oid)
270 _prev_manifest = _child_manifest
271
272 _t4_start = _time.perf_counter()
273
274 def _run_mpack_path() -> "PushResult":
275 # Step 3: pack the walk result into a single mpack.
276 mpack = build_mpack_from_walk(root, commit_walk, compress=True)
277 _n_commits_mpack = len(mpack.get("commits", []))
278 _n_snapshots_mpack = len(mpack.get("snapshots", []))
279 _n_blobs_mpack = len(mpack.get("blobs", []))
280 wire_bytes = build_wire_mpack(mpack)
281 mpack_key = _blob_id(wire_bytes)
282
283 print(f"[PUSH step 3] pack into one mpack:", file=sys.stderr)
284 print(f"[PUSH step 3] {{ commits: {_n_commits_mpack}, snapshots: {_n_snapshots_mpack}, blobs: {_n_blobs_mpack} }}", file=sys.stderr)
285 print(f"[PUSH step 3] format=MUSE binary size={len(wire_bytes)} bytes", file=sys.stderr)
286 print(f"[PUSH step 4] mpack_bytes = {len(wire_bytes)} bytes", file=sys.stderr)
287 print(f"[PUSH step 4] mpack_key = {mpack_key}", file=sys.stderr)
288 print(f"[PUSH step 4] size_bytes = {len(wire_bytes)}", file=sys.stderr)
289
290 # Step 5: get presigned PUT URL
291 print(f"[PUSH step 5] POST /push/mpack-presign {{ mpack_key, size_bytes }}", file=sys.stderr)
292 presign = transport.push_mpack_presign(url, signing, wire_bytes)
293 upload_url: str = presign["upload_url"]
294 print(f"[PUSH step 5] → upload_url = {upload_url}", file=sys.stderr)
295
296 # Step 6: PUT mpack to presigned URL
297 _pre_put_hash = _blob_id(wire_bytes)
298 _pre_put_match = _pre_put_hash == mpack_key
299 print(f"[PUSH step 6] pre-PUT integrity check:", file=sys.stderr)
300 print(f"[PUSH step 6] len(wire_bytes) = {len(wire_bytes)}", file=sys.stderr)
301 print(f"[PUSH step 6] sha256(wire_bytes) = {_pre_put_hash}", file=sys.stderr)
302 print(f"[PUSH step 6] mpack_key = {mpack_key}", file=sys.stderr)
303 print(f"[PUSH step 6] match = {_pre_put_match} {'✓' if _pre_put_match else '✗ MISMATCH'}", file=sys.stderr)
304 print(f"[PUSH step 6] PUT mpack_bytes → presigned_url", file=sys.stderr)
305 transport.push_mpack_put(upload_url, wire_bytes, mpack_key)
306
307 # Step 7: notify server to unpack
308 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)
309 unpack_data = transport.push_mpack_unpack(
310 url, signing, mpack_key,
311 branch=branch, head=local_head,
312 commits_count=n_commits, blobs_count=n_objects,
313 force=force,
314 )
315 print(
316 f"[PUSH step 7] → {{ blobs_written={unpack_data.get('blobs_in_mpack')}, "
317 f"blobs_skipped=0, "
318 f"commits_written={unpack_data.get('commits_in_mpack')}, "
319 f"branch_head={unpack_data.get('head', '')} }}",
320 file=sys.stderr,
321 )
322 return PushResult(ok=True, message="ok", branch_heads={branch: local_head})
323
324 with _ignore_sigpipe():
325 result = _run_mpack_path()
326
327 _t4 = _time.perf_counter()
328
329 return result, n_commits, n_objects
330
331 def _fetch_remote_info_safe(
332 transport: MuseTransport,
333 url: str,
334 token: str | None,
335 ) -> RemoteInfo | None:
336 """Call ``GET /refs`` on the remote and return its current branch heads.
337
338 Returns ``None`` only for network-level failures (``status_code == 0``,
339 e.g. connection refused or DNS failure) so callers can fall back to
340 locally-cached tracking refs.
341
342 Re-raises ``TransportError`` for all HTTP error codes (404, 401, 409,
343 5xx) — those require user action and must not be silenced.
344 """
345 try:
346 return transport.fetch_remote_info(url, token)
347 except TransportError as exc:
348 if exc.status_code == 0:
349 return None
350 raise
351
352 def _is_valid_commit_id(value: str) -> bool:
353 """Return True if *value* is a well-formed sha256:<64-hex> commit ID.
354
355 Filters out server sentinels like ``"init-sha256:<short>"`` that signal an
356 empty remote branch. These are not real commit IDs and must not be passed
357 to ``commit_exists`` or included in the ``have`` list.
358 """
359 try:
360 split_id(value)
361 return True
362 except ValueError:
363 return False
364
365 def _all_known_have_anchors(root: pathlib.Path) -> list[str]:
366 """Return every commit ID cached in any remote's tracking refs.
367
368 When pushing a new branch (or to a remote with no local tracking cache),
369 these commits are our best guess at what the remote already holds. Any
370 remote the user has previously pushed to shares commit ancestry with other
371 remotes — using all cached heads as ``have`` anchors ensures ``build_mpack``
372 only transmits the delta since the nearest shared ancestor.
373
374 Symlinks inside ``.muse/remotes/`` are skipped rather than followed to
375 prevent path-traversal attacks. Unreadable files are silently skipped
376 so a corrupted tracking ref doesn't abort the push.
377 """
378 remotes_dir = _remotes_dir(root)
379 if not remotes_dir.is_dir():
380 return []
381 heads: list[str] = []
382 for ref_file in remotes_dir.rglob("*"):
383 if ref_file.is_symlink() or not ref_file.is_file():
384 continue
385 commit_id = read_ref(ref_file)
386 if commit_id:
387 heads.append(commit_id)
388 return heads
389
390 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
391 """Register the ``muse push`` subcommand and all its flags."""
392 parser = subparsers.add_parser(
393 "push",
394 help="Upload local commits, snapshots, and objects to a remote.",
395 description=__doc__,
396 formatter_class=argparse.RawDescriptionHelpFormatter,
397 )
398 parser.add_argument(
399 "remote", nargs="?", default="origin",
400 help="Remote name to push to (default: origin).",
401 )
402 parser.add_argument(
403 "branch_pos", nargs="?", default=None, metavar="BRANCH",
404 help="Branch to push (default: current branch). Same as --branch.",
405 )
406 parser.add_argument(
407 "--branch", "-b", default=None, dest="branch_flag",
408 help="Branch to push (default: current branch).",
409 )
410 parser.add_argument(
411 "-u", "--set-upstream", action="store_true", dest="set_upstream_flag",
412 help="Record upstream tracking for this branch.",
413 )
414 parser.add_argument(
415 "--force", "-f", action="store_true",
416 help="Force push even if the remote has diverged.",
417 )
418 parser.add_argument(
419 "--force-with-lease", action="store_true", dest="force_with_lease",
420 help=(
421 "Force push only if the remote branch has not advanced since the last fetch. "
422 "Safer than --force: rejects the push if someone else has pushed in the meantime."
423 ),
424 )
425 parser.add_argument(
426 "--delete", "-d", action="store_true", dest="delete_branch",
427 help="Delete the named branch on the remote.",
428 )
429 parser.add_argument(
430 "-n", "--dry-run", action="store_true",
431 help="Compute what would be pushed without sending any data.",
432 )
433 parser.add_argument(
434 "--workers", type=int, default=16, metavar="N",
435 help="Number of parallel upload workers (default: 16).",
436 )
437 parser.add_argument(
438 "--tags", action="store_true", dest="push_tags",
439 help=(
440 "Push all local version tags to the remote after pushing commits. "
441 "Also enabled automatically when push.tags=true in config."
442 ),
443 )
444 parser.add_argument(
445 "--json", "-j", action="store_true", dest="json_out",
446 help="Emit machine-readable JSON instead of human text.",
447 )
448 parser.add_argument(
449 "--hub", default=None, metavar="URL",
450 help=(
451 "Resolve a hub URL to its configured remote name and use that remote. "
452 "Equivalent to finding the remote whose URL matches and using it as "
453 "the <remote> argument. "
454 "Example: --hub https://staging.musehub.ai"
455 ),
456 )
457 parser.set_defaults(func=run)
458
459 def run(args: argparse.Namespace) -> None:
460 """Upload local commits, snapshots, and objects to a remote.
461
462 Requires the remote to be a fast-forward of the local branch unless
463 ``--force`` is specified.
464
465 All progress and error messages go to **stderr**. ``--format json``
466 (or ``--json``) emits a single JSON object on stdout for agent pipelines.
467
468 ``--dry-run`` computes the pack that *would* be sent using **all** local
469 tracking refs as have-anchors (the same sibling-ref negotiation the live
470 push performs after its GET /refs) and exits 0 without connecting to the
471 remote. Counts equal the live push when tracking refs are fresh (i.e.
472 after a ``muse fetch``); stale tracking refs may drift.
473
474 JSON schema::
475
476 {
477 "status": "pushed | up_to_date | dry_run | deleted",
478 "remote": "<remote_name>",
479 "branch": "<branch>",
480 "head": "<sha256> | null",
481 "commits_sent": <N>,
482 "objects_sent": <N>,
483 "force": false,
484 "dry_run": false
485 }
486
487 Exit codes::
488
489 0 — success
490 1 — remote not configured, branch has no commits, push rejected,
491 authentication failure, network error, invalid format
492 """
493 elapsed = start_timer()
494 # ── --hub URL: resolve to a configured remote name ───────────────────────
495 # Agents sometimes pass --hub <url> (the muse hub subcommand pattern) to
496 # muse push by mistake. Accept it gracefully: look up the remote whose URL
497 # matches and use it, or fail with a clear, actionable message.
498 #
499 # Positional re-interpretation: when --hub is given, the <remote> positional
500 # slot is irrelevant (the remote is determined by the URL). If only one
501 # positional was provided it landed in args.remote but was intended as the
502 # branch — move it to branch_pos so it is not silently discarded.
503 json_out: bool = args.json_out
504 hub_url: str | None = getattr(args, "hub", None)
505 if hub_url is not None:
506 root_for_hub = require_repo()
507 all_remotes = list_remotes(root_for_hub)
508 matched = next(
509 (r for r in all_remotes if r["url"].rstrip("/") == hub_url.rstrip("/")),
510 None,
511 )
512 if matched is None:
513 remote_list = ", ".join(r["name"] for r in all_remotes) if all_remotes else "(none configured)"
514 if json_out:
515 print(json.dumps({"error": "remote_not_found", "hub_url": hub_url, "configured_remotes": remote_list}))
516 else:
517 print(
518 f"❌ No remote is configured for hub URL '{sanitize_display(hub_url)}'.\n"
519 f" Configured remotes: {remote_list}\n"
520 f" muse push uses remote names, not --hub URLs:\n"
521 f" muse push <remote> <branch>",
522 file=sys.stderr,
523 )
524 raise SystemExit(ExitCode.USER_ERROR)
525 # If only one positional was supplied it consumed the <remote> slot but
526 # is actually the branch name — rescue it before overwriting args.remote.
527 if args.branch_pos is None and args.remote not in {r["name"] for r in all_remotes}:
528 args.branch_pos = args.remote
529 args.remote = matched["name"]
530
531 remote: str = args.remote
532 branch: str | None = (
533 getattr(args, "branch_flag", None) or getattr(args, "branch_pos", None)
534 )
535 set_upstream_flag: bool = args.set_upstream_flag
536 force: bool = args.force
537 force_with_lease: bool = getattr(args, "force_with_lease", False)
538 if force_with_lease and not force:
539 # --force-with-lease implies force behaviour but with a safety check.
540 force = True
541 delete_branch: bool = getattr(args, "delete_branch", False)
542 dry_run: bool = getattr(args, "dry_run", False)
543
544 root = require_repo()
545
546 url = get_remote(remote, root)
547 if url is None:
548 all_remotes = list_remotes(root)
549 configured = (
550 ", ".join(r["name"] for r in all_remotes) if all_remotes else "(none)"
551 )
552 if json_out:
553 print(json.dumps({"error": "remote_not_configured", "remote": remote, "configured_remotes": configured}))
554 else:
555 print(
556 f"❌ Remote '{sanitize_display(remote)}' is not configured.\n"
557 f" Configured remotes: {configured}\n"
558 f" Add one with: muse remote add {sanitize_display(remote)} <url>",
559 file=sys.stderr,
560 )
561 raise SystemExit(ExitCode.USER_ERROR)
562
563 # ── DELETE MODE ───────────────────────────────────────────────────────────
564 if delete_branch:
565 current_branch = read_current_branch(root)
566 del_branch = branch or current_branch
567 if not del_branch:
568 if json_out:
569 print(json.dumps({"error": "no_branch_specified", "message": "specify a branch to delete: muse push <remote> --delete <branch>"}))
570 else:
571 print(
572 "❌ Specify a branch to delete: muse push <remote> --delete <branch>",
573 file=sys.stderr,
574 )
575 raise SystemExit(ExitCode.USER_ERROR)
576
577 if dry_run:
578 msg = f"Would delete {sanitize_display(remote)}/{sanitize_display(del_branch)}"
579 if json_out:
580 print(json.dumps(_PushJson(
581 **make_envelope(elapsed),
582 status="dry_run",
583 remote=remote,
584 branch=del_branch,
585 head=None,
586 commits_sent=0,
587 objects_sent=0,
588 force=force,
589 dry_run=True,
590 )))
591 else:
592 print(msg)
593 return
594
595 token = get_signing_identity(root, remote_url=url)
596 transport = make_transport(url)
597 print(
598 f"Deleting {sanitize_display(remote)}/{sanitize_display(del_branch)} …",
599 file=sys.stderr,
600 )
601 already_gone = False
602 try:
603 transport.delete_branch_remote(url, token, del_branch)
604 except TransportError as exc:
605 if exc.status_code == 404:
606 already_gone = True
607 elif exc.status_code == 409:
608 if json_out:
609 print(json.dumps({"error": "default_branch", "branch": del_branch, "message": f"cannot delete the default branch '{del_branch}'"}))
610 else:
611 print(f"❌ Cannot delete the default branch '{sanitize_display(del_branch)}'.", file=sys.stderr)
612 raise SystemExit(ExitCode.USER_ERROR)
613 elif exc.status_code == 403:
614 if json_out:
615 print(json.dumps({"error": "permission_denied", "message": "only the repo owner may delete branches"}))
616 else:
617 print("❌ Permission denied — only the repo owner may delete branches.", file=sys.stderr)
618 raise SystemExit(ExitCode.USER_ERROR)
619 else:
620 if json_out:
621 print(json.dumps({"error": "delete_failed", "message": str(exc)}))
622 else:
623 print(f"❌ Delete failed: {sanitize_display(str(exc))}", file=sys.stderr)
624 raise SystemExit(ExitCode.USER_ERROR)
625
626 pruned = delete_remote_head(remote, del_branch, root)
627 if json_out:
628 print(json.dumps(_PushJson(
629 **make_envelope(elapsed),
630 status="deleted",
631 remote=remote,
632 branch=del_branch,
633 head=None,
634 commits_sent=0,
635 objects_sent=0,
636 force=force,
637 dry_run=False,
638 )))
639 else:
640 if already_gone:
641 note = " (already absent on remote)"
642 if pruned:
643 note += ", tracking ref pruned"
644 print(f"✅ {sanitize_display(remote)}/{sanitize_display(del_branch)}{note}")
645 else:
646 prune_note = " (tracking ref pruned)" if pruned else ""
647 print(
648 f"✅ Deleted remote branch "
649 f"{sanitize_display(remote)}/{sanitize_display(del_branch)}{prune_note}"
650 )
651 return
652
653 # ── NORMAL PUSH ──────────────────────────────────────────────────────────
654 current_branch = read_current_branch(root)
655 push_branch = branch or current_branch
656
657 local_head = get_head_commit_id(root, push_branch)
658 if local_head is None:
659 if json_out:
660 print(json.dumps({"error": "no_commits", "branch": push_branch, "message": f"branch '{push_branch}' has no commits to push"}))
661 else:
662 print(f"❌ Branch '{sanitize_display(push_branch)}' has no commits to push.", file=sys.stderr)
663 raise SystemExit(ExitCode.USER_ERROR)
664
665 # ── DRY-RUN: compute pack locally, no network calls ───────────────────────
666 if dry_run:
667 # Use all local tracking-ref heads as the have anchor for BOTH the commit
668 # walk and the object dedup — the same logic the live path uses after its
669 # GET /refs (push.py:819-822). Tracking refs are the local mirror of remote
670 # state; dry-run is accurate when they are fresh (i.e. after a muse fetch).
671 # This fixes #32: the previous code used only get_remote_head(remote, push_branch)
672 # for the commit walk, which over-counted when sibling branches had already
673 # been pushed (their tracking refs were ignored as BFS boundary anchors).
674 #
675 # Do NOT filter out local_head: if a tracking ref equals local_head (e.g.
676 # remote dev IS local main, or this branch was already pushed), then
677 # walk_commits([local_head], have=[..., local_head]) prunes immediately →
678 # commits_sent=0, matching the live path's up-to-date short-circuit (#32 VL_01).
679 have: list[str] = [
680 c for c in _all_known_have_anchors(root)
681 if commit_exists(root, c)
682 ]
683 dry_walk = walk_commits(root, [local_head], have=have)
684 dry_commits = len(dry_walk["commits"])
685 dry_objects = len(collect_blob_ids(root, [local_head], have=have))
686 if json_out:
687 print(json.dumps(_PushJson(
688 **make_envelope(elapsed),
689 status="dry_run",
690 remote=remote,
691 branch=push_branch,
692 head=local_head,
693 commits_sent=dry_commits,
694 objects_sent=dry_objects,
695 force=force,
696 dry_run=True,
697 )))
698 else:
699 print(
700 f"Would push {dry_commits} commit(s) and ~{dry_objects} object(s) "
701 f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} (dry run)"
702 )
703 return
704
705 transport = make_transport(url)
706 token = get_signing_identity(root, remote_url=url)
707
708 # ── STEP 0: discover remote state ────────────────────────────────────────
709 print(f"[PUSH step 0] GET {url.rstrip('/')}/refs", file=sys.stderr)
710 try:
711 remote_info = _fetch_remote_info_safe(transport, url, token)
712 except TransportError as exc:
713 if json_out:
714 code_map = {404: "repository_not_found", 401: "auth_required"}
715 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)}))
716 elif exc.status_code == 404:
717 print(
718 f"❌ Repository not found on remote '{sanitize_display(remote)}'.\n"
719 f" Create it first: muse hub repo create --name <repo-name>\n"
720 f" Then verify the remote URL: muse remote -v",
721 file=sys.stderr,
722 )
723 elif exc.status_code == 401:
724 print(
725 f"❌ Authentication required — remote '{sanitize_display(remote)}' returned 401.\n"
726 f" Run: muse auth register",
727 file=sys.stderr,
728 )
729 else:
730 print(f"❌ Remote error ({exc.status_code}): {exc}", file=sys.stderr)
731 raise SystemExit(ExitCode.USER_ERROR)
732 if remote_info is not None:
733 remote_branch_heads = remote_info["branch_heads"]
734 candidate_have = list(remote_branch_heads.values())
735 _remote_head = remote_branch_heads.get(push_branch) or None
736 print(f"[PUSH step 0] → branch_heads = {remote_branch_heads}", file=sys.stderr)
737 print(f"[PUSH step 0] remote_head = {_remote_head or 'null'}", file=sys.stderr)
738 print(f"[PUSH step 0] have = {candidate_have}", file=sys.stderr)
739 else:
740 # live /refs unreachable (e.g. SSL failure on self-signed cert) —
741 # fall back to locally cached tracking refs for THIS remote only.
742 # Same-remote tracking refs are safe: they were written on the last
743 # successful push to this exact remote, so they represent objects
744 # the remote already holds. Cross-remote refs are NOT included.
745 remote_branch_heads = {}
746 remote_tracking_dir = _remotes_dir(root) / remote
747 candidate_have = []
748 if remote_tracking_dir.is_dir():
749 for _ref_file in remote_tracking_dir.rglob("*"):
750 if _ref_file.is_symlink() or not _ref_file.is_file():
751 continue
752 _cached_id = read_ref(_ref_file)
753 if _cached_id:
754 candidate_have.append(_cached_id)
755 # have-anchors are what the SERVER already has — not what we have locally.
756 # commit_exists() must NOT be used here: if the remote has a commit we
757 # don't have locally (e.g. after a force-push or diverged history), the
758 # local walk stops naturally when it can't traverse past a missing commit.
759 # Filtering by commit_exists() causes have=[] and re-sends the full history.
760 have = [
761 c for c in candidate_have
762 if c != local_head and _is_valid_commit_id(c)
763 ]
764
765 if remote_info is not None:
766 remote_head: str | None = remote_branch_heads.get(push_branch)
767 else:
768 remote_head = get_remote_head(remote, push_branch, root)
769
770 print(f"[PUSH step 1] walk local DAG → find commits not on remote (want - have)", file=sys.stderr)
771 print(f"[PUSH step 1] want = {local_head}", file=sys.stderr)
772 print(f"[PUSH step 1] remote_head = {remote_head or 'null'}", file=sys.stderr)
773 print(f"[PUSH step 1] have = {have}", file=sys.stderr)
774
775 # ── FORCE-WITH-LEASE safety check ────────────────────────────────────────
776 # Reject the push if the live remote HEAD has advanced beyond what we last
777 # fetched — i.e., someone else pushed after our last fetch/pull.
778 if force_with_lease and remote_head is not None:
779 cached_head = get_remote_head(remote, push_branch, root)
780 if cached_head != remote_head:
781 if json_out:
782 print(json.dumps({"error": "force_with_lease_rejected", "remote": remote, "branch": push_branch, "message": "remote has advanced since last fetch — run muse fetch first"}))
783 else:
784 print(
785 f"❌ Push rejected (--force-with-lease): remote "
786 f"'{sanitize_display(remote)}/{sanitize_display(push_branch)}' has advanced "
787 f"since your last fetch.\n"
788 f" Run 'muse fetch' to update your tracking refs, then retry.",
789 file=sys.stderr,
790 )
791 raise SystemExit(ExitCode.USER_ERROR)
792
793 if remote_head is None:
794 print(f"[PUSH step 1] remote_head is null → new_commits = all commits reachable from local tip", file=sys.stderr)
795 elif remote_head == local_head:
796 print(f"[PUSH step 1] remote_head == local_tip → nothing to push", file=sys.stderr)
797 else:
798 print(f"[PUSH step 1] new_commits = commits reachable from local tip, not reachable from remote_head", file=sys.stderr)
799
800 # ── OPTIONAL: push version tags ──────────────────────────────────────────
801 # Enabled by --tags flag or push.tags=true in config. Resolved and acted
802 # on here — BEFORE the "up to date" early return below — because tags can
803 # need syncing even when the branch itself has no new commits to send
804 # (e.g. tagging a commit that was already pushed earlier).
805 push_tags_flag: bool = getattr(args, "push_tags", False)
806 if not push_tags_flag:
807 try:
808 cfg_val = get_config_value("push.tags", root)
809 push_tags_flag = (cfg_val or "").lower() in ("true", "1", "yes")
810 except Exception:
811 pass
812
813 tags_pushed = 0
814 tags_skipped = 0
815 if push_tags_flag:
816 local_tags = list_version_tags(root)
817 if local_tags:
818 tag_dicts = [
819 {
820 "tag_id": r.tag_id,
821 "repo_id": r.repo_id,
822 "tag": r.tag,
823 "semver": {
824 "major": r.semver["major"],
825 "minor": r.semver["minor"],
826 "patch": r.semver["patch"],
827 "pre": r.semver["pre"],
828 "build": r.semver["build"],
829 },
830 "commit_id": r.commit_id,
831 "created_at": r.created_at.isoformat(),
832 "author": r.author,
833 "message": r.message,
834 }
835 for r in local_tags
836 ]
837 try:
838 tag_result = transport.push_version_tags(url, token, tag_dicts, force=force)
839 tags_pushed = tag_result.get("stored", 0)
840 tags_skipped = tag_result.get("skipped", 0)
841 except TransportError as exc:
842 logger.warning("Version tag push failed: %s", exc)
843
844 if remote_head == local_head:
845 if push_tags_flag:
846 if json_out:
847 print(json.dumps(_PushWithTagsJson(
848 **make_envelope(elapsed),
849 status="up_to_date",
850 remote=remote,
851 branch=push_branch,
852 head=local_head,
853 commits_sent=0,
854 objects_sent=0,
855 force=force,
856 dry_run=False,
857 tags_pushed=tags_pushed,
858 tags_skipped=tags_skipped,
859 )))
860 else:
861 print(
862 f"Everything up to date. "
863 f"Remote {sanitize_display(remote)}/{sanitize_display(push_branch)} "
864 f"is already at {local_head}."
865 )
866 if tags_pushed or tags_skipped:
867 print(f" Tags: {tags_pushed} pushed, {tags_skipped} already current")
868 else:
869 if json_out:
870 print(json.dumps(_PushJson(
871 **make_envelope(elapsed),
872 status="up_to_date",
873 remote=remote,
874 branch=push_branch,
875 head=local_head,
876 commits_sent=0,
877 objects_sent=0,
878 force=force,
879 dry_run=False,
880 )))
881 else:
882 print(
883 f"Everything up to date. "
884 f"Remote {sanitize_display(remote)}/{sanitize_display(push_branch)} "
885 f"is already at {local_head}."
886 )
887 return
888
889 # `branch_have` is the commit-graph boundary for build_mpack. Use ALL
890 # known remote branch heads unconditionally so the BFS stops at any commit
891 # the server already holds — regardless of which branch it lives on.
892 #
893 # When pushing a second branch (e.g. dev after main), the remote has no
894 # head for dev yet, so the old code set branch_have=[] and walked the
895 # entire DAG back to root, re-sending every commit already on main. Using
896 # all remote heads fixes this: the BFS stops at main's head and only sends
897 # the dev-only commits above it.
898 branch_have: list[str] = [
899 h for h in remote_branch_heads.values()
900 if _is_valid_commit_id(h)
901 ]
902
903 try:
904 result, commits_sent, objects_sent = _push_mpack(
905 transport, url, token, root, local_head, have, push_branch, force,
906 branch_have=branch_have,
907 )
908 except TransportError as exc:
909 if json_out:
910 code_map = {404: "repo_not_found", 401: "auth_required", 409: "diverged"}
911 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)}))
912 elif exc.status_code == 404:
913 print(
914 f"❌ Repository not found on remote '{sanitize_display(remote)}'.\n"
915 f" The remote server returned 404 for this repo.\n"
916 f"\n"
917 f" If this is a new repo, create it on the server first:\n"
918 f" muse hub repo create (if using MuseHub)\n"
919 f"\n"
920 f" If the repo exists, verify your remote URL:\n"
921 f" muse remote -v\n"
922 f"\n"
923 f" If you are authenticated, run: muse auth whoami",
924 file=sys.stderr,
925 )
926 elif exc.status_code == 401:
927 print(
928 f"❌ Authentication required — remote '{sanitize_display(remote)}' returned 401.\n"
929 f" Log in first: muse auth register",
930 file=sys.stderr,
931 )
932 elif exc.status_code == 409:
933 print(
934 f"❌ Push rejected — remote "
935 f"'{sanitize_display(remote)}/{sanitize_display(push_branch)}' has diverged.\n"
936 f" Pull first (muse pull) or use --force to override.",
937 file=sys.stderr,
938 )
939 else:
940 print(f"❌ Push failed: {sanitize_display(str(exc))}", file=sys.stderr)
941 raise SystemExit(ExitCode.USER_ERROR)
942
943 if not result["ok"]:
944 if json_out:
945 print(json.dumps({"error": "push_rejected", "remote": remote, "branch": push_branch, "message": result["message"]}))
946 else:
947 print(f"❌ Push rejected by remote: {sanitize_display(str(result['message']))}", file=sys.stderr)
948 raise SystemExit(ExitCode.USER_ERROR)
949
950 updated_head = result["branch_heads"].get(push_branch, local_head)
951 set_remote_head(remote, push_branch, updated_head, root)
952
953 if set_upstream_flag:
954 set_upstream(push_branch, remote, root)
955 print(
956 f" Upstream set: {sanitize_display(push_branch)} → "
957 f"{sanitize_display(remote)}/{sanitize_display(push_branch)}",
958 file=sys.stderr,
959 )
960
961 # push_tags_flag / tags_pushed / tags_skipped were already resolved and
962 # acted on above, before the "up to date" early return — not repeated
963 # here, since calling transport.push_version_tags() twice would
964 # double-push every tag on a normal (non-up-to-date) push.
965 if push_tags_flag:
966 if json_out:
967 print(json.dumps(_PushWithTagsJson(
968 **make_envelope(elapsed),
969 status="pushed",
970 remote=remote,
971 branch=push_branch,
972 head=updated_head,
973 commits_sent=commits_sent,
974 objects_sent=objects_sent,
975 force=force,
976 dry_run=False,
977 tags_pushed=tags_pushed,
978 tags_skipped=tags_skipped,
979 )))
980 else:
981 print(
982 f"✅ Pushed {commits_sent} commit(s), {objects_sent} object(s) "
983 f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} "
984 f"({updated_head})"
985 )
986 if tags_pushed or tags_skipped:
987 print(f" Tags: {tags_pushed} pushed, {tags_skipped} already current")
988 else:
989 if json_out:
990 print(json.dumps(_PushJson(
991 **make_envelope(elapsed),
992 status="pushed",
993 remote=remote,
994 branch=push_branch,
995 head=updated_head,
996 commits_sent=commits_sent,
997 objects_sent=objects_sent,
998 force=force,
999 dry_run=False,
1000 )))
1001 else:
1002 print(
1003 f"✅ Pushed {commits_sent} commit(s), {objects_sent} object(s) "
1004 f"to {sanitize_display(remote)}/{sanitize_display(push_branch)} "
1005 f"({updated_head})"
1006 )
1007 # probe
1008 # 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