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