push.py
python
| 1 | """muse push — upload local commits to the configured remote Muse Hub. |
| 2 | |
| 3 | Push algorithm |
| 4 | -------------- |
| 5 | 1. Resolve repo root and read ``repo_id`` from ``.muse/repo.json``. |
| 6 | 2. Read current branch from ``.muse/HEAD``. |
| 7 | 3. Read local branch HEAD commit ID from ``.muse/refs/heads/<branch>``. |
| 8 | Exits 1 if the branch has no commits. |
| 9 | 4. Resolve ``origin`` URL from ``.muse/config.toml``. |
| 10 | Exits 1 with an instructive message if no remote is configured. |
| 11 | 5. Read last known remote HEAD from ``.muse/remotes/origin/<branch>`` |
| 12 | (may not exist on first push). |
| 13 | 6. Query Postgres for all commits on the branch; compute the delta since |
| 14 | the last known remote HEAD (or all commits if no prior push). |
| 15 | 7. Build :class:`~maestro.muse_cli.hub_client.PushRequest` payload. |
| 16 | 8. POST to ``<remote_url>/push`` with Bearer auth. |
| 17 | 9. On success, update ``.muse/remotes/origin/<branch>`` to the new HEAD. |
| 18 | If ``--set-upstream`` was given, record the upstream tracking in config. |
| 19 | |
| 20 | Flags |
| 21 | ----- |
| 22 | - ``--force / -f``: overwrite remote branch even on non-fast-forward. |
| 23 | - ``--force-with-lease``: overwrite only if remote HEAD matches the last |
| 24 | known local tracking pointer (safer than ``--force``; the Hub must reject |
| 25 | if the remote has advanced since we last fetched). |
| 26 | - ``--tags``: push all VCS-style tag refs from ``.muse/refs/tags/`` alongside |
| 27 | the branch commits. |
| 28 | - ``--set-upstream / -u``: after a successful push, record the remote as the |
| 29 | upstream for this branch in ``.muse/config.toml``. |
| 30 | |
| 31 | Exit codes: |
| 32 | 0 — success |
| 33 | 1 — user error (no remote, no commits, bad args, force-with-lease mismatch) |
| 34 | 2 — not a Muse repository |
| 35 | 3 — network / server error |
| 36 | """ |
| 37 | from __future__ import annotations |
| 38 | |
| 39 | import asyncio |
| 40 | import json |
| 41 | import logging |
| 42 | import pathlib |
| 43 | |
| 44 | import httpx |
| 45 | import typer |
| 46 | |
| 47 | from maestro.muse_cli._repo import require_repo |
| 48 | from maestro.muse_cli.config import ( |
| 49 | get_remote, |
| 50 | get_remote_head, |
| 51 | set_remote_head, |
| 52 | set_upstream, |
| 53 | ) |
| 54 | from maestro.muse_cli.db import get_commits_for_branch, get_all_object_ids, open_session |
| 55 | from maestro.muse_cli.errors import ExitCode |
| 56 | from maestro.muse_cli.hub_client import ( |
| 57 | MuseHubClient, |
| 58 | PushCommitPayload, |
| 59 | PushObjectPayload, |
| 60 | PushRequest, |
| 61 | PushTagPayload, |
| 62 | ) |
| 63 | from maestro.muse_cli.models import MuseCliCommit |
| 64 | |
| 65 | logger = logging.getLogger(__name__) |
| 66 | |
| 67 | app = typer.Typer() |
| 68 | |
| 69 | _NO_REMOTE_MSG = ( |
| 70 | "No remote named 'origin'. " |
| 71 | "Run `muse remote add origin <url>` to configure one." |
| 72 | ) |
| 73 | |
| 74 | |
| 75 | # --------------------------------------------------------------------------- |
| 76 | # Push delta helper |
| 77 | # --------------------------------------------------------------------------- |
| 78 | |
| 79 | |
| 80 | def _compute_push_delta( |
| 81 | commits: list[MuseCliCommit], |
| 82 | remote_head: str | None, |
| 83 | ) -> list[MuseCliCommit]: |
| 84 | """Return the commits that are missing from the remote. |
| 85 | |
| 86 | *commits* is the full branch history (newest-first from the DB query). |
| 87 | If *remote_head* is ``None`` (first push), all commits are included. |
| 88 | |
| 89 | We include every commit from the local HEAD down to—but not including—the |
| 90 | known remote HEAD. The list is returned in chronological order (oldest |
| 91 | first) so the Hub can apply them in ancestry order. |
| 92 | """ |
| 93 | if not commits: |
| 94 | return [] |
| 95 | if remote_head is None: |
| 96 | # First push — send all commits, chronological order |
| 97 | return list(reversed(commits)) |
| 98 | |
| 99 | # Walk from newest to oldest; stop when we hit the remote head |
| 100 | delta: list[MuseCliCommit] = [] |
| 101 | for commit in commits: |
| 102 | if commit.commit_id == remote_head: |
| 103 | break |
| 104 | delta.append(commit) |
| 105 | |
| 106 | # Return chronological order (oldest first) |
| 107 | return list(reversed(delta)) |
| 108 | |
| 109 | |
| 110 | def _collect_tag_refs(root: pathlib.Path) -> list[PushTagPayload]: |
| 111 | """Enumerate VCS-style tag refs from ``.muse/refs/tags/``. |
| 112 | |
| 113 | Each file under ``.muse/refs/tags/`` is a lightweight tag: the filename |
| 114 | is the tag name and the file content is the commit ID it points to. |
| 115 | |
| 116 | Returns an empty list when the directory does not exist or contains no |
| 117 | readable tag files. |
| 118 | |
| 119 | Args: |
| 120 | root: Repository root path. |
| 121 | |
| 122 | Returns: |
| 123 | List of :class:`PushTagPayload` dicts, one per tag file found. |
| 124 | """ |
| 125 | tags_dir = root / ".muse" / "refs" / "tags" |
| 126 | if not tags_dir.is_dir(): |
| 127 | return [] |
| 128 | |
| 129 | payloads: list[PushTagPayload] = [] |
| 130 | for tag_file in sorted(tags_dir.iterdir()): |
| 131 | if not tag_file.is_file(): |
| 132 | continue |
| 133 | commit_id = tag_file.read_text(encoding="utf-8").strip() |
| 134 | if commit_id: |
| 135 | payloads.append(PushTagPayload(tag_name=tag_file.name, commit_id=commit_id)) |
| 136 | |
| 137 | return payloads |
| 138 | |
| 139 | |
| 140 | def _build_push_request( |
| 141 | branch: str, |
| 142 | head_commit_id: str, |
| 143 | delta: list[MuseCliCommit], |
| 144 | all_object_ids: list[str], |
| 145 | *, |
| 146 | force: bool = False, |
| 147 | force_with_lease: bool = False, |
| 148 | expected_remote_head: str | None = None, |
| 149 | tag_payloads: list[PushTagPayload] | None = None, |
| 150 | ) -> PushRequest: |
| 151 | """Serialize the push payload from local ORM objects. |
| 152 | |
| 153 | ``objects`` includes all object IDs known to this repo so the Hub can |
| 154 | store references even if it already has the blobs (deduplication is the |
| 155 | Hub's responsibility). |
| 156 | |
| 157 | When ``force_with_lease`` is ``True``, ``expected_remote_head`` is the |
| 158 | commit ID we believe the remote HEAD to be. The Hub must reject the push |
| 159 | if its current HEAD differs. |
| 160 | |
| 161 | Args: |
| 162 | branch: Branch name being pushed. |
| 163 | head_commit_id: Local branch HEAD commit ID. |
| 164 | delta: Commits not yet on the remote (oldest-first). |
| 165 | all_object_ids: All known object IDs in this repo. |
| 166 | force: If ``True``, allow non-fast-forward overwrite. |
| 167 | force_with_lease: If ``True``, include expected remote HEAD for |
| 168 | lease-based safety check. |
| 169 | expected_remote_head: Commit ID the caller believes the remote HEAD |
| 170 | to be (used with ``force_with_lease``). |
| 171 | tag_payloads: VCS tag refs to include (from ``--tags``). |
| 172 | |
| 173 | Returns: |
| 174 | A :class:`PushRequest` TypedDict ready to be JSON-serialised. |
| 175 | """ |
| 176 | commits: list[PushCommitPayload] = [ |
| 177 | PushCommitPayload( |
| 178 | commit_id=c.commit_id, |
| 179 | parent_commit_id=c.parent_commit_id, |
| 180 | snapshot_id=c.snapshot_id, |
| 181 | branch=c.branch, |
| 182 | message=c.message, |
| 183 | author=c.author, |
| 184 | committed_at=c.committed_at.isoformat(), |
| 185 | metadata=dict(c.commit_metadata) if c.commit_metadata else None, |
| 186 | ) |
| 187 | for c in delta |
| 188 | ] |
| 189 | |
| 190 | objects: list[PushObjectPayload] = [ |
| 191 | PushObjectPayload(object_id=oid, size_bytes=0) |
| 192 | for oid in all_object_ids |
| 193 | ] |
| 194 | |
| 195 | request = PushRequest( |
| 196 | branch=branch, |
| 197 | head_commit_id=head_commit_id, |
| 198 | commits=commits, |
| 199 | objects=objects, |
| 200 | ) |
| 201 | |
| 202 | if force: |
| 203 | request["force"] = True |
| 204 | if force_with_lease: |
| 205 | request["force_with_lease"] = True |
| 206 | request["expected_remote_head"] = expected_remote_head |
| 207 | if tag_payloads: |
| 208 | request["tags"] = tag_payloads |
| 209 | |
| 210 | return request |
| 211 | |
| 212 | |
| 213 | # --------------------------------------------------------------------------- |
| 214 | # Async push core |
| 215 | # --------------------------------------------------------------------------- |
| 216 | |
| 217 | |
| 218 | async def _push_async( |
| 219 | *, |
| 220 | root: pathlib.Path, |
| 221 | remote_name: str, |
| 222 | branch: str | None, |
| 223 | force: bool = False, |
| 224 | force_with_lease: bool = False, |
| 225 | include_tags: bool = False, |
| 226 | set_upstream_flag: bool = False, |
| 227 | ) -> None: |
| 228 | """Execute the push pipeline. |
| 229 | |
| 230 | Raises :class:`typer.Exit` with the appropriate code on all error paths |
| 231 | so the Typer callback surfaces clean messages instead of tracebacks. |
| 232 | |
| 233 | When ``force_with_lease`` is ``True`` and the Hub returns HTTP 409 |
| 234 | (conflict), the push is rejected because the remote has advanced beyond |
| 235 | our last-known tracking pointer — the user must fetch and retry. |
| 236 | |
| 237 | When ``set_upstream_flag`` is ``True``, a successful push writes the |
| 238 | upstream tracking entry to ``.muse/config.toml``. |
| 239 | """ |
| 240 | muse_dir = root / ".muse" |
| 241 | |
| 242 | # ── Repo identity ──────────────────────────────────────────────────── |
| 243 | repo_data: dict[str, str] = json.loads((muse_dir / "repo.json").read_text()) |
| 244 | repo_id = repo_data["repo_id"] |
| 245 | |
| 246 | # ── Branch resolution ──────────────────────────────────────────────── |
| 247 | head_ref = (muse_dir / "HEAD").read_text().strip() |
| 248 | effective_branch = branch or head_ref.rsplit("/", 1)[-1] |
| 249 | ref_path = muse_dir / "refs" / "heads" / effective_branch |
| 250 | |
| 251 | if not ref_path.exists() or not ref_path.read_text().strip(): |
| 252 | typer.echo(f"❌ Branch '{effective_branch}' has no commits. Run `muse commit` first.") |
| 253 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 254 | |
| 255 | head_commit_id = ref_path.read_text().strip() |
| 256 | |
| 257 | # ── Remote URL ─────────────────────────────────────────────────────── |
| 258 | remote_url = get_remote(remote_name, root) |
| 259 | if not remote_url: |
| 260 | typer.echo(_NO_REMOTE_MSG) |
| 261 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 262 | |
| 263 | # ── Known remote head ──────────────────────────────────────────────── |
| 264 | remote_head = get_remote_head(remote_name, effective_branch, root) |
| 265 | |
| 266 | # ── Build push payload ─────────────────────────────────────────────── |
| 267 | async with open_session() as session: |
| 268 | commits = await get_commits_for_branch(session, repo_id, effective_branch) |
| 269 | all_object_ids = await get_all_object_ids(session, repo_id) |
| 270 | |
| 271 | delta = _compute_push_delta(commits, remote_head) |
| 272 | |
| 273 | if not delta and remote_head == head_commit_id and not include_tags: |
| 274 | typer.echo(f"✅ Everything up to date — {remote_name}/{effective_branch} is current.") |
| 275 | return |
| 276 | |
| 277 | # ── Collect tag refs if requested ──────────────────────────────────── |
| 278 | tag_payloads = _collect_tag_refs(root) if include_tags else [] |
| 279 | |
| 280 | payload = _build_push_request( |
| 281 | branch=effective_branch, |
| 282 | head_commit_id=head_commit_id, |
| 283 | delta=delta, |
| 284 | all_object_ids=all_object_ids, |
| 285 | force=force, |
| 286 | force_with_lease=force_with_lease, |
| 287 | expected_remote_head=remote_head if force_with_lease else None, |
| 288 | tag_payloads=tag_payloads if tag_payloads else None, |
| 289 | ) |
| 290 | |
| 291 | extra_flags = [] |
| 292 | if force: |
| 293 | extra_flags.append("--force") |
| 294 | elif force_with_lease: |
| 295 | extra_flags.append("--force-with-lease") |
| 296 | if include_tags and tag_payloads: |
| 297 | extra_flags.append(f"--tags ({len(tag_payloads)} tag(s))") |
| 298 | |
| 299 | flags_desc = f" [{', '.join(extra_flags)}]" if extra_flags else "" |
| 300 | typer.echo( |
| 301 | f"⬆️ Pushing {len(delta)} commit(s) to {remote_name}/{effective_branch}{flags_desc} …" |
| 302 | ) |
| 303 | |
| 304 | # ── HTTP push ──────────────────────────────────────────────────────── |
| 305 | try: |
| 306 | async with MuseHubClient(base_url=remote_url, repo_root=root) as hub: |
| 307 | response = await hub.post("/push", json=payload) |
| 308 | |
| 309 | if response.status_code == 200: |
| 310 | set_remote_head(remote_name, effective_branch, head_commit_id, root) |
| 311 | if set_upstream_flag: |
| 312 | set_upstream(effective_branch, remote_name, root) |
| 313 | typer.echo( |
| 314 | f"✅ Branch '{effective_branch}' set to track '{remote_name}/{effective_branch}'" |
| 315 | ) |
| 316 | typer.echo( |
| 317 | f"✅ Pushed {len(delta)} commit(s) → " |
| 318 | f"{remote_name}/{effective_branch} [{head_commit_id[:8]}]" |
| 319 | ) |
| 320 | logger.info( |
| 321 | "✅ muse push %s → %s/%s [%s] (%d commits)", |
| 322 | repo_id, |
| 323 | remote_name, |
| 324 | effective_branch, |
| 325 | head_commit_id[:8], |
| 326 | len(delta), |
| 327 | ) |
| 328 | elif response.status_code == 409 and force_with_lease: |
| 329 | typer.echo( |
| 330 | f"❌ Push rejected: remote {remote_name}/{effective_branch} has advanced " |
| 331 | f"since last fetch. Run `muse pull` then retry, or use `--force` to override." |
| 332 | ) |
| 333 | logger.warning( |
| 334 | "⚠️ muse push --force-with-lease rejected: remote has advanced beyond %s", |
| 335 | remote_head[:8] if remote_head else "None", |
| 336 | ) |
| 337 | raise typer.Exit(code=int(ExitCode.USER_ERROR)) |
| 338 | else: |
| 339 | typer.echo( |
| 340 | f"❌ Hub rejected push (HTTP {response.status_code}): {response.text}" |
| 341 | ) |
| 342 | logger.error( |
| 343 | "❌ muse push failed: HTTP %d — %s", |
| 344 | response.status_code, |
| 345 | response.text, |
| 346 | ) |
| 347 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 348 | |
| 349 | except typer.Exit: |
| 350 | raise |
| 351 | except httpx.TimeoutException: |
| 352 | typer.echo(f"❌ Push timed out connecting to {remote_url}") |
| 353 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 354 | except httpx.HTTPError as exc: |
| 355 | typer.echo(f"❌ Network error during push: {exc}") |
| 356 | logger.error("❌ muse push network error: %s", exc, exc_info=True) |
| 357 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |
| 358 | |
| 359 | |
| 360 | # --------------------------------------------------------------------------- |
| 361 | # Typer command |
| 362 | # --------------------------------------------------------------------------- |
| 363 | |
| 364 | |
| 365 | @app.callback(invoke_without_command=True) |
| 366 | def push( |
| 367 | ctx: typer.Context, |
| 368 | branch: str | None = typer.Option( |
| 369 | None, |
| 370 | "--branch", |
| 371 | "-b", |
| 372 | help="Branch to push. Defaults to the current branch.", |
| 373 | ), |
| 374 | remote: str = typer.Option( |
| 375 | "origin", |
| 376 | "--remote", |
| 377 | help="Remote name to push to.", |
| 378 | ), |
| 379 | force: bool = typer.Option( |
| 380 | False, |
| 381 | "--force", |
| 382 | "-f", |
| 383 | help=( |
| 384 | "Overwrite the remote branch even if the push is non-fast-forward. " |
| 385 | "Use with caution — this discards remote history. " |
| 386 | "Prefer --force-with-lease for a safer alternative." |
| 387 | ), |
| 388 | ), |
| 389 | force_with_lease: bool = typer.Option( |
| 390 | False, |
| 391 | "--force-with-lease", |
| 392 | help=( |
| 393 | "Overwrite the remote branch only if its current HEAD matches the " |
| 394 | "last commit we fetched from it. Safer than --force because it " |
| 395 | "prevents overwriting commits pushed by others after our last fetch." |
| 396 | ), |
| 397 | ), |
| 398 | tags: bool = typer.Option( |
| 399 | False, |
| 400 | "--tags", |
| 401 | help=( |
| 402 | "Push all VCS-style tag refs from .muse/refs/tags/ alongside the " |
| 403 | "branch commits. Tags are lightweight refs (filename = tag name, " |
| 404 | "content = commit ID)." |
| 405 | ), |
| 406 | ), |
| 407 | set_upstream: bool = typer.Option( |
| 408 | False, |
| 409 | "--set-upstream", |
| 410 | "-u", |
| 411 | help=( |
| 412 | "After a successful push, record this remote as the upstream for " |
| 413 | "the current branch in .muse/config.toml. Subsequent push/pull " |
| 414 | "commands can then default to this remote." |
| 415 | ), |
| 416 | ), |
| 417 | ) -> None: |
| 418 | """Push local commits to the configured remote Muse Hub. |
| 419 | |
| 420 | Sends commits that the remote does not yet have, then updates the local |
| 421 | remote-tracking pointer (``.muse/remotes/<remote>/<branch>``). |
| 422 | |
| 423 | Example:: |
| 424 | |
| 425 | muse push |
| 426 | muse push --branch feature/groove-v2 |
| 427 | muse push --remote staging |
| 428 | muse push --force-with-lease |
| 429 | muse push --set-upstream origin main |
| 430 | muse push --tags |
| 431 | """ |
| 432 | root = require_repo() |
| 433 | |
| 434 | try: |
| 435 | asyncio.run( |
| 436 | _push_async( |
| 437 | root=root, |
| 438 | remote_name=remote, |
| 439 | branch=branch, |
| 440 | force=force, |
| 441 | force_with_lease=force_with_lease, |
| 442 | include_tags=tags, |
| 443 | set_upstream_flag=set_upstream, |
| 444 | ) |
| 445 | ) |
| 446 | except typer.Exit: |
| 447 | raise |
| 448 | except Exception as exc: |
| 449 | typer.echo(f"❌ muse push failed: {exc}") |
| 450 | logger.error("❌ muse push unexpected error: %s", exc, exc_info=True) |
| 451 | raise typer.Exit(code=int(ExitCode.INTERNAL_ERROR)) |