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