test_push_branch_have.py
python
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f
chore: merge main — carry all urllib/typing/test fixes from dev
Sonnet 4.6
minor
⚠ breaking
17 days ago
| 1 | """TDD — Phase 1: branch_have uses all remote branch heads unconditionally. |
| 2 | |
| 3 | Root cause (issue #56) |
| 4 | ----------------------- |
| 5 | ``branch_have`` — the BFS stop set passed to ``walk_commits`` / ``build_mpack`` |
| 6 | — only contains the target branch's remote HEAD for non-merge commits. |
| 7 | |
| 8 | When pushing a second branch (e.g. ``dev`` after ``main``), the client should |
| 9 | stop the DAG walk at any commit already on the remote, regardless of which |
| 10 | remote branch contains it. Without this, the client walks back to the |
| 11 | repository root and packs every commit the remote already holds under |
| 12 | ``main``. |
| 13 | |
| 14 | Scenario |
| 15 | -------- |
| 16 | Repo history:: |
| 17 | |
| 18 | A ← B ← C ← D (main) |
| 19 | ↑ |
| 20 | E (dev, 1 commit ahead) |
| 21 | |
| 22 | Remote state after first push:: |
| 23 | main → D |
| 24 | |
| 25 | Correct second push (dev):: |
| 26 | branch_have = [D] # D is main's remote head — walk stops there |
| 27 | new_commits = [E] # only E is new |
| 28 | |
| 29 | Current buggy second push (dev):: |
| 30 | branch_have = [] # dev has no remote head yet → target head is null |
| 31 | new_commits = [E, D, C, B, A] # entire history re-sent |
| 32 | |
| 33 | Coverage |
| 34 | -------- |
| 35 | BH-1 branch_have includes all remote branch heads, not just target branch. |
| 36 | BH-2 Non-merge commit: walk stops at any remote branch head, not just target. |
| 37 | BH-3 walk_commits BFS count equals expected new commits only. |
| 38 | BH-4 build_mpack sends only genuinely new commits + objects. |
| 39 | BH-5 mpack size is small (no redundant commits) on second-branch push. |
| 40 | """ |
| 41 | from __future__ import annotations |
| 42 | |
| 43 | import datetime |
| 44 | import pathlib |
| 45 | |
| 46 | import pytest |
| 47 | |
| 48 | from muse.core.commits import write_commit, CommitRecord |
| 49 | from muse.core.mpack import walk_commits, build_mpack_from_walk |
| 50 | from muse.core.object_store import write_object |
| 51 | from muse.core.refs import write_branch_ref |
| 52 | from muse.core.snapshot import compute_commit_id, compute_snapshot_id |
| 53 | from muse.core.snapshots import write_snapshot, SnapshotRecord |
| 54 | |
| 55 | |
| 56 | # --------------------------------------------------------------------------- |
| 57 | # Helpers |
| 58 | # --------------------------------------------------------------------------- |
| 59 | |
| 60 | _TS = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) |
| 61 | |
| 62 | |
| 63 | def _obj(root: pathlib.Path, content: bytes) -> str: |
| 64 | from muse.core.types import blob_id |
| 65 | oid = blob_id(content) |
| 66 | write_object(root, oid, content) |
| 67 | return oid |
| 68 | |
| 69 | |
| 70 | def _snap(root: pathlib.Path, manifest: dict[str, str], dirs: list[str] | None = None) -> str: |
| 71 | sid = compute_snapshot_id(manifest, dirs) |
| 72 | write_snapshot(root, SnapshotRecord( |
| 73 | snapshot_id=sid, |
| 74 | manifest=manifest, |
| 75 | directories=dirs or [], |
| 76 | )) |
| 77 | return sid |
| 78 | |
| 79 | |
| 80 | def _commit( |
| 81 | root: pathlib.Path, |
| 82 | message: str, |
| 83 | snapshot_id: str, |
| 84 | parent: str | None = None, |
| 85 | author: str = "test", |
| 86 | ) -> str: |
| 87 | cid = compute_commit_id( |
| 88 | parent_ids=[parent] if parent else [], |
| 89 | snapshot_id=snapshot_id, |
| 90 | message=message, |
| 91 | committed_at_iso=_TS.isoformat(), |
| 92 | author=author, |
| 93 | ) |
| 94 | write_commit(root, CommitRecord( |
| 95 | commit_id=cid, |
| 96 | branch="main", |
| 97 | snapshot_id=snapshot_id, |
| 98 | message=message, |
| 99 | committed_at=_TS, |
| 100 | parent_commit_id=parent, |
| 101 | author=author, |
| 102 | )) |
| 103 | return cid |
| 104 | |
| 105 | |
| 106 | def _build_linear_chain(root: pathlib.Path, n: int) -> list[str]: |
| 107 | """Build n commits A→B→…→N, each adding a file. Returns commit IDs oldest first.""" |
| 108 | commits: list[str] = [] |
| 109 | manifest: dict[str, str] = {} |
| 110 | prev: str | None = None |
| 111 | for i in range(n): |
| 112 | content = f"file {i}".encode() |
| 113 | oid = _obj(root, content) |
| 114 | manifest[f"file{i}.txt"] = oid |
| 115 | sid = _snap(root, dict(manifest)) |
| 116 | cid = _commit(root, f"commit {i}", sid, parent=prev) |
| 117 | commits.append(cid) |
| 118 | prev = cid |
| 119 | return commits |
| 120 | |
| 121 | |
| 122 | # --------------------------------------------------------------------------- |
| 123 | # BH-1 branch_have includes ALL remote branch heads |
| 124 | # --------------------------------------------------------------------------- |
| 125 | |
| 126 | def test_BH1_branch_have_includes_all_remote_heads( |
| 127 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 128 | ) -> None: |
| 129 | """BH-1: branch_have from _push_mpack includes all remote branch heads.""" |
| 130 | from muse.cli.commands import push as push_mod |
| 131 | |
| 132 | # Simulate remote_branch_heads returned by fetch_remote_refs |
| 133 | remote_heads = { |
| 134 | "main": "sha256:" + "a" * 64, |
| 135 | "dev": "sha256:" + "b" * 64, |
| 136 | "feat": "sha256:" + "c" * 64, |
| 137 | } |
| 138 | local_head = "sha256:" + "d" * 64 |
| 139 | push_branch = "newbranch" |
| 140 | |
| 141 | # The branch_have logic lives in the run() function. |
| 142 | # We extract just the computation: all remote heads that are valid commit IDs. |
| 143 | from muse.cli.commands.push import _is_valid_commit_id |
| 144 | |
| 145 | # Current (buggy) logic: only uses remote_head for target branch |
| 146 | target_remote_head = remote_heads.get(push_branch) # None — new branch |
| 147 | buggy_branch_have = ( |
| 148 | [target_remote_head] |
| 149 | if target_remote_head and _is_valid_commit_id(target_remote_head) |
| 150 | else [] |
| 151 | ) |
| 152 | assert buggy_branch_have == [], "Buggy logic: empty when pushing new branch" |
| 153 | |
| 154 | # Fixed logic: use ALL remote heads unconditionally |
| 155 | fixed_branch_have = [ |
| 156 | h for h in remote_heads.values() |
| 157 | if _is_valid_commit_id(h) |
| 158 | ] |
| 159 | assert len(fixed_branch_have) == 3, ( |
| 160 | f"Fixed logic must include all {len(remote_heads)} remote heads, " |
| 161 | f"got {len(fixed_branch_have)}" |
| 162 | ) |
| 163 | assert "sha256:" + "a" * 64 in fixed_branch_have |
| 164 | assert "sha256:" + "b" * 64 in fixed_branch_have |
| 165 | assert "sha256:" + "c" * 64 in fixed_branch_have |
| 166 | |
| 167 | |
| 168 | # --------------------------------------------------------------------------- |
| 169 | # BH-2 Non-merge push stops at remote branches other than target |
| 170 | # --------------------------------------------------------------------------- |
| 171 | |
| 172 | def test_BH2_walk_stops_at_any_remote_head(tmp_path: pathlib.Path) -> None: |
| 173 | """BH-2: walk_commits stops at a commit on a different remote branch.""" |
| 174 | monkeypatch = None # not needed — we test walk_commits directly |
| 175 | |
| 176 | from muse.core.mpack import walk_commits as _walk |
| 177 | |
| 178 | root = tmp_path |
| 179 | from muse.core.paths import init_repo_dirs as init_repo |
| 180 | init_repo(root) |
| 181 | |
| 182 | # Build: A → B → C → D (main), D → E (dev) |
| 183 | commits = _build_linear_chain(root, 5) # A B C D E |
| 184 | A, B, C, D, E = commits |
| 185 | |
| 186 | # Simulate: main is at D on remote, dev has E locally but no remote head |
| 187 | # Fixed branch_have: [D] (main's remote head) |
| 188 | branch_have_fixed = [D] |
| 189 | |
| 190 | result = _walk(root, [E], have=branch_have_fixed) |
| 191 | new_commit_ids = [c.commit_id for c in result["commits"]] |
| 192 | |
| 193 | # Only E should be new — D and below are already on remote (via main) |
| 194 | assert E in new_commit_ids, "E (new dev commit) must be in walk result" |
| 195 | assert D not in new_commit_ids, ( |
| 196 | "D must NOT be in walk result — it's already on remote under main" |
| 197 | ) |
| 198 | assert len(new_commit_ids) == 1, ( |
| 199 | f"Only 1 new commit (E), got {len(new_commit_ids)}: {new_commit_ids}" |
| 200 | ) |
| 201 | |
| 202 | |
| 203 | # --------------------------------------------------------------------------- |
| 204 | # BH-3 Buggy logic walks entire history for new branch |
| 205 | # --------------------------------------------------------------------------- |
| 206 | |
| 207 | def test_BH3_buggy_branch_have_walks_entire_history(tmp_path: pathlib.Path) -> None: |
| 208 | """BH-3: Without the fix, pushing a new branch re-sends the entire DAG.""" |
| 209 | from muse.core.mpack import walk_commits as _walk |
| 210 | from muse.core.paths import init_repo_dirs as init_repo |
| 211 | |
| 212 | root = tmp_path |
| 213 | init_repo(root) |
| 214 | |
| 215 | commits = _build_linear_chain(root, 5) |
| 216 | A, B, C, D, E = commits |
| 217 | |
| 218 | # Buggy branch_have: [] (target branch has no remote head yet) |
| 219 | branch_have_buggy: list[str] = [] |
| 220 | |
| 221 | result = _walk(root, [E], have=branch_have_buggy) |
| 222 | new_commit_ids = [c.commit_id for c in result["commits"]] |
| 223 | |
| 224 | # All 5 commits are sent — the entire history |
| 225 | assert len(new_commit_ids) == 5, ( |
| 226 | f"Buggy logic must walk all 5 commits, got {len(new_commit_ids)}" |
| 227 | ) |
| 228 | |
| 229 | |
| 230 | # --------------------------------------------------------------------------- |
| 231 | # BH-4 build_mpack_from_walk sends only new commits + objects |
| 232 | # --------------------------------------------------------------------------- |
| 233 | |
| 234 | def test_BH4_build_mpack_only_contains_new_commits(tmp_path: pathlib.Path) -> None: |
| 235 | """BH-4: With fixed branch_have, mpack only contains E's commit and objects.""" |
| 236 | from muse.core.mpack import walk_commits as _walk, build_mpack_from_walk |
| 237 | from muse.core.paths import init_repo_dirs as init_repo |
| 238 | |
| 239 | root = tmp_path |
| 240 | init_repo(root) |
| 241 | |
| 242 | commits = _build_linear_chain(root, 5) |
| 243 | A, B, C, D, E = commits |
| 244 | |
| 245 | branch_have_fixed = [D] |
| 246 | result = _walk(root, [E], have=branch_have_fixed) |
| 247 | mpack = build_mpack_from_walk(root, result) |
| 248 | |
| 249 | commit_ids_in_mpack = [c["commit_id"] if isinstance(c, dict) else c.commit_id for c in mpack.get("commits", [])] |
| 250 | assert E in commit_ids_in_mpack |
| 251 | assert D not in commit_ids_in_mpack, "D already on remote — must not be in mpack" |
| 252 | assert len(commit_ids_in_mpack) == 1, ( |
| 253 | f"Mpack must contain exactly 1 commit (E), got {len(commit_ids_in_mpack)}" |
| 254 | ) |
| 255 | |
| 256 | # Blobs: only the object added in commit E (file4.txt), not earlier files |
| 257 | blob_ids = [b["object_id"] for b in mpack.get("blobs", [])] |
| 258 | assert len(blob_ids) <= 1, ( |
| 259 | f"Mpack must contain at most 1 new blob (file4.txt), got {len(blob_ids)}" |
| 260 | ) |
| 261 | |
| 262 | |
| 263 | # --------------------------------------------------------------------------- |
| 264 | # BH-5 Fixed push.py branch_have is unconditional for all commits |
| 265 | # --------------------------------------------------------------------------- |
| 266 | |
| 267 | def test_BH5_push_run_uses_all_remote_heads_as_branch_have( |
| 268 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 269 | ) -> None: |
| 270 | """BH-5: push.run() builds branch_have from ALL remote branch heads.""" |
| 271 | from muse.core.paths import init_repo_dirs as init_repo |
| 272 | from muse.cli.commands.push import _push_mpack, _is_valid_commit_id |
| 273 | |
| 274 | root = tmp_path |
| 275 | init_repo(root) |
| 276 | |
| 277 | commits = _build_linear_chain(root, 3) |
| 278 | A, B, C = commits |
| 279 | write_branch_ref(root, "main", C) |
| 280 | |
| 281 | captured_branch_have: list[list[str]] = [] |
| 282 | |
| 283 | original_push_mpack = _push_mpack |
| 284 | |
| 285 | def _spy_push_mpack(*args: object, branch_have: list[str] | None = None, **kwargs: object) -> object: |
| 286 | captured_branch_have.append(list(branch_have or [])) |
| 287 | raise SystemExit(0) # abort early — we just need branch_have |
| 288 | |
| 289 | monkeypatch.setattr("muse.cli.commands.push._push_mpack", _spy_push_mpack) |
| 290 | |
| 291 | import argparse |
| 292 | from unittest.mock import MagicMock, patch |
| 293 | |
| 294 | remote_heads = { |
| 295 | "main": "sha256:" + "a" * 64, |
| 296 | "staging": "sha256:" + "b" * 64, |
| 297 | } |
| 298 | |
| 299 | mock_info = remote_heads # push.py accesses info["branch_heads"] — return a plain dict |
| 300 | mock_transport = MagicMock() |
| 301 | mock_transport.fetch_remote_info.return_value = {"branch_heads": remote_heads} |
| 302 | |
| 303 | with patch("muse.cli.commands.push.make_transport", return_value=mock_transport), \ |
| 304 | patch("muse.cli.commands.push.get_remote", return_value="https://example.com/repo"), \ |
| 305 | patch("muse.cli.commands.push.get_signing_identity", return_value=None), \ |
| 306 | patch("muse.cli.commands.push.get_remote_head", return_value=None), \ |
| 307 | patch("muse.cli.commands.push.require_repo", return_value=root), \ |
| 308 | patch("muse.cli.commands.push.read_current_branch", return_value="newbranch"), \ |
| 309 | patch("muse.cli.commands.push.get_head_commit_id", return_value=C): |
| 310 | try: |
| 311 | args = argparse.Namespace( |
| 312 | remote="origin", branch=None, force=False, |
| 313 | force_with_lease=False, dry_run=False, delete=False, |
| 314 | json_out=False, upstream=False, workers=4, |
| 315 | set_upstream_flag=False, |
| 316 | ) |
| 317 | from muse.cli.commands.push import run |
| 318 | run(args) |
| 319 | except SystemExit: |
| 320 | pass |
| 321 | |
| 322 | assert captured_branch_have, "branch_have was never captured — spy not called" |
| 323 | bh = captured_branch_have[0] |
| 324 | |
| 325 | # Fixed: ALL remote heads must be in branch_have |
| 326 | for remote_head in remote_heads.values(): |
| 327 | assert remote_head in bh, ( |
| 328 | f"Fixed branch_have must include all remote heads. " |
| 329 | f"Missing {remote_head[:20]}. Got: {[h[:20] for h in bh]}" |
| 330 | ) |
File History
3 commits
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f
chore: merge main — carry all urllib/typing/test fixes from dev
Sonnet 4.6
minor
⚠
17 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156
chore: bump version to 0.2.0rc11; fix typing audit violatio…
Sonnet 4.6
minor
⚠
17 days ago
sha256:b1447dbe2ef78eb6ec67b8ec4cc0e9c29472382f4390741d6ce069cdf5efa792
fix: branch_have uses all remote heads unconditionally (Pha…
Sonnet 4.6
patch
19 days ago