cgcardona / muse public
push.py python
451 lines 16.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
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))