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