test_issue_61_clone_after_push.py
python
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