gabriel / muse public
test_push_branch_have.py python
330 lines 12.0 KB
Raw
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