gabriel / musehub public
test_issue_61_clone_after_push.py python
270 lines 11.9 KB
Raw
sha256:4992098130166d191cefed0a2821d19cd3cdd3cf50867a4e715c2b30636826c7 fix: repair syntax errors from typing annotation cleanup Sonnet 4.6 49 days ago
1 """Subprocess E2E — issue #61: clone after push from copytree (MWP-6, Phase 5).
2
3 Root-cause history:
4 The original bug: musehub_commits used commit_id as its sole PK. When Repo A
5 pushed commit sha256:X, the row was stored once. When Repo B pushed the same
6 commit, ON CONFLICT DO NOTHING skipped the insert — no repo-scoped ref was
7 written for Repo B. The fetch BFS for Repo B found zero commits and returned
8 an empty mpack.
9
10 Fix (MWP-1 + schema evolution):
11 - MusehubCommit now has commit_id as a *global* PK with no repo_id column.
12 Commits are shared content-addressed objects across all repos.
13 - MusehubCommitRef (composite PK: repo_id, commit_id) is the per-repo
14 reachability index. The push path writes MusehubCommitRef rows for ALL
15 commits in the mpack (not just newly inserted ones), so cross-repo pushes
16 always create the per-repo ref row.
17 - The fetch BFS (_walk_commit_delta) queries MusehubCommitGraph (keyed only
18 by commit_id, no repo_id filter) and falls back to the authoritative
19 MusehubCommit DAG walk (also no repo_id filter). Both paths find commits
20 regardless of which repo first stored them.
21
22 Contracts verified here:
23 - muse remote remove <name> purges all tracking refs for that remote
24 - After muse push from a copytree'd repo, the server holds the objects
25 - muse clone immediately after such a push succeeds
26 - Cross-repo dedup: Repo B is cloneable after the same commits land in Repo A
27
28 Prerequisites:
29 Requires a live musehub container at https://localhost:1337.
30 Start it with: docker restart musehub && sleep 5 && curl -sk https://localhost:1337/healthz
31 Muse auth must be configured: muse auth whoami
32
33 Run:
34 python3 -m pytest tests/test_issue_61_clone_after_push.py -v -m slow
35
36 Repro sequence (mirrors _bench_fetch_or_pull exactly):
37 shutil.copytree(seed) → remote remove → remote add <new-url> → push → clone
38 """
39 from __future__ import annotations
40
41 import json
42 import shutil
43 import subprocess
44 import tempfile
45 from pathlib import Path
46
47 import pytest
48
49 pytestmark = [pytest.mark.wire, pytest.mark.slow]
50
51 HUB = "https://localhost:1337"
52 REPO_ROOT = Path(__file__).parent.parent
53
54
55 # ── helpers ───────────────────────────────────────────────────────────────────
56
57 def muse(*args: str, cwd: Path, timeout: int = 60) -> subprocess.CompletedProcess:
58 return subprocess.run(
59 ["muse"] + list(args),
60 cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
61 )
62
63
64 def muse_check(*args: str, cwd: Path, timeout: int = 60) -> str:
65 r = muse(*args, cwd=cwd, timeout=timeout)
66 if r.returncode != 0:
67 raise RuntimeError(f"muse {' '.join(args)} failed:\n{r.stderr[:400]}")
68 return r.stdout
69
70
71 # ── fixtures ──────────────────────────────────────────────────────────────────
72
73 @pytest.fixture
74 def seed_repo(tmp_path: Path) -> Path:
75 """A minimal muse repo with one commit — no remotes configured."""
76 repo = tmp_path / "seed"
77 repo.mkdir()
78 muse_check("init", cwd=repo)
79 (repo / "words.txt").write_text(
80 "abandon ability able about above absent absorb abstract absurd abuse\n" * 20
81 )
82 muse_check("code", "add", ".", cwd=repo)
83 muse_check(
84 "commit", "-m", "initial",
85 "--agent-id", "test", "--model-id", "test",
86 cwd=repo,
87 )
88 return repo
89
90
91 @pytest.fixture
92 def hub_repo(tmp_path: Path) -> None:
93 """Create a fresh hub repo, yield its full slug, delete after the test."""
94 name = f"test-issue-61-probe-{tmp_path.name[-6:]}"
95 out = muse_check(
96 "hub", "repo", "create", "--name", name,
97 "--visibility", "public", "--no-init", "--hub", HUB, "--json",
98 cwd=REPO_ROOT,
99 )
100 slug = f"gabriel/{json.loads(out)['slug']}"
101 yield slug
102 muse("hub", "repo", "delete", slug, "--yes", "--hub", HUB, "--json", cwd=REPO_ROOT)
103
104
105 # ── Phase 2 pre-req: tracking ref hygiene ────────────────────────────────────
106
107 class TestRemoteRemoveClearsTrackingRefs:
108 def test_tracking_ref_absent_before_push(self, seed_repo: Path, hub_repo: str) -> None:
109 """Baseline: no tracking refs exist before any remote is configured."""
110 remotes_dir = seed_repo / ".muse" / "remotes"
111 assert not remotes_dir.exists() or not any(remotes_dir.iterdir()), (
112 "fresh repo must have no tracking refs"
113 )
114
115 def test_push_creates_tracking_ref(self, seed_repo: Path, hub_repo: str) -> None:
116 """After push, a tracking ref for origin/main must exist."""
117 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo}", cwd=seed_repo)
118 muse_check("push", "origin", "main", cwd=seed_repo)
119
120 tracking_ref = seed_repo / ".muse" / "remotes" / "origin" / "main"
121 assert tracking_ref.exists(), (
122 "push must write a tracking ref at .muse/remotes/origin/main"
123 )
124
125 def test_remote_remove_clears_tracking_refs(self, seed_repo: Path, hub_repo: str) -> None:
126 """After `muse remote remove origin`, the tracking ref directory must be gone."""
127 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo}", cwd=seed_repo)
128 muse_check("push", "origin", "main", cwd=seed_repo)
129 muse_check("remote", "remove", "origin", cwd=seed_repo)
130
131 tracking_dir = seed_repo / ".muse" / "remotes" / "origin"
132 assert not tracking_dir.exists(), (
133 "`muse remote remove origin` must delete .muse/remotes/origin/ — "
134 "stale tracking refs cause push from copytree to send 0 objects"
135 )
136
137
138 # ── Phase 1: server-side object storage ──────────────────────────────────────
139
140 class TestCloneAfterPushFromCopytree:
141 """The core bug: clone after push from a shutil.copytree'd repo returns empty mpack."""
142
143 def _push_from_copy(self, seed_repo: Path, hub_repo: str, tmp_path: Path) -> Path:
144 """Copy seed, wire new remote, push. Returns the copy path."""
145 copy = tmp_path / "copy"
146 shutil.copytree(str(seed_repo), str(copy), symlinks=False)
147 muse("remote", "remove", "origin", cwd=copy) # no-op if absent; ignore rc
148 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo}", cwd=copy)
149 muse_check("push", "origin", "main", cwd=copy)
150 return copy
151
152 def test_push_from_copytree_exits_zero(
153 self, seed_repo: Path, hub_repo: str, tmp_path: Path
154 ) -> None:
155 """Push from a copytree'd repo must exit 0."""
156 copy = tmp_path / "copy"
157 shutil.copytree(str(seed_repo), str(copy), symlinks=False)
158 muse("remote", "remove", "origin", cwd=copy)
159 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo}", cwd=copy)
160 r = muse("push", "origin", "main", cwd=copy)
161 assert r.returncode == 0, f"push from copytree must exit 0:\n{r.stderr}"
162
163 def test_server_has_branch_after_push_from_copytree(
164 self, seed_repo: Path, hub_repo: str, tmp_path: Path
165 ) -> None:
166 """After push, ls-remote must report a non-null main branch on the server."""
167 self._push_from_copy(seed_repo, hub_repo, tmp_path)
168
169 # Use a throw-away local repo to run ls-remote — avoids polluting seed
170 probe = tmp_path / "probe"
171 probe.mkdir()
172 muse_check("init", cwd=probe)
173 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo}", cwd=probe)
174 out = muse_check("ls-remote", "origin", "--json", cwd=probe)
175 branches = json.loads(out).get("branches", {})
176 assert "main" in branches and branches["main"], (
177 f"server must have a main branch after push from copytree — "
178 f"ls-remote returned: {branches}"
179 )
180
181 def test_clone_after_push_from_copytree_succeeds(
182 self, seed_repo: Path, hub_repo: str, tmp_path: Path
183 ) -> None:
184 """Clone immediately after push from copytree must exit 0."""
185 self._push_from_copy(seed_repo, hub_repo, tmp_path)
186
187 clone_dir = tmp_path / "clone"
188 clone_dir.mkdir()
189 r = muse("clone", f"{HUB}/{hub_repo}", cwd=clone_dir)
190 assert r.returncode == 0, (
191 f"clone after push from copytree must succeed — got empty mpack:\n{r.stderr}"
192 )
193
194 def test_cloned_repo_has_correct_commit_count(
195 self, seed_repo: Path, hub_repo: str, tmp_path: Path
196 ) -> None:
197 """The cloned repo must have the same number of commits as the source."""
198 self._push_from_copy(seed_repo, hub_repo, tmp_path)
199
200 clone_dir = tmp_path / "clone"
201 clone_dir.mkdir()
202 muse_check("clone", f"{HUB}/{hub_repo}", cwd=clone_dir)
203
204 slug_name = hub_repo.split("/")[-1]
205 cloned = clone_dir / slug_name
206
207 src_commits = json.loads(muse_check("log", "--json", cwd=seed_repo))["commits"]
208 clone_commits = json.loads(muse_check("log", "--json", cwd=cloned))["commits"]
209 assert len(clone_commits) == len(src_commits), (
210 f"clone must have {len(src_commits)} commit(s), got {len(clone_commits)}"
211 )
212
213
214 # ── Root cause: commit dedup across repos ────────────────────────────────────
215
216 class TestCommitDedupAcrossRepos:
217 """Proves the cross-repo dedup fix: Repo B is cloneable after the same commits land in Repo A.
218
219 musehub_commits uses commit_id as a global PK (no repo_id column).
220 MusehubCommitRef (composite PK: repo_id, commit_id) is the per-repo
221 reachability index. The push path writes MusehubCommitRef rows for ALL
222 commits in the mpack, so cross-repo pushes always create the per-repo ref.
223 The fetch BFS queries MusehubCommitGraph (no repo_id filter) and finds
224 commits regardless of which repo first stored them.
225 """
226
227 @pytest.fixture
228 def hub_repo_b(self, tmp_path: Path) -> None:
229 """A second hub repo for the cross-repo dedup test."""
230 name = f"test-issue-61-repo-b-{tmp_path.name[-6:]}"
231 out = muse_check(
232 "hub", "repo", "create", "--name", name,
233 "--visibility", "public", "--no-init", "--hub", HUB, "--json",
234 cwd=REPO_ROOT,
235 )
236 slug = f"gabriel/{json.loads(out)['slug']}"
237 yield slug
238 muse("hub", "repo", "delete", slug, "--yes", "--hub", HUB, "--json", cwd=REPO_ROOT)
239
240 def test_clone_second_repo_after_same_commits_pushed_to_first(
241 self, seed_repo: Path, hub_repo: str, hub_repo_b: str, tmp_path: Path
242 ) -> None:
243 """Push identical commits to two repos — both must be cloneable.
244
245 This is the exact bench scenario:
246 bench-seed-xs ← pushed first (Repo A)
247 bench-fetch-xs-0-xxx ← pushed second with same content (Repo B)
248
249 Repo B clone fails because musehub_commits stores the commit with
250 repo_id=A and the fetch BFS filters WHERE repo_id=B.
251 """
252 # Push to Repo A first (simulates ensure_hub_seed / bench-seed-xs)
253 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo}", cwd=seed_repo)
254 muse_check("push", "origin", "main", cwd=seed_repo)
255
256 # Push same commits to Repo B (simulates the bench fetch/pull run repo)
257 copy = tmp_path / "copy"
258 shutil.copytree(str(seed_repo), str(copy), symlinks=False)
259 muse("remote", "remove", "origin", cwd=copy)
260 muse_check("remote", "add", "origin", f"{HUB}/{hub_repo_b}", cwd=copy)
261 muse_check("push", "origin", "main", cwd=copy)
262
263 # Clone Repo B — this is the failing case
264 clone_dir = tmp_path / "clone"
265 clone_dir.mkdir()
266 r = muse("clone", f"{HUB}/{hub_repo_b}", cwd=clone_dir)
267 assert r.returncode == 0, (
268 f"clone of Repo B must succeed when Repo A already holds the same commits.\n"
269 f"stderr: {r.stderr}"
270 )
File History 2 commits
sha256:4992098130166d191cefed0a2821d19cd3cdd3cf50867a4e715c2b30636826c7 fix: repair syntax errors from typing annotation cleanup Sonnet 4.6 49 days ago
sha256:ef10830ce231e0a20efcb0e2586cb879471247e916616e6fdd0d51df459e2595 fix: typing audit — 0 violations, 0 untyped defs across all… Sonnet 4.6 minor 49 days ago