gabriel / muse public
test_push_refspec.py python
263 lines 10.0 KB
Raw
sha256:1c3d22f581aa652cf47f38f4dc32816f888bffc5fa53b579dc6f139f333a6090 feat(push): refspec syntax local:remote for cross-branch pu… Sonnet 5 patch 2 days ago
1 """TDD — muse#43: push refspec syntax `local:remote` for cross-branch pushes.
2
3 Phase 1 (RS_01-RS_05): parse_push_refspec unit tests — pure function, no I/O.
4 Phase 2 (RS_06-RS_10): muse push <remote> <refspec> integration tests.
5 """
6 from __future__ import annotations
7
8 import datetime
9 import json
10 import pathlib
11 from unittest.mock import MagicMock, patch
12
13 import pytest
14
15 from muse._version import __version__
16 from muse.core.mpack import RemoteInfo
17 from muse.core.object_store import write_object
18 from muse.core.paths import heads_dir, muse_dir
19 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
20 from muse.core.commits import CommitRecord, write_commit
21 from muse.core.snapshots import SnapshotRecord, write_snapshot
22 from muse.core.types import Manifest, blob_id
23 from muse.cli.commands.push import parse_push_refspec
24 from tests.cli_test_helper import CliRunner
25
26 cli = None
27 runner = CliRunner()
28
29
30 # ---------------------------------------------------------------------------
31 # Phase 1 — RS_01-RS_05: parse_push_refspec unit tests
32 # ---------------------------------------------------------------------------
33
34
35 class TestParsePushRefspec:
36 def test_rs01_bare_name(self) -> None:
37 assert parse_push_refspec("dev") == ("dev", "dev", False)
38
39 def test_rs02_local_colon_remote(self) -> None:
40 assert parse_push_refspec("dev:main") == ("dev", "main", False)
41
42 def test_rs03_force_prefix_with_colon(self) -> None:
43 assert parse_push_refspec("+dev:main") == ("dev", "main", True)
44
45 def test_rs04_force_prefix_bare_name(self) -> None:
46 assert parse_push_refspec("+dev") == ("dev", "dev", True)
47
48 def test_rs05_empty_remote_side_raises(self) -> None:
49 with pytest.raises(ValueError):
50 parse_push_refspec("dev:")
51
52 def test_rs05_empty_local_side_raises(self) -> None:
53 with pytest.raises(ValueError):
54 parse_push_refspec(":main")
55
56 def test_rs05_too_many_colons_raises(self) -> None:
57 with pytest.raises(ValueError):
58 parse_push_refspec("dev:main:extra")
59
60 def test_rs05_error_messages_are_clear(self) -> None:
61 with pytest.raises(ValueError, match="dev:"):
62 parse_push_refspec("dev:")
63 with pytest.raises(ValueError, match=":main"):
64 parse_push_refspec(":main")
65
66
67 # ---------------------------------------------------------------------------
68 # Phase 2 — RS_06-RS_10: integration tests
69 # ---------------------------------------------------------------------------
70
71
72 def _bare_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
73 muse = muse_dir(tmp_path)
74 for d in ("commits", "snapshots", "objects", "refs/heads", "remotes"):
75 (muse / d).mkdir(parents=True, exist_ok=True)
76 (muse / "HEAD").write_text("ref: refs/heads/main\n")
77 (muse / "repo.json").write_text(
78 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
79 )
80 (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/r"\n')
81 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
82 monkeypatch.chdir(tmp_path)
83 return tmp_path
84
85
86 def _make_commit(
87 root: pathlib.Path,
88 label: str,
89 branch: str = "main",
90 parent_id: str | None = None,
91 content: bytes | None = None,
92 ) -> CommitRecord:
93 raw = content if content is not None else f"content-{label}".encode()
94 oid = blob_id(raw)
95 write_object(root, oid, raw)
96 manifest: Manifest = {"file.txt": oid}
97 snap_id = compute_snapshot_id(manifest)
98 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
99 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
100 parent_ids = [parent_id] if parent_id else []
101 cid = compute_commit_id(
102 parent_ids=parent_ids,
103 snapshot_id=snap_id,
104 message=f"commit {label}",
105 committed_at_iso=committed_at.isoformat(),
106 )
107 commit = CommitRecord(
108 commit_id=cid,
109 branch=branch,
110 snapshot_id=snap_id,
111 message=f"commit {label}",
112 committed_at=committed_at,
113 parent_commit_id=parent_id,
114 )
115 write_commit(root, commit)
116 return commit
117
118
119 def _mpack_push_transport(local_head: str, remote_branch: str = "main") -> MagicMock:
120 """Transport mock that makes _push_mpack succeed via the new urllib path."""
121 transport = MagicMock()
122 transport.fetch_remote_info.return_value = RemoteInfo(
123 domain="code", default_branch="main", branch_heads={},
124 )
125 mock_req = MagicMock()
126 mock_req.headers = {"Authorization": "MSign stub", "Content-Type": "application/x-msgpack"}
127 transport._build_request.return_value = mock_req
128 transport.push_mpack_presign.return_value = {
129 "upload_url": "https://minio.example.com/put?sig=x",
130 "mpack_key": "sha256:fake",
131 }
132 transport.push_mpack_put.return_value = None
133 transport.push_mpack_unpack.return_value = {
134 "job_id": "j", "head": local_head, "branch": remote_branch,
135 "blobs_in_mpack": 0, "commits_in_mpack": 0,
136 }
137 return transport
138
139
140 class TestRefspecSameNameUnchanged:
141 def test_rs06_dev_colon_dev_behaves_like_plain_dev(
142 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
143 ) -> None:
144 root = _bare_repo(tmp_path, monkeypatch)
145 commit = _make_commit(root, "rs06", branch="dev")
146 (heads_dir(root) / "dev").write_text(commit.commit_id)
147
148 transport = _mpack_push_transport(commit.commit_id, remote_branch="dev")
149
150 with patch("muse.cli.commands.push.make_transport", return_value=transport):
151 result = runner.invoke(
152 cli, ["push", "origin", "dev:dev", "--json"], catch_exceptions=False
153 )
154
155 assert result.exit_code == 0, result.output
156 data = json.loads(result.output)
157 assert data["status"] == "pushed"
158 assert data["branch"] == "dev"
159
160 _, _, kwargs = transport.push_mpack_unpack.mock_calls[0]
161 assert kwargs["branch"] == "dev"
162
163
164 class TestRefspecCrossBranch:
165 def test_rs07_dev_colon_main_advances_remote_main_not_dev(
166 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
167 ) -> None:
168 """Local dev's commits are read, but the remote ref advanced is main."""
169 root = _bare_repo(tmp_path, monkeypatch)
170 commit = _make_commit(root, "rs07", branch="dev")
171 (heads_dir(root) / "dev").write_text(commit.commit_id)
172
173 transport = _mpack_push_transport(commit.commit_id, remote_branch="main")
174
175 with patch("muse.cli.commands.push.make_transport", return_value=transport):
176 result = runner.invoke(
177 cli, ["push", "origin", "dev:main", "--json"], catch_exceptions=False
178 )
179
180 assert result.exit_code == 0, result.output
181 data = json.loads(result.output)
182 assert data["status"] == "pushed"
183 assert data["branch"] == "main"
184 assert data["head"] == commit.commit_id
185
186 # The commit walk / unpack call targeted "main" on the remote, but
187 # the commit itself is local dev's tip.
188 _, args, kwargs = transport.push_mpack_unpack.mock_calls[0]
189 assert kwargs["branch"] == "main"
190 assert kwargs["head"] == commit.commit_id
191
192
193 class TestRefspecForcePrefix:
194 def test_rs08_force_prefix_equivalent_to_force_flag(
195 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
196 ) -> None:
197 root = _bare_repo(tmp_path, monkeypatch)
198 commit = _make_commit(root, "rs08", branch="dev")
199 (heads_dir(root) / "dev").write_text(commit.commit_id)
200
201 transport = _mpack_push_transport(commit.commit_id, remote_branch="main")
202
203 with patch("muse.cli.commands.push.make_transport", return_value=transport):
204 result = runner.invoke(
205 cli, ["push", "origin", "+dev:main", "--json"], catch_exceptions=False
206 )
207
208 assert result.exit_code == 0, result.output
209 data = json.loads(result.output)
210 assert data["force"] is True
211
212 _, _, kwargs = transport.push_mpack_unpack.mock_calls[0]
213 assert kwargs["force"] is True
214
215
216 class TestRefspecNewRemoteBranch:
217 def test_rs09_pushing_to_nonexistent_remote_branch_creates_it(
218 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
219 ) -> None:
220 """No special handling needed — the server already supports creating
221 a new branch by name; this just confirms the refspec's remote name
222 reaches the server unchanged."""
223 root = _bare_repo(tmp_path, monkeypatch)
224 commit = _make_commit(root, "rs09", branch="dev")
225 (heads_dir(root) / "dev").write_text(commit.commit_id)
226
227 transport = _mpack_push_transport(commit.commit_id, remote_branch="nonexistent")
228
229 with patch("muse.cli.commands.push.make_transport", return_value=transport):
230 result = runner.invoke(
231 cli, ["push", "origin", "dev:nonexistent", "--json"], catch_exceptions=False
232 )
233
234 assert result.exit_code == 0, result.output
235 data = json.loads(result.output)
236 assert data["branch"] == "nonexistent"
237
238 _, _, kwargs = transport.push_mpack_unpack.mock_calls[0]
239 assert kwargs["branch"] == "nonexistent"
240
241
242 class TestRefspecInvalid:
243 def test_rs10_trailing_colon_exits_nonzero_before_network_call(
244 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
245 ) -> None:
246 root = _bare_repo(tmp_path, monkeypatch)
247 commit = _make_commit(root, "rs10", branch="dev")
248 (heads_dir(root) / "dev").write_text(commit.commit_id)
249
250 transport = MagicMock()
251 transport.fetch_remote_info.side_effect = AssertionError(
252 "invalid refspec must be rejected before any network call"
253 )
254
255 with patch("muse.cli.commands.push.make_transport", return_value=transport):
256 result = runner.invoke(
257 cli, ["push", "origin", "dev:", "--json"], catch_exceptions=False
258 )
259
260 assert result.exit_code != 0
261 data = json.loads(result.output)
262 assert data["error"] == "invalid_refspec"
263 transport.fetch_remote_info.assert_not_called()
File History 1 commit
sha256:1c3d22f581aa652cf47f38f4dc32816f888bffc5fa53b579dc6f139f333a6090 feat(push): refspec syntax local:remote for cross-branch pu… Sonnet 5 patch 2 days ago