gabriel / muse public
test_wire_localhost.py python
795 lines 29.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 124 days ago
1 """Muse Wire Protocol — end-to-end localhost integration tests.
2
3 Requires ``https://localhost:1337`` to be running with gabriel's identity
4 registered. All tests are auto-skipped when the hub is not reachable.
5
6 The local hub uses a self-signed TLS cert (deploy/local-tls/) so all urllib
7 calls use an unverified SSL context and all httpx calls pass verify=False.
8
9 Coverage
10 --------
11 T1 Hub health + auth — whoami round-trip
12 T2 Repo lifecycle — create, list, delete hub repo
13 T3 Push (cold) — initial push of local commits to a fresh hub repo
14 T4 Clone — clone a pushed repo, verify snapshot equality
15 T5 Incremental push — push new commits, only delta transferred
16 T6 Pull — push from location A, pull from B, verify merge result
17 T7 Fetch — fetch from remote, objects arrive, local HEAD unchanged
18 T8 Force push — divergent history accepted with --force
19 T9 Cross-repo — multi-file repo (contracts-style) full push/clone cycle
20 T10 Idempotent re-push — re-push same commits, 0 new objects stored
21 """
22 from __future__ import annotations
23
24 import datetime
25 import itertools
26 import json
27 import pathlib
28 import time
29 import urllib.error
30 import urllib.request
31 from collections.abc import Mapping
32 from typing import TYPE_CHECKING, TypedDict
33
34 if TYPE_CHECKING:
35 from muse.core.transport import SigningIdentity
36
37 import ssl
38
39 import pytest
40
41
42 # Unverified SSL context for the self-signed localhost cert.
43 _SSL_NOVERIFY = ssl.create_default_context()
44 _SSL_NOVERIFY.check_hostname = False
45 _SSL_NOVERIFY.verify_mode = ssl.CERT_NONE
46
47
48 class _HubRepo(TypedDict):
49 repo_id: str
50 slug: str
51 url: str
52
53 from muse._version import __version__
54 from muse.cli.config import get_signing_identity
55 from muse.core.msign import build_msign_header
56 from muse.core.object_store import write_object
57 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
58 from muse.core.store import (
59 CommitRecord,
60 SnapshotRecord,
61 get_head_commit_id,
62 read_commit,
63 read_snapshot,
64 write_commit,
65 write_snapshot,
66 )
67 from tests.cli_test_helper import CliRunner
68 from muse.core.types import blob_id, content_hash
69 from muse.core.paths import heads_dir, muse_dir, ref_path
70
71 _id_seq = itertools.count()
72
73
74 def _new_id() -> str:
75 return content_hash({"seq": next(_id_seq)})
76
77 # ---------------------------------------------------------------------------
78 # Constants
79 # ---------------------------------------------------------------------------
80
81 HUB = "https://localhost:1337"
82 OWNER = "gabriel"
83
84 runner = CliRunner()
85
86
87 # ---------------------------------------------------------------------------
88 # Hub availability guard — skip entire module if hub not reachable
89 # ---------------------------------------------------------------------------
90
91 def _hub_reachable() -> bool:
92 try:
93 urllib.request.urlopen(f"{HUB}/healthz", timeout=2, context=_SSL_NOVERIFY)
94 return True
95 except (urllib.error.URLError, OSError):
96 return False
97
98
99 def _identity_registered() -> bool:
100 """Return True only if hub is reachable AND gabriel's identity is registered."""
101 if not _hub_reachable():
102 return False
103 from muse.cli.config import get_signing_identity
104 from muse.core.msign import build_msign_header
105 signing = get_signing_identity(remote_url=HUB)
106 if signing is None:
107 return False
108 url = f"{HUB}/api/identities/{signing.handle}"
109 auth = build_msign_header(signing, "GET", url, None)
110 req = urllib.request.Request(url, headers={"Authorization": auth, "Accept": "application/json"})
111 try:
112 urllib.request.urlopen(req, timeout=5, context=_SSL_NOVERIFY)
113 return True
114 except (urllib.error.URLError, urllib.error.HTTPError, OSError):
115 return False
116
117
118 pytestmark = pytest.mark.skipif(
119 not _identity_registered(),
120 reason="localhost hub not reachable or identity not registered — run: muse auth register",
121 )
122
123
124 # ---------------------------------------------------------------------------
125 # Auth helper
126 # ---------------------------------------------------------------------------
127
128 def _signing() -> "SigningIdentity":
129 """Return gabriel's signing identity for localhost."""
130 signing = get_signing_identity(remote_url=HUB)
131 if signing is None:
132 pytest.skip("No signing identity for localhost hub")
133 return signing
134
135
136 def _hub_request(method: str, path: str, body: Mapping[str, object] | None = None) -> Mapping[str, object]:
137 """Make a signed API request to the local hub. Returns parsed JSON."""
138 signing = _signing()
139 url = f"{HUB}{path}"
140 data: bytes | None = None
141 if body is not None:
142 data = json.dumps(body).encode()
143 auth = build_msign_header(signing, method, url, data)
144 headers: dict[str, str] = {
145 "Authorization": auth,
146 "Accept": "application/json",
147 }
148 if data is not None:
149 headers["Content-Type"] = "application/json"
150 req = urllib.request.Request(url, data=data, headers=headers, method=method)
151 try:
152 with urllib.request.urlopen(req, timeout=15, context=_SSL_NOVERIFY) as resp:
153 body = resp.read()
154 return json.loads(body) if body.strip() else {}
155 except urllib.error.HTTPError as exc:
156 raw = exc.read().decode(errors="replace")
157 if exc.code in (401, 403) or (exc.code == 404 and "identity not found" in raw):
158 pytest.skip("Identity not registered on localhost hub — run: muse auth register")
159 pytest.fail(f"Hub request failed: {method} {path} → {exc.code}: {raw}")
160
161
162 # ---------------------------------------------------------------------------
163 # Hub repo lifecycle fixture
164 # ---------------------------------------------------------------------------
165
166 @pytest.fixture
167 def hub_repo() -> _HubRepo:
168 """Create a private test hub repo; delete it after the test."""
169 slug = f"test-wire-{_new_id()[7:15]}"
170 resp = _hub_request("POST", "/api/repos", {
171 "name": slug,
172 "owner": OWNER,
173 "visibility": "private",
174 "domain": "code",
175 })
176 repo_id: str = resp["repoId"]
177 yield {"repo_id": repo_id, "slug": slug, "url": f"{HUB}/{OWNER}/{slug}"}
178 # Cleanup — tolerate 404 if test already deleted it
179 try:
180 _hub_request("DELETE", f"/api/repos/{repo_id}")
181 except BaseException:
182 pass
183
184
185 # ---------------------------------------------------------------------------
186 # Local repo builder
187 # ---------------------------------------------------------------------------
188
189 def _init_local_repo(
190 root: pathlib.Path,
191 hub_slug: str,
192 *,
193 branch: str = "main",
194 n_commits: int = 1,
195 file_tree: dict[str, bytes] | None = None,
196 ) -> list[str]:
197 """Initialise a .muse/ repo with N commits; return commit IDs oldest-first.
198
199 ``file_tree`` maps path → content for the first commit. Subsequent
200 commits each add/update a single generated file.
201 """
202 dot_muse = muse_dir(root)
203 for sub in ("refs/heads", "objects", "commits", "snapshots"):
204 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
205
206 (dot_muse / "repo.json").write_text(json.dumps({
207 "repo_id": f"test-{hub_slug}",
208 "schema_version": __version__,
209 "domain": "code",
210 }))
211 (dot_muse / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
212 (dot_muse / "config.toml").write_text(
213 f'[remotes.local]\nurl = "{HUB}/{OWNER}/{hub_slug}"\n'
214 )
215
216 if file_tree is None:
217 file_tree = {f"file_{_new_id()[7:13]}.txt": b"initial content"}
218
219 commit_ids: list[str] = []
220 manifest: dict[str, str] = {}
221
222 # First commit — full file tree
223 for path, content in file_tree.items():
224 oid = blob_id(content)
225 write_object(root, oid, content)
226 manifest[path] = oid
227
228 snap_id = compute_snapshot_id(manifest)
229 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=dict(manifest)))
230 now = datetime.datetime.now(tz=datetime.timezone.utc)
231 cid = compute_commit_id( parent_ids=[],
232 snapshot_id=snap_id,
233 message="initial commit",
234 committed_at_iso=now.isoformat(),
235 author=OWNER,)
236 write_commit(root, CommitRecord(
237 commit_id=cid,
238 branch=branch,
239 snapshot_id=snap_id,
240 message="initial commit",
241 committed_at=now,
242 author=OWNER,
243 ))
244 commit_ids.append(cid)
245 parent_ids = [cid]
246
247 # Additional commits
248 for i in range(1, n_commits):
249 extra = f"extra_{i}_{_new_id()[7:11]}.txt"
250 content = f"commit {i} content".encode()
251 oid = blob_id(content)
252 write_object(root, oid, content)
253 manifest = dict(manifest)
254 manifest[extra] = oid
255 snap_id = compute_snapshot_id(manifest)
256 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=dict(manifest)))
257 now = datetime.datetime.now(tz=datetime.timezone.utc)
258 cid = compute_commit_id( parent_ids=parent_ids,
259 snapshot_id=snap_id,
260 message=f"commit {i}",
261 committed_at_iso=now.isoformat(),
262 author=OWNER,)
263 write_commit(root, CommitRecord(
264 commit_id=cid,
265 branch=branch,
266 snapshot_id=snap_id,
267 message=f"commit {i}",
268 committed_at=now,
269 author=OWNER,
270 parent_commit_id=parent_ids[0] if len(parent_ids) == 1 else None,
271 parent2_commit_id=parent_ids[1] if len(parent_ids) > 1 else None,
272 ))
273 commit_ids.append(cid)
274 parent_ids = [cid]
275
276 (dot_muse / "refs" / "heads" / branch).write_text(commit_ids[-1])
277 return commit_ids
278
279
280 def _add_commit(root: pathlib.Path, hub_slug: str, branch: str = "main") -> str:
281 """Append one more commit to an existing local repo. Returns new commit ID."""
282 from muse.core.store import read_snapshot as _read_snap
283 parent_cid = get_head_commit_id(root, branch)
284 parent_rec = read_commit(root, parent_cid)
285 parent_snap = _read_snap(root, parent_rec.snapshot_id)
286 manifest = dict(parent_snap.manifest) if parent_snap else {}
287
288 extra = f"extra_{_new_id()[7:13]}.txt"
289 content = f"added: {extra}".encode()
290 oid = blob_id(content)
291 write_object(root, oid, content)
292 manifest[extra] = oid
293
294 snap_id = compute_snapshot_id(manifest)
295 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=dict(manifest)))
296 now = datetime.datetime.now(tz=datetime.timezone.utc)
297 cid = compute_commit_id( parent_ids=[parent_cid],
298 snapshot_id=snap_id,
299 message=f"new commit {extra}",
300 committed_at_iso=now.isoformat(),
301 author=OWNER,)
302 write_commit(root, CommitRecord(
303 commit_id=cid,
304 branch=branch,
305 snapshot_id=snap_id,
306 message=f"new commit {extra}",
307 committed_at=now,
308 author=OWNER,
309 parent_commit_id=parent_cid,
310 ))
311 (ref_path(root, branch)).write_text(cid)
312 return cid
313
314
315 # ---------------------------------------------------------------------------
316 # T1 — Hub health + auth
317 # ---------------------------------------------------------------------------
318
319 class TestT1Auth:
320 """Tier 1: hub is up, gabriel's identity round-trips."""
321
322 def test_healthz(self) -> None:
323 resp = urllib.request.urlopen(f"{HUB}/healthz", timeout=5, context=_SSL_NOVERIFY)
324 data = json.loads(resp.read())
325 assert data["status"] == "ok"
326 assert data["db"] is True
327
328 def test_whoami(self) -> None:
329 """Signed GET to /api/identities/{handle} returns gabriel's handle."""
330 data = _hub_request("GET", f"/api/identities/{OWNER}")
331 assert data.get("handle") == OWNER or data.get("owner") == OWNER or OWNER in str(data)
332
333
334 # ---------------------------------------------------------------------------
335 # T2 — Repo lifecycle
336 # ---------------------------------------------------------------------------
337
338 class TestT2RepoLifecycle:
339 """Tier 2: create, verify presence, delete a hub repo."""
340
341 def test_create_and_list(self, hub_repo: _HubRepo) -> None:
342 slug = hub_repo["slug"]
343 data = _hub_request("GET", f"/{OWNER}/{slug}/refs")
344 assert "branch_heads" in data or "branches" in data or data is not None
345
346 def test_repo_id_is_sha256(self, hub_repo: _HubRepo) -> None:
347 repo_id = hub_repo["repo_id"]
348 assert repo_id.startswith("sha256:"), (
349 f"repo_id should be sha256-addressed, got: {repo_id!r}"
350 )
351
352 def test_delete_returns_no_content(self, hub_repo: _HubRepo) -> None:
353 """Explicit delete — fixture cleanup would also cover this, but verify 204."""
354 signing = _signing()
355 url = f"{HUB}/api/repos/{hub_repo['repo_id']}"
356 auth = build_msign_header(signing, "DELETE", url, None)
357 req = urllib.request.Request(
358 url, headers={"Authorization": auth, "Accept": "application/json"}, method="DELETE"
359 )
360 try:
361 with urllib.request.urlopen(req, timeout=10, context=_SSL_NOVERIFY) as resp:
362 assert resp.status == 204
363 except urllib.error.HTTPError as exc:
364 if exc.code == 204:
365 pass # urllib raises on 204, treat as success
366 else:
367 pytest.fail(f"Delete failed: {exc.code} {exc.read()}")
368 # Fixture cleanup will get a 404 — that's fine, it tolerates it
369
370
371 # ---------------------------------------------------------------------------
372 # T3 — Push (cold)
373 # ---------------------------------------------------------------------------
374
375 class TestT3ColdPush:
376 """Tier 3: push a local repo to a fresh hub repo."""
377
378 def test_initial_push_succeeds(
379 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
380 ) -> None:
381 root = tmp_path / "local"
382 root.mkdir()
383 commit_ids = _init_local_repo(root, hub_repo["slug"], n_commits=3)
384 monkeypatch.chdir(root)
385
386 result = runner.invoke(None, ["push", "local", "main"])
387 assert result.exit_code == 0, f"push failed:\n{result.output}\n{result.stderr}"
388 assert "Pushed" in result.output or "✅" in result.output
389
390 def test_push_reports_commit_count(
391 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
392 ) -> None:
393 root = tmp_path / "local"
394 root.mkdir()
395 _init_local_repo(root, hub_repo["slug"], n_commits=5)
396 monkeypatch.chdir(root)
397
398 result = runner.invoke(None, ["push", "local", "main"])
399 assert result.exit_code == 0
400 # Output should mention commit count
401 output = result.output + (result.stderr or "")
402 assert any(c.isdigit() for c in output), "Expected numeric output (commit/object counts)"
403
404 def test_push_empty_repo_succeeds(
405 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
406 ) -> None:
407 """Even a single-commit repo pushes cleanly."""
408 root = tmp_path / "local"
409 root.mkdir()
410 _init_local_repo(root, hub_repo["slug"], n_commits=1)
411 monkeypatch.chdir(root)
412
413 result = runner.invoke(None, ["push", "local", "main"])
414 assert result.exit_code == 0
415
416
417 # ---------------------------------------------------------------------------
418 # T4 — Clone
419 # ---------------------------------------------------------------------------
420
421 class TestT4Clone:
422 """Tier 4: clone a pushed repo and verify snapshot equality."""
423
424 def test_clone_restores_snapshot(
425 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
426 ) -> None:
427 # Push from location A
428 src = tmp_path / "source"
429 src.mkdir()
430 commit_ids = _init_local_repo(
431 src, hub_repo["slug"], n_commits=2,
432 file_tree={
433 "README.md": b"# Test repo",
434 "src/main.py": b"print('hello')",
435 }
436 )
437 monkeypatch.chdir(src)
438 push_result = runner.invoke(None, ["push", "local", "main"])
439 assert push_result.exit_code == 0, push_result.output
440
441 # Clone to location B
442 dst = tmp_path / "clone"
443 result = runner.invoke(None, ["clone", hub_repo["url"], str(dst)])
444 assert result.exit_code == 0, f"clone failed:\n{result.output}\n{result.stderr}"
445 assert dst.exists(), "Clone directory not created"
446
447 # Verify HEAD commit matches
448 cloned_head = get_head_commit_id(dst, "main")
449 assert cloned_head == commit_ids[-1], (
450 f"Cloned HEAD {cloned_head} != expected {commit_ids[-1]}"
451 )
452
453 def test_clone_restores_file_objects(
454 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
455 ) -> None:
456 file_content = b"unique content for object verification wire-test"
457 oid = blob_id(file_content)
458
459 src = tmp_path / "source"
460 src.mkdir()
461 _init_local_repo(src, hub_repo["slug"], file_tree={"data.bin": file_content})
462 monkeypatch.chdir(src)
463 runner.invoke(None, ["push", "local", "main"])
464
465 dst = tmp_path / "clone"
466 result = runner.invoke(None, ["clone", hub_repo["url"], str(dst)])
467 assert result.exit_code == 0
468
469 # Verify object content was transferred
470 from muse.core.object_store import read_object
471 cloned_obj = read_object(dst, oid)
472 assert cloned_obj == file_content
473
474
475 # ---------------------------------------------------------------------------
476 # T5 — Incremental push
477 # ---------------------------------------------------------------------------
478
479 class TestT5IncrementalPush:
480 """Tier 5: second push transfers only new objects."""
481
482 def test_incremental_push_succeeds(
483 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
484 ) -> None:
485 root = tmp_path / "local"
486 root.mkdir()
487 _init_local_repo(root, hub_repo["slug"], n_commits=2)
488 monkeypatch.chdir(root)
489
490 # First push
491 r1 = runner.invoke(None, ["push", "local", "main"])
492 assert r1.exit_code == 0, r1.output
493
494 # Add a commit
495 new_cid = _add_commit(root, hub_repo["slug"])
496
497 # Second push — should succeed with fewer objects
498 r2 = runner.invoke(None, ["push", "local", "main"])
499 assert r2.exit_code == 0, r2.output
500 output = r2.output + (r2.stderr or "")
501 assert "Pushed" in output or "✅" in output
502
503 def test_incremental_push_head_advances(
504 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
505 ) -> None:
506 root = tmp_path / "local"
507 root.mkdir()
508 _init_local_repo(root, hub_repo["slug"], n_commits=1)
509 monkeypatch.chdir(root)
510 runner.invoke(None, ["push", "local", "main"])
511
512 new_cid = _add_commit(root, hub_repo["slug"])
513 r2 = runner.invoke(None, ["push", "local", "main"])
514 assert r2.exit_code == 0
515
516 # Verify hub refs report the new head
517 refs_data = _hub_request("GET", f"/{OWNER}/{hub_repo['slug']}/refs")
518 branch_heads = refs_data.get("branch_heads", refs_data.get("branches", {}))
519 assert "main" in branch_heads
520 # Head should now be the new commit (or its sha256: prefixed form)
521 hub_head = branch_heads["main"]
522 assert new_cid.lstrip("sha256:") in hub_head or hub_head in new_cid
523
524
525 # ---------------------------------------------------------------------------
526 # T6 — Pull
527 # ---------------------------------------------------------------------------
528
529 class TestT6Pull:
530 """Tier 6: push from A, pull from B, verify merge result."""
531
532 def test_pull_updates_local_head(
533 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
534 ) -> None:
535 # Push initial commits from location A
536 src = tmp_path / "A"
537 src.mkdir()
538 commit_ids = _init_local_repo(src, hub_repo["slug"], n_commits=2)
539 monkeypatch.chdir(src)
540 runner.invoke(None, ["push", "local", "main"])
541
542 # Clone to location B
543 dst = tmp_path / "B"
544 r_clone = runner.invoke(None, ["clone", hub_repo["url"], str(dst)])
545 assert r_clone.exit_code == 0, r_clone.output
546
547 # Push a new commit from A
548 monkeypatch.chdir(src)
549 new_cid = _add_commit(src, hub_repo["slug"])
550 runner.invoke(None, ["push", "local", "main"])
551
552 # Pull from B (clone creates remote named 'origin')
553 monkeypatch.chdir(dst)
554 r_pull = runner.invoke(None, ["pull", "origin", "main"])
555 assert r_pull.exit_code == 0, f"pull failed:\n{r_pull.output}\n{r_pull.stderr}"
556
557 # B's HEAD should now match A's HEAD
558 b_head = get_head_commit_id(dst, "main")
559 assert b_head == new_cid, f"Pull did not advance B's HEAD: {b_head} != {new_cid}"
560
561
562 # ---------------------------------------------------------------------------
563 # T7 — Fetch
564 # ---------------------------------------------------------------------------
565
566 class TestT7Fetch:
567 """Tier 7: fetch downloads objects but does not move local HEAD."""
568
569 def test_fetch_does_not_move_head(
570 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
571 ) -> None:
572 # Push initial from A
573 src = tmp_path / "A"
574 src.mkdir()
575 commit_ids = _init_local_repo(src, hub_repo["slug"], n_commits=1)
576 monkeypatch.chdir(src)
577 runner.invoke(None, ["push", "local", "main"])
578
579 # Clone to B — B now has commit_ids[-1] as HEAD
580 dst = tmp_path / "B"
581 r_clone = runner.invoke(None, ["clone", hub_repo["url"], str(dst)])
582 assert r_clone.exit_code == 0
583
584 b_head_before = get_head_commit_id(dst, "main")
585
586 # Push new commit from A
587 monkeypatch.chdir(src)
588 _add_commit(src, hub_repo["slug"])
589 runner.invoke(None, ["push", "local", "main"])
590
591 # Fetch from B — should NOT move main HEAD (clone creates remote 'origin')
592 monkeypatch.chdir(dst)
593 r_fetch = runner.invoke(None, ["fetch", "origin"])
594 assert r_fetch.exit_code == 0, f"fetch failed:\n{r_fetch.output}\n{r_fetch.stderr}"
595
596 b_head_after = get_head_commit_id(dst, "main")
597 assert b_head_after == b_head_before, (
598 f"fetch must not advance HEAD: was {b_head_before}, now {b_head_after}"
599 )
600
601
602 # ---------------------------------------------------------------------------
603 # T8 — Force push
604 # ---------------------------------------------------------------------------
605
606 class TestT8ForcePush:
607 """Tier 8: divergent history rejected by default, accepted with --force."""
608
609 def test_non_fast_forward_rejected(
610 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
611 ) -> None:
612 # Push 2 commits
613 src = tmp_path / "local"
614 src.mkdir()
615 commit_ids = _init_local_repo(src, hub_repo["slug"], n_commits=2)
616 monkeypatch.chdir(src)
617 runner.invoke(None, ["push", "local", "main"])
618
619 # Rewind HEAD to first commit (create divergent history)
620 head_ref = heads_dir(src) / "main"
621 head_ref.write_text(commit_ids[0])
622
623 # Try normal push — should fail (not fast-forward)
624 r = runner.invoke(None, ["push", "local", "main"])
625 # Either exit code non-zero OR output contains rejection message
626 assert r.exit_code != 0 or "not fast-forward" in (r.output + (r.stderr or "")).lower()
627
628 def test_force_push_accepted(
629 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
630 ) -> None:
631 # Push 2 commits
632 src = tmp_path / "local"
633 src.mkdir()
634 commit_ids = _init_local_repo(src, hub_repo["slug"], n_commits=2)
635 monkeypatch.chdir(src)
636 runner.invoke(None, ["push", "local", "main"])
637
638 # Rewind to first commit and add a divergent commit
639 head_ref = heads_dir(src) / "main"
640 head_ref.write_text(commit_ids[0])
641 _add_commit(src, hub_repo["slug"])
642
643 # Force push
644 r = runner.invoke(None, ["push", "local", "main", "--force"])
645 assert r.exit_code == 0, f"force push failed:\n{r.output}\n{r.stderr}"
646
647
648 # ---------------------------------------------------------------------------
649 # T9 — Cross-repo (contracts-style multi-file repo)
650 # ---------------------------------------------------------------------------
651
652 class TestT9CrossRepo:
653 """Tier 9: rich multi-file repo — push, clone, pull full cycle."""
654
655 def test_multi_file_repo_round_trip(
656 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
657 ) -> None:
658 # Build a contracts-style repo with many files
659 file_tree = {
660 "README.md": b"# contracts\nShared type contracts.",
661 "muse_contracts/__init__.py": b"",
662 "muse_contracts/wire.py": b"from dataclasses import dataclass\n",
663 "muse_contracts/issue.py": b"from typing import TypedDict\n",
664 "docs/reference/type-contracts.md": b"# Type Contracts\n",
665 "scripts/gen_type_contracts.py": b"#!/usr/bin/env python3\n",
666 "pyproject.toml": b"[tool.poetry]\nname = 'muse-contracts'\n",
667 }
668 src = tmp_path / "contracts"
669 src.mkdir()
670 commit_ids = _init_local_repo(
671 src, hub_repo["slug"], n_commits=1, file_tree=file_tree
672 )
673 monkeypatch.chdir(src)
674
675 r_push = runner.invoke(None, ["push", "local", "main"])
676 assert r_push.exit_code == 0, r_push.output
677
678 dst = tmp_path / "contracts_clone"
679 r_clone = runner.invoke(None, ["clone", hub_repo["url"], str(dst)])
680 assert r_clone.exit_code == 0, r_clone.output
681
682 # Verify all file objects transferred
683 from muse.core.object_store import read_object
684 cloned_head = get_head_commit_id(dst, "main")
685 cloned_commit = read_commit(dst, cloned_head)
686 cloned_snap = read_snapshot(dst, cloned_commit.snapshot_id)
687 assert cloned_snap is not None
688
689 for path, content in file_tree.items():
690 expected_oid = blob_id(content)
691 assert path in cloned_snap.manifest, f"Missing file in cloned snapshot: {path}"
692 assert cloned_snap.manifest[path] == expected_oid
693 cloned_bytes = read_object(dst, expected_oid)
694 assert cloned_bytes == content, f"Content mismatch for {path}"
695
696 def test_multi_commit_pull_from_cross_repo(
697 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
698 ) -> None:
699 """Push 3 commits, clone, push 2 more, pull — verify all 5 commits."""
700 src = tmp_path / "src"
701 src.mkdir()
702 commit_ids = _init_local_repo(src, hub_repo["slug"], n_commits=3)
703 monkeypatch.chdir(src)
704 runner.invoke(None, ["push", "local", "main"])
705
706 dst = tmp_path / "dst"
707 runner.invoke(None, ["clone", hub_repo["url"], str(dst)])
708
709 monkeypatch.chdir(src)
710 new1 = _add_commit(src, hub_repo["slug"])
711 new2 = _add_commit(src, hub_repo["slug"])
712 runner.invoke(None, ["push", "local", "main"])
713
714 monkeypatch.chdir(dst)
715 r = runner.invoke(None, ["pull", "origin", "main"])
716 assert r.exit_code == 0, r.output
717
718 # Walk commit chain — should have 5 commits total
719 head = get_head_commit_id(dst, "main")
720 assert head == new2
721
722 seen = []
723 cid = head
724 while cid:
725 rec = read_commit(dst, cid)
726 seen.append(cid)
727 cid = rec.parent_commit_id if rec else None
728 assert len(seen) == 5, f"Expected 5 commits in chain, got {len(seen)}"
729
730
731 # ---------------------------------------------------------------------------
732 # T10 — Idempotent re-push
733 # ---------------------------------------------------------------------------
734
735 class TestT10IdempotentPush:
736 """Tier 10: re-pushing the same commits transfers 0 new objects."""
737
738 def test_idempotent_push_zero_new_objects(
739 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
740 ) -> None:
741 root = tmp_path / "local"
742 root.mkdir()
743 _init_local_repo(root, hub_repo["slug"], n_commits=3)
744 monkeypatch.chdir(root)
745
746 # First push
747 r1 = runner.invoke(None, ["push", "local", "main"])
748 assert r1.exit_code == 0, r1.output
749
750 # Second push — identical history, nothing new
751 r2 = runner.invoke(None, ["push", "local", "main"])
752 assert r2.exit_code == 0, r2.output
753
754 output = r2.output + (r2.stderr or "")
755 # Remote should report nothing new to push
756 assert (
757 "already present" in output
758 or "0 commit" in output
759 or "✅" in output
760 or "up to date" in output.lower()
761 or "already at" in output.lower()
762 )
763
764 def test_nuke_and_repush(
765 self, tmp_path: pathlib.Path, hub_repo: _HubRepo, monkeypatch: pytest.MonkeyPatch
766 ) -> None:
767 """Delete the hub repo, recreate it, re-push — full idempotency check."""
768 root = tmp_path / "local"
769 root.mkdir()
770 commit_ids = _init_local_repo(root, hub_repo["slug"], n_commits=4)
771 monkeypatch.chdir(root)
772
773 # First push
774 r1 = runner.invoke(None, ["push", "local", "main"])
775 assert r1.exit_code == 0
776
777 # Nuke the hub repo
778 _hub_request("DELETE", f"/api/repos/{hub_repo['repo_id']}")
779
780 # Recreate with same slug
781 resp = _hub_request("POST", "/api/repos", {
782 "name": hub_repo["slug"],
783 "owner": OWNER,
784 "visibility": "private",
785 "domain": "code",
786 })
787 hub_repo["repo_id"] = resp["repoId"] # update for fixture cleanup
788
789 # Re-push same local commits
790 r2 = runner.invoke(None, ["push", "local", "main"])
791 assert r2.exit_code == 0, f"re-push after nuke failed:\n{r2.output}\n{r2.stderr}"
792
793 # Verify all commits landed correctly
794 head = get_head_commit_id(root, "main")
795 assert head == commit_ids[-1]
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 124 days ago