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