test_local_file_transport.py
python
| 1 | """Comprehensive tests for LocalFileTransport — unit, integration, security, stress. |
| 2 | |
| 3 | Coverage matrix |
| 4 | --------------- |
| 5 | Unit |
| 6 | _repo_root : valid URL → resolved path; bad scheme; missing .muse/ |
| 7 | fetch_remote_info : reads repo.json + branch heads |
| 8 | fetch_pack : delegates to build_pack with correct args |
| 9 | push_pack : fast-forward check; force flag; ref write; result shape |
| 10 | make_transport : file:// → LocalFileTransport; https:// → HttpTransport |
| 11 | |
| 12 | Integration (two real repos on disk) |
| 13 | push from A → B via file:// |
| 14 | pull-equivalent: fetch_remote_info + fetch_pack from B after push |
| 15 | round-trip: push A→B, verify B branch heads, then fetch B→A-mirror |
| 16 | |
| 17 | Security |
| 18 | _repo_root with symlink target that has no .muse/ is rejected |
| 19 | push_pack with path-traversal branch name is rejected |
| 20 | push_pack with null-byte branch name is rejected |
| 21 | push_pack with non-fast-forward is rejected unless force=True |
| 22 | |
| 23 | Stress |
| 24 | push bundle with 50 commits and 200 objects |
| 25 | """ |
| 26 | |
| 27 | from __future__ import annotations |
| 28 | |
| 29 | import base64 |
| 30 | import datetime |
| 31 | import hashlib |
| 32 | import json |
| 33 | import os |
| 34 | import pathlib |
| 35 | |
| 36 | import pytest |
| 37 | |
| 38 | from muse._version import __version__ |
| 39 | from muse.core.object_store import write_object |
| 40 | from muse.core.pack import PackBundle, RemoteInfo |
| 41 | from muse.core.store import ( |
| 42 | CommitRecord, |
| 43 | SnapshotRecord, |
| 44 | get_all_branch_heads, |
| 45 | get_head_commit_id, |
| 46 | read_commit, |
| 47 | write_commit, |
| 48 | write_snapshot, |
| 49 | ) |
| 50 | from muse.core.transport import ( |
| 51 | HttpTransport, |
| 52 | LocalFileTransport, |
| 53 | TransportError, |
| 54 | make_transport, |
| 55 | ) |
| 56 | |
| 57 | |
| 58 | # --------------------------------------------------------------------------- |
| 59 | # Helpers |
| 60 | # --------------------------------------------------------------------------- |
| 61 | |
| 62 | |
| 63 | def _sha(b: bytes) -> str: |
| 64 | return hashlib.sha256(b).hexdigest() |
| 65 | |
| 66 | |
| 67 | def _make_repo(path: pathlib.Path, branch: str = "main") -> pathlib.Path: |
| 68 | """Create a minimal initialised Muse repo at *path*.""" |
| 69 | muse = path / ".muse" |
| 70 | (muse / "refs" / "heads").mkdir(parents=True) |
| 71 | (muse / "objects").mkdir() |
| 72 | (muse / "commits").mkdir() |
| 73 | (muse / "snapshots").mkdir() |
| 74 | (muse / "repo.json").write_text( |
| 75 | json.dumps({"repo_id": f"repo-{path.name}", "schema_version": __version__, "domain": "midi", "default_branch": branch}) |
| 76 | ) |
| 77 | (muse / "HEAD").write_text(f"ref: refs/heads/{branch}\n") |
| 78 | return path |
| 79 | |
| 80 | |
| 81 | def _add_commit( |
| 82 | root: pathlib.Path, |
| 83 | commit_id: str, |
| 84 | branch: str = "main", |
| 85 | parent: str | None = None, |
| 86 | content: bytes = b"hello", |
| 87 | ) -> str: |
| 88 | oid = _sha(content) |
| 89 | write_object(root, oid, content) |
| 90 | snap = SnapshotRecord(snapshot_id=_sha(commit_id.encode()), manifest={"file.txt": oid}) |
| 91 | write_snapshot(root, snap) |
| 92 | commit = CommitRecord( |
| 93 | commit_id=commit_id, |
| 94 | repo_id=f"repo-{root.name}", |
| 95 | branch=branch, |
| 96 | snapshot_id=snap.snapshot_id, |
| 97 | message=f"commit {commit_id[:8]}", |
| 98 | committed_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), |
| 99 | parent_commit_id=parent, |
| 100 | ) |
| 101 | write_commit(root, commit) |
| 102 | (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id) |
| 103 | return commit_id |
| 104 | |
| 105 | |
| 106 | # --------------------------------------------------------------------------- |
| 107 | # Unit — _repo_root |
| 108 | # --------------------------------------------------------------------------- |
| 109 | |
| 110 | |
| 111 | class TestRepoRoot: |
| 112 | def test_valid_url_returns_resolved_path(self, tmp_path: pathlib.Path) -> None: |
| 113 | repo = _make_repo(tmp_path / "myrepo") |
| 114 | url = f"file://{repo}" |
| 115 | result = LocalFileTransport._repo_root(url) |
| 116 | assert result == repo.resolve() |
| 117 | |
| 118 | def test_invalid_scheme_raises_transport_error(self, tmp_path: pathlib.Path) -> None: |
| 119 | with pytest.raises(TransportError, match="file://"): |
| 120 | LocalFileTransport._repo_root("https://hub.example.com/repos/r1") |
| 121 | |
| 122 | def test_missing_muse_dir_raises_404(self, tmp_path: pathlib.Path) -> None: |
| 123 | with pytest.raises(TransportError) as exc_info: |
| 124 | LocalFileTransport._repo_root(f"file://{tmp_path}") |
| 125 | assert exc_info.value.status_code == 404 |
| 126 | assert ".muse/" in str(exc_info.value) |
| 127 | |
| 128 | def test_path_with_double_dots_normalized(self, tmp_path: pathlib.Path) -> None: |
| 129 | """resolve() must collapse .. so the check is on the canonical path.""" |
| 130 | repo = _make_repo(tmp_path / "repo") |
| 131 | # Construct a URL with a harmless .. that stays inside the repo. |
| 132 | url = f"file://{repo}/subdir/../" |
| 133 | # The path resolves to the repo root — .muse/ exists there. |
| 134 | result = LocalFileTransport._repo_root(url) |
| 135 | assert result == repo.resolve() |
| 136 | |
| 137 | def test_symlink_target_with_no_muse_is_rejected(self, tmp_path: pathlib.Path) -> None: |
| 138 | """A symlink that resolves to a dir without .muse/ must raise TransportError.""" |
| 139 | target = tmp_path / "innocent" |
| 140 | target.mkdir() |
| 141 | link = tmp_path / "evil_link" |
| 142 | link.symlink_to(target) |
| 143 | with pytest.raises(TransportError) as exc_info: |
| 144 | LocalFileTransport._repo_root(f"file://{link}") |
| 145 | assert exc_info.value.status_code == 404 |
| 146 | |
| 147 | def test_symlink_to_valid_repo_is_accepted(self, tmp_path: pathlib.Path) -> None: |
| 148 | """A symlink that resolves to a valid repo is accepted after resolve().""" |
| 149 | repo = _make_repo(tmp_path / "real_repo") |
| 150 | link = tmp_path / "alias" |
| 151 | link.symlink_to(repo) |
| 152 | result = LocalFileTransport._repo_root(f"file://{link}") |
| 153 | # Should return the canonical (resolved) path, not the symlink. |
| 154 | assert result == repo.resolve() |
| 155 | |
| 156 | |
| 157 | # --------------------------------------------------------------------------- |
| 158 | # Unit — fetch_remote_info |
| 159 | # --------------------------------------------------------------------------- |
| 160 | |
| 161 | |
| 162 | class TestFetchRemoteInfo: |
| 163 | def test_reads_repo_json_and_branch_heads(self, tmp_path: pathlib.Path) -> None: |
| 164 | repo = _make_repo(tmp_path / "remote") |
| 165 | _add_commit(repo, "a" * 64) |
| 166 | t = LocalFileTransport() |
| 167 | info = t.fetch_remote_info(f"file://{repo}", token=None) |
| 168 | assert info["repo_id"] == f"repo-{repo.name}" |
| 169 | assert info["domain"] == "midi" |
| 170 | assert info["default_branch"] == "main" |
| 171 | assert info["branch_heads"]["main"] == "a" * 64 |
| 172 | |
| 173 | def test_multiple_branches_returned(self, tmp_path: pathlib.Path) -> None: |
| 174 | repo = _make_repo(tmp_path / "remote") |
| 175 | _add_commit(repo, "a" * 64, branch="main") |
| 176 | _add_commit(repo, "b" * 64, branch="dev") |
| 177 | t = LocalFileTransport() |
| 178 | info = t.fetch_remote_info(f"file://{repo}", token=None) |
| 179 | assert info["branch_heads"]["main"] == "a" * 64 |
| 180 | assert info["branch_heads"]["dev"] == "b" * 64 |
| 181 | |
| 182 | def test_token_is_ignored(self, tmp_path: pathlib.Path) -> None: |
| 183 | """LocalFileTransport ignores the token arg — no auth for local repos.""" |
| 184 | repo = _make_repo(tmp_path / "remote") |
| 185 | _add_commit(repo, "c" * 64) |
| 186 | t = LocalFileTransport() |
| 187 | info = t.fetch_remote_info(f"file://{repo}", token="should-be-ignored") |
| 188 | assert info["repo_id"] == f"repo-{repo.name}" |
| 189 | |
| 190 | def test_corrupted_repo_json_raises_transport_error(self, tmp_path: pathlib.Path) -> None: |
| 191 | repo = _make_repo(tmp_path / "bad") |
| 192 | (repo / ".muse" / "repo.json").write_text("NOT JSON") |
| 193 | t = LocalFileTransport() |
| 194 | with pytest.raises(TransportError, match="repo.json"): |
| 195 | t.fetch_remote_info(f"file://{repo}", token=None) |
| 196 | |
| 197 | |
| 198 | # --------------------------------------------------------------------------- |
| 199 | # Unit — fetch_pack |
| 200 | # --------------------------------------------------------------------------- |
| 201 | |
| 202 | |
| 203 | class TestFetchPack: |
| 204 | def test_returns_pack_bundle_for_wanted_commit(self, tmp_path: pathlib.Path) -> None: |
| 205 | repo = _make_repo(tmp_path / "remote") |
| 206 | cid = _add_commit(repo, _sha(b"commit-1")) |
| 207 | t = LocalFileTransport() |
| 208 | bundle = t.fetch_pack(f"file://{repo}", token=None, want=[cid], have=[]) |
| 209 | commits = bundle.get("commits") or [] |
| 210 | commit_ids = [c["commit_id"] for c in commits] |
| 211 | assert cid in commit_ids |
| 212 | |
| 213 | def test_empty_want_returns_empty_bundle(self, tmp_path: pathlib.Path) -> None: |
| 214 | repo = _make_repo(tmp_path / "remote") |
| 215 | _add_commit(repo, _sha(b"commit-1")) |
| 216 | t = LocalFileTransport() |
| 217 | bundle = t.fetch_pack(f"file://{repo}", token=None, want=[], have=[]) |
| 218 | commits = bundle.get("commits") or [] |
| 219 | assert commits == [] |
| 220 | |
| 221 | def test_have_excludes_already_known_commits(self, tmp_path: pathlib.Path) -> None: |
| 222 | repo = _make_repo(tmp_path / "remote") |
| 223 | cid1 = _add_commit(repo, _sha(b"commit-1")) |
| 224 | cid2 = _add_commit(repo, _sha(b"commit-2"), parent=cid1) |
| 225 | t = LocalFileTransport() |
| 226 | bundle = t.fetch_pack(f"file://{repo}", token=None, want=[cid2], have=[cid1]) |
| 227 | commits = bundle.get("commits") or [] |
| 228 | commit_ids = {c["commit_id"] for c in commits} |
| 229 | # cid2 is wanted; cid1 is in have so it may be excluded. |
| 230 | assert cid2 in commit_ids |
| 231 | |
| 232 | |
| 233 | # --------------------------------------------------------------------------- |
| 234 | # Unit — push_pack |
| 235 | # --------------------------------------------------------------------------- |
| 236 | |
| 237 | |
| 238 | class TestPushPack: |
| 239 | def _minimal_bundle( |
| 240 | self, commit_id: str, branch: str = "main", parent: str | None = None |
| 241 | ) -> PackBundle: |
| 242 | content = b"test-content" |
| 243 | oid = _sha(content) |
| 244 | snap_id = _sha(commit_id.encode()) |
| 245 | return PackBundle( |
| 246 | commits=[{ |
| 247 | "commit_id": commit_id, |
| 248 | "repo_id": "test", |
| 249 | "branch": branch, |
| 250 | "snapshot_id": snap_id, |
| 251 | "message": "test", |
| 252 | "committed_at": "2026-01-01T00:00:00+00:00", |
| 253 | "parent_commit_id": parent, |
| 254 | "parent2_commit_id": None, |
| 255 | "author": "test", |
| 256 | "metadata": {}, |
| 257 | "structured_delta": None, |
| 258 | "sem_ver_bump": "none", |
| 259 | "breaking_changes": [], |
| 260 | "agent_id": "", |
| 261 | "model_id": "", |
| 262 | "toolchain_id": "", |
| 263 | "prompt_hash": "", |
| 264 | "signature": "", |
| 265 | "signer_key_id": "", |
| 266 | "format_version": 5, |
| 267 | "reviewed_by": [], |
| 268 | "test_runs": 0, |
| 269 | }], |
| 270 | snapshots=[{ |
| 271 | "snapshot_id": snap_id, |
| 272 | "manifest": {"file.txt": oid}, |
| 273 | "created_at": "2026-01-01T00:00:00+00:00", |
| 274 | }], |
| 275 | objects=[{"object_id": oid, "content_b64": base64.b64encode(content).decode()}], |
| 276 | branch_heads={branch: commit_id}, |
| 277 | ) |
| 278 | |
| 279 | def test_successful_push_returns_ok(self, tmp_path: pathlib.Path) -> None: |
| 280 | remote = _make_repo(tmp_path / "remote") |
| 281 | cid = _sha(b"new-commit") |
| 282 | bundle = self._minimal_bundle(cid) |
| 283 | t = LocalFileTransport() |
| 284 | result = t.push_pack(f"file://{remote}", None, bundle, "main", force=False) |
| 285 | assert result["ok"] is True |
| 286 | assert get_head_commit_id(remote, "main") == cid |
| 287 | |
| 288 | def test_push_updates_ref_file(self, tmp_path: pathlib.Path) -> None: |
| 289 | remote = _make_repo(tmp_path / "remote") |
| 290 | cid = _sha(b"tip") |
| 291 | bundle = self._minimal_bundle(cid) |
| 292 | LocalFileTransport().push_pack(f"file://{remote}", None, bundle, "main", force=False) |
| 293 | ref = (remote / ".muse" / "refs" / "heads" / "main").read_text() |
| 294 | assert ref == cid |
| 295 | |
| 296 | def test_push_result_includes_all_branch_heads(self, tmp_path: pathlib.Path) -> None: |
| 297 | remote = _make_repo(tmp_path / "remote") |
| 298 | _add_commit(remote, "e" * 64, branch="dev") |
| 299 | cid = _sha(b"tip") |
| 300 | bundle = self._minimal_bundle(cid) |
| 301 | result = LocalFileTransport().push_pack(f"file://{remote}", None, bundle, "main", force=False) |
| 302 | assert "main" in result["branch_heads"] |
| 303 | assert "dev" in result["branch_heads"] |
| 304 | |
| 305 | def test_fast_forward_check_rejects_diverged_push(self, tmp_path: pathlib.Path) -> None: |
| 306 | remote = _make_repo(tmp_path / "remote") |
| 307 | existing = _add_commit(remote, "f" * 64) |
| 308 | # Bundle that doesn't include the existing commit in its ancestry. |
| 309 | new_cid = _sha(b"diverged") |
| 310 | bundle = self._minimal_bundle(new_cid) # no parent → diverged |
| 311 | result = LocalFileTransport().push_pack( |
| 312 | f"file://{remote}", None, bundle, "main", force=False |
| 313 | ) |
| 314 | assert result["ok"] is False |
| 315 | assert "diverged" in result["message"] |
| 316 | |
| 317 | def test_force_flag_overrides_fast_forward_check(self, tmp_path: pathlib.Path) -> None: |
| 318 | remote = _make_repo(tmp_path / "remote") |
| 319 | _add_commit(remote, "f" * 64) |
| 320 | new_cid = _sha(b"force-rewrite") |
| 321 | bundle = self._minimal_bundle(new_cid) |
| 322 | result = LocalFileTransport().push_pack( |
| 323 | f"file://{remote}", None, bundle, "main", force=True |
| 324 | ) |
| 325 | assert result["ok"] is True |
| 326 | assert get_head_commit_id(remote, "main") == new_cid |
| 327 | |
| 328 | def test_push_creates_new_branch(self, tmp_path: pathlib.Path) -> None: |
| 329 | remote = _make_repo(tmp_path / "remote") |
| 330 | cid = _sha(b"feature-tip") |
| 331 | bundle = self._minimal_bundle(cid, branch="feature/my-branch") |
| 332 | result = LocalFileTransport().push_pack( |
| 333 | f"file://{remote}", None, bundle, "feature/my-branch", force=False |
| 334 | ) |
| 335 | assert result["ok"] is True |
| 336 | ref_file = remote / ".muse" / "refs" / "heads" / "feature" / "my-branch" |
| 337 | assert ref_file.exists() |
| 338 | assert ref_file.read_text() == cid |
| 339 | |
| 340 | |
| 341 | # --------------------------------------------------------------------------- |
| 342 | # Security — branch name and path traversal |
| 343 | # --------------------------------------------------------------------------- |
| 344 | |
| 345 | |
| 346 | class TestPushPackSecurity: |
| 347 | def _remote(self, tmp_path: pathlib.Path) -> pathlib.Path: |
| 348 | return _make_repo(tmp_path / "remote") |
| 349 | |
| 350 | def _bundle(self, branch: str = "main") -> PackBundle: |
| 351 | cid = _sha(b"payload") |
| 352 | return PackBundle( |
| 353 | commits=[], |
| 354 | snapshots=[], |
| 355 | objects=[], |
| 356 | branch_heads={branch: cid}, |
| 357 | ) |
| 358 | |
| 359 | def test_path_traversal_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 360 | remote = self._remote(tmp_path) |
| 361 | bundle = self._bundle("main") |
| 362 | bundle["branch_heads"] = {"main": _sha(b"tip")} |
| 363 | result = LocalFileTransport().push_pack( |
| 364 | f"file://{remote}", None, bundle, "../evil", force=True |
| 365 | ) |
| 366 | assert result["ok"] is False |
| 367 | |
| 368 | def test_double_dot_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 369 | remote = self._remote(tmp_path) |
| 370 | result = LocalFileTransport().push_pack( |
| 371 | f"file://{remote}", None, {}, "foo..bar", force=True |
| 372 | ) |
| 373 | assert result["ok"] is False |
| 374 | |
| 375 | def test_null_byte_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 376 | remote = self._remote(tmp_path) |
| 377 | result = LocalFileTransport().push_pack( |
| 378 | f"file://{remote}", None, {}, "main\x00evil", force=True |
| 379 | ) |
| 380 | assert result["ok"] is False |
| 381 | |
| 382 | def test_backslash_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 383 | remote = self._remote(tmp_path) |
| 384 | result = LocalFileTransport().push_pack( |
| 385 | f"file://{remote}", None, {}, "main\\evil", force=True |
| 386 | ) |
| 387 | assert result["ok"] is False |
| 388 | |
| 389 | def test_cr_lf_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 390 | remote = self._remote(tmp_path) |
| 391 | for bad in ("main\r", "main\ninjected", "main\r\nevil"): |
| 392 | result = LocalFileTransport().push_pack( |
| 393 | f"file://{remote}", None, {}, bad, force=True |
| 394 | ) |
| 395 | assert result["ok"] is False, f"Expected rejection for branch={bad!r}" |
| 396 | |
| 397 | def test_empty_branch_rejected(self, tmp_path: pathlib.Path) -> None: |
| 398 | remote = self._remote(tmp_path) |
| 399 | result = LocalFileTransport().push_pack( |
| 400 | f"file://{remote}", None, {}, "", force=True |
| 401 | ) |
| 402 | assert result["ok"] is False |
| 403 | |
| 404 | def test_symlink_in_heads_dir_cannot_escape(self, tmp_path: pathlib.Path) -> None: |
| 405 | """Pre-placed symlink in .muse/refs/heads/ that points outside cannot be followed.""" |
| 406 | remote = self._remote(tmp_path) |
| 407 | outside = tmp_path / "outside.txt" |
| 408 | outside.write_text("sensitive") |
| 409 | heads_dir = remote / ".muse" / "refs" / "heads" |
| 410 | link = heads_dir / "evil" |
| 411 | link.symlink_to(outside) |
| 412 | # contain_path resolves the symlink — result is outside heads_dir → rejected. |
| 413 | cid = _sha(b"tip") |
| 414 | bundle = PackBundle( |
| 415 | commits=[], |
| 416 | snapshots=[], |
| 417 | objects=[], |
| 418 | branch_heads={"evil": cid}, |
| 419 | ) |
| 420 | result = LocalFileTransport().push_pack( |
| 421 | f"file://{remote}", None, bundle, "evil", force=True |
| 422 | ) |
| 423 | # Symlink escapes the base → contain_path raises → push_pack returns ok=False. |
| 424 | # If the symlink doesn't escape (OS resolved it back inside), the push may succeed; |
| 425 | # either way, the outside file must not be overwritten with the commit ID. |
| 426 | if not result["ok"]: |
| 427 | assert "unsafe" in result["message"] |
| 428 | # The outside file must be untouched regardless of result. |
| 429 | assert outside.read_text() == "sensitive" |
| 430 | |
| 431 | |
| 432 | # --------------------------------------------------------------------------- |
| 433 | # make_transport factory |
| 434 | # --------------------------------------------------------------------------- |
| 435 | |
| 436 | |
| 437 | class TestMakeTransport: |
| 438 | def test_file_url_returns_local_transport(self) -> None: |
| 439 | assert isinstance(make_transport("file:///some/path"), LocalFileTransport) |
| 440 | |
| 441 | def test_https_url_returns_http_transport(self) -> None: |
| 442 | assert isinstance(make_transport("https://hub.example.com/repos/r1"), HttpTransport) |
| 443 | |
| 444 | def test_http_url_returns_http_transport(self) -> None: |
| 445 | assert isinstance(make_transport("http://hub.example.com/repos/r1"), HttpTransport) |
| 446 | |
| 447 | def test_empty_url_returns_http_transport(self) -> None: |
| 448 | assert isinstance(make_transport(""), HttpTransport) |
| 449 | |
| 450 | |
| 451 | # --------------------------------------------------------------------------- |
| 452 | # Integration — full round-trip between two real repos |
| 453 | # --------------------------------------------------------------------------- |
| 454 | |
| 455 | |
| 456 | class TestIntegrationRoundTrip: |
| 457 | def test_push_then_fetch_info(self, tmp_path: pathlib.Path) -> None: |
| 458 | """Push from local → remote; remote branch heads should reflect the push.""" |
| 459 | local = _make_repo(tmp_path / "local") |
| 460 | remote = _make_repo(tmp_path / "remote") |
| 461 | cid = _add_commit(local, _sha(b"initial"), branch="main") |
| 462 | |
| 463 | from muse.core.pack import build_pack |
| 464 | bundle = build_pack(local, commit_ids=[cid], have=[]) |
| 465 | |
| 466 | t = LocalFileTransport() |
| 467 | result = t.push_pack(f"file://{remote}", None, bundle, "main", force=False) |
| 468 | assert result["ok"] is True |
| 469 | |
| 470 | info = t.fetch_remote_info(f"file://{remote}", None) |
| 471 | assert info["branch_heads"]["main"] == cid |
| 472 | |
| 473 | def test_fetch_pack_after_push(self, tmp_path: pathlib.Path) -> None: |
| 474 | """After pushing A→B, fetching from B should give back the same commit.""" |
| 475 | src = _make_repo(tmp_path / "src") |
| 476 | dst = _make_repo(tmp_path / "dst") |
| 477 | cid = _add_commit(src, _sha(b"content"), branch="main") |
| 478 | |
| 479 | from muse.core.pack import build_pack |
| 480 | bundle = build_pack(src, commit_ids=[cid], have=[]) |
| 481 | LocalFileTransport().push_pack(f"file://{dst}", None, bundle, "main", force=False) |
| 482 | |
| 483 | fetched_bundle = LocalFileTransport().fetch_pack( |
| 484 | f"file://{dst}", None, want=[cid], have=[] |
| 485 | ) |
| 486 | fetched_ids = {c["commit_id"] for c in (fetched_bundle.get("commits") or [])} |
| 487 | assert cid in fetched_ids |
| 488 | |
| 489 | def test_multi_branch_round_trip(self, tmp_path: pathlib.Path) -> None: |
| 490 | """Push two branches; remote should have both.""" |
| 491 | local = _make_repo(tmp_path / "local") |
| 492 | remote = _make_repo(tmp_path / "remote") |
| 493 | |
| 494 | cid_main = _add_commit(local, _sha(b"main-commit"), branch="main") |
| 495 | cid_dev = _add_commit(local, _sha(b"dev-commit"), branch="dev") |
| 496 | |
| 497 | from muse.core.pack import build_pack |
| 498 | t = LocalFileTransport() |
| 499 | url = f"file://{remote}" |
| 500 | |
| 501 | bundle_main = build_pack(local, commit_ids=[cid_main], have=[]) |
| 502 | t.push_pack(url, None, bundle_main, "main", force=False) |
| 503 | |
| 504 | bundle_dev = build_pack(local, commit_ids=[cid_dev], have=[]) |
| 505 | t.push_pack(url, None, bundle_dev, "dev", force=False) |
| 506 | |
| 507 | info = t.fetch_remote_info(url, None) |
| 508 | assert info["branch_heads"]["main"] == cid_main |
| 509 | assert info["branch_heads"]["dev"] == cid_dev |
| 510 | |
| 511 | def test_incremental_push_is_fast_forward(self, tmp_path: pathlib.Path) -> None: |
| 512 | """Second push whose parent is the remote tip is accepted (fast-forward).""" |
| 513 | local = _make_repo(tmp_path / "local") |
| 514 | remote = _make_repo(tmp_path / "remote") |
| 515 | |
| 516 | cid1 = _sha(b"commit-1") |
| 517 | _add_commit(local, cid1, branch="main") |
| 518 | |
| 519 | from muse.core.pack import build_pack |
| 520 | t = LocalFileTransport() |
| 521 | url = f"file://{remote}" |
| 522 | |
| 523 | b1 = build_pack(local, commit_ids=[cid1], have=[]) |
| 524 | t.push_pack(url, None, b1, "main", force=False) |
| 525 | |
| 526 | # Second commit with cid1 as parent. |
| 527 | cid2 = _sha(b"commit-2") |
| 528 | _add_commit(local, cid2, branch="main", parent=cid1) |
| 529 | b2 = build_pack(local, commit_ids=[cid2], have=[cid1]) |
| 530 | result = t.push_pack(url, None, b2, "main", force=False) |
| 531 | |
| 532 | assert result["ok"] is True |
| 533 | assert get_head_commit_id(remote, "main") == cid2 |
| 534 | |
| 535 | |
| 536 | # --------------------------------------------------------------------------- |
| 537 | # Stress — large bundle |
| 538 | # --------------------------------------------------------------------------- |
| 539 | |
| 540 | |
| 541 | class TestStress: |
| 542 | def test_push_large_bundle(self, tmp_path: pathlib.Path) -> None: |
| 543 | """Push a bundle with 50 commits and 200 distinct objects.""" |
| 544 | remote = _make_repo(tmp_path / "remote") |
| 545 | local = _make_repo(tmp_path / "local") |
| 546 | |
| 547 | prev_cid: str | None = None |
| 548 | last_cid = "" |
| 549 | for i in range(50): |
| 550 | content = f"object-content-{i}".encode() |
| 551 | cid = _sha(f"commit-{i}".encode()) |
| 552 | last_cid = cid |
| 553 | # Write 4 objects per commit (200 total). |
| 554 | manifest: dict[str, str] = {} |
| 555 | for j in range(4): |
| 556 | blob = f"blob-{i}-{j}".encode() |
| 557 | oid = _sha(blob) |
| 558 | write_object(local, oid, blob) |
| 559 | manifest[f"file_{i}_{j}.txt"] = oid |
| 560 | snap = SnapshotRecord(snapshot_id=_sha(cid.encode()), manifest=manifest) |
| 561 | write_snapshot(local, snap) |
| 562 | commit = CommitRecord( |
| 563 | commit_id=cid, |
| 564 | repo_id=f"repo-{local.name}", |
| 565 | branch="main", |
| 566 | snapshot_id=snap.snapshot_id, |
| 567 | message=f"commit {i}", |
| 568 | committed_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), |
| 569 | parent_commit_id=prev_cid, |
| 570 | ) |
| 571 | write_commit(local, commit) |
| 572 | prev_cid = cid |
| 573 | |
| 574 | (local / ".muse" / "refs" / "heads" / "main").write_text(last_cid) |
| 575 | |
| 576 | from muse.core.pack import build_pack |
| 577 | bundle = build_pack(local, commit_ids=[last_cid], have=[]) |
| 578 | result = LocalFileTransport().push_pack( |
| 579 | f"file://{remote}", None, bundle, "main", force=False |
| 580 | ) |
| 581 | assert result["ok"] is True |
| 582 | assert get_head_commit_id(remote, "main") == last_cid |
| 583 | |
| 584 | def test_fetch_pack_large_bundle(self, tmp_path: pathlib.Path) -> None: |
| 585 | """Fetch from a remote with 20 commits; verify all are returned.""" |
| 586 | remote = _make_repo(tmp_path / "remote") |
| 587 | all_cids: list[str] = [] |
| 588 | prev: str | None = None |
| 589 | |
| 590 | for i in range(20): |
| 591 | cid = _sha(f"remote-commit-{i}".encode()) |
| 592 | _add_commit(remote, cid, parent=prev) |
| 593 | all_cids.append(cid) |
| 594 | prev = cid |
| 595 | |
| 596 | last = all_cids[-1] |
| 597 | bundle = LocalFileTransport().fetch_pack( |
| 598 | f"file://{remote}", None, want=[last], have=[] |
| 599 | ) |
| 600 | fetched_ids = {c["commit_id"] for c in (bundle.get("commits") or [])} |
| 601 | assert last in fetched_ids |