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