gabriel / muse public
test_reflog_remote_sharing.py python
341 lines 13.3 KB
Raw
sha256:bbaa4948a0e72cfa68f5d62f76f176f0c11153b513826d6fc84df500a11db79c feat: muse#54 item 4 — remote reflog sharing via push/pull Sonnet 5 minor ⚠ breaking 16 hours ago
1 """muse#54 item 4 -- remote reflog sharing.
2
3 Covers:
4 - core: reflog_entries_to_wire() / merge_remote_reflog_entries() / read_remote_reflog()
5 - CLI: `muse push --reflog` sends local entries, `muse pull` fetches and
6 merges them into the remote-tracking namespace, `muse reflog --remote`
7 reads them back.
8 """
9 from __future__ import annotations
10
11 import datetime
12 import json
13 import pathlib
14 from unittest.mock import MagicMock, patch
15
16 import pytest
17
18 from muse.core.types import fake_id, NULL_COMMIT_ID
19 from muse.core.paths import muse_dir, ref_path, reflog_branch_path, reflog_remote_path
20 from muse.core.reflog import (
21 ReflogEntry,
22 append_reflog,
23 merge_remote_reflog_entries,
24 read_remote_reflog,
25 reflog_entries_to_wire,
26 )
27 from tests.cli_test_helper import CliRunner
28
29 cli = None
30 runner = CliRunner()
31
32
33 def _env(root: pathlib.Path) -> dict:
34 return {"MUSE_REPO_ROOT": str(root)}
35
36
37 def _run(root: pathlib.Path, *args: str):
38 return runner.invoke(cli, list(args), env=_env(root))
39
40
41 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
42 dot_muse = muse_dir(tmp_path)
43 dot_muse.mkdir()
44 repo_id = fake_id("repo")
45 (dot_muse / "repo.json").write_text(json.dumps({
46 "repo_id": repo_id, "domain": "code", "default_branch": "main",
47 "created_at": "2025-01-01T00:00:00+00:00",
48 }), encoding="utf-8")
49 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
50 (dot_muse / "refs" / "heads").mkdir(parents=True)
51 (dot_muse / "snapshots").mkdir()
52 (dot_muse / "commits").mkdir()
53 (dot_muse / "objects").mkdir()
54 (dot_muse / "config.toml").write_text(
55 '[remotes.local]\nurl = "https://localhost:1337/gabriel/myrepo"\n',
56 encoding="utf-8",
57 )
58 return tmp_path, repo_id
59
60
61 def _commit_file(root: pathlib.Path, content: str, message: str) -> str:
62 (root / "f.txt").write_text(content)
63 add = _run(root, "code", "add", "f.txt")
64 assert add.exit_code == 0, add.output
65 commit = _run(root, "commit", "-m", message, "--json")
66 assert commit.exit_code == 0, commit.output
67 return json.loads(commit.output)["commit_id"]
68
69
70 def _fake_commit_id(n: int) -> str:
71 return f"{n:064x}"
72
73
74 def _mock_push_transport(local_head: str) -> MagicMock:
75 from muse.core.mpack import RemoteInfo
76 transport = MagicMock()
77 transport.fetch_remote_info.return_value = RemoteInfo(
78 domain="code", default_branch="main", branch_heads={},
79 )
80 transport.push_mpack_presign.return_value = {
81 "upload_url": "https://minio.example.com/put?sig=x",
82 "mpack_key": "sha256:" + "a" * 64,
83 }
84 transport.push_mpack_put.return_value = None
85 transport.push_mpack_unpack.return_value = {
86 "job_id": "j", "head": local_head, "branch": "main",
87 "blobs_in_mpack": 0, "commits_in_mpack": 1,
88 }
89 return transport
90
91
92 class TestReflogEntriesToWire:
93 def test_normalizes_ids_to_canonical_form(self) -> None:
94 entry = ReflogEntry(
95 old_id=NULL_COMMIT_ID,
96 new_id="a" * 64,
97 author="gabriel",
98 timestamp=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
99 operation="commit: c1",
100 )
101 wire = reflog_entries_to_wire([entry])
102 assert len(wire) == 1
103 assert wire[0]["old_id"] == f"sha256:{NULL_COMMIT_ID}"
104 assert wire[0]["new_id"] == "sha256:" + "a" * 64
105 assert wire[0]["operation"] == "commit: c1"
106 assert wire[0]["author"] == "gabriel"
107
108
109 class TestMergeRemoteReflogEntries:
110 def test_merges_new_entries_into_remote_namespace(self, tmp_path: pathlib.Path) -> None:
111 root, repo_id = _init_repo(tmp_path)
112 wire_entries = [{
113 "old_id": NULL_COMMIT_ID, "new_id": _fake_commit_id(1),
114 "author": "gabriel", "timestamp": "2026-01-01T00:00:00+00:00",
115 "operation": "commit: c1",
116 }]
117 merged = merge_remote_reflog_entries(root, "local", "main", wire_entries)
118 assert merged == 1
119
120 entries = read_remote_reflog(root, "local", "main")
121 assert len(entries) == 1
122 assert entries[0].new_id == _fake_commit_id(1)
123
124 def test_does_not_touch_local_branch_reflog(self, tmp_path: pathlib.Path) -> None:
125 """Merging remote entries must never write into the local branch's
126 own reflog -- only its remote-tracking namespace."""
127 root, repo_id = _init_repo(tmp_path)
128 local_log = reflog_branch_path(root, "main")
129 assert not local_log.exists()
130
131 merge_remote_reflog_entries(root, "local", "main", [{
132 "old_id": NULL_COMMIT_ID, "new_id": _fake_commit_id(1),
133 "author": "gabriel", "timestamp": "2026-01-01T00:00:00+00:00",
134 "operation": "commit: c1",
135 }])
136 assert not local_log.exists()
137
138 remote_log = reflog_remote_path(root, "local", "main")
139 assert remote_log.exists()
140
141 def test_idempotent_on_repeat_merge(self, tmp_path: pathlib.Path) -> None:
142 root, repo_id = _init_repo(tmp_path)
143 wire_entries = [{
144 "old_id": NULL_COMMIT_ID, "new_id": _fake_commit_id(1),
145 "author": "gabriel", "timestamp": "2026-01-01T00:00:00+00:00",
146 "operation": "commit: c1",
147 }]
148 first = merge_remote_reflog_entries(root, "local", "main", wire_entries)
149 second = merge_remote_reflog_entries(root, "local", "main", wire_entries)
150 assert first == 1
151 assert second == 0
152
153 entries = read_remote_reflog(root, "local", "main")
154 assert len(entries) == 1
155
156 def test_separate_remotes_do_not_collide(self, tmp_path: pathlib.Path) -> None:
157 root, repo_id = _init_repo(tmp_path)
158 merge_remote_reflog_entries(root, "local", "main", [{
159 "old_id": NULL_COMMIT_ID, "new_id": _fake_commit_id(1),
160 "author": "alice", "timestamp": "2026-01-01T00:00:00+00:00",
161 "operation": "commit: c1",
162 }])
163 merge_remote_reflog_entries(root, "staging", "main", [{
164 "old_id": NULL_COMMIT_ID, "new_id": _fake_commit_id(2),
165 "author": "bob", "timestamp": "2026-01-01T00:00:00+00:00",
166 "operation": "commit: c2",
167 }])
168
169 local_entries = read_remote_reflog(root, "local", "main")
170 staging_entries = read_remote_reflog(root, "staging", "main")
171 assert len(local_entries) == 1
172 assert len(staging_entries) == 1
173 assert local_entries[0].new_id == _fake_commit_id(1)
174 assert staging_entries[0].new_id == _fake_commit_id(2)
175
176 def test_empty_entries_is_noop(self, tmp_path: pathlib.Path) -> None:
177 root, repo_id = _init_repo(tmp_path)
178 assert merge_remote_reflog_entries(root, "local", "main", []) == 0
179
180
181 class TestPushReflogFlag:
182 def test_push_with_reflog_calls_transport(
183 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
184 ) -> None:
185 root, repo_id = _init_repo(tmp_path)
186 commit_id = _commit_file(root, "v1\n", "c1")
187 monkeypatch.setenv("MUSE_REPO_ROOT", str(root))
188 monkeypatch.chdir(root)
189
190 transport = _mock_push_transport(commit_id)
191 transport.push_reflog.return_value = {"stored": 1, "skipped": 0}
192
193 with patch("muse.cli.commands.push.make_transport", return_value=transport):
194 result = _run(root, "push", "local", "main", "--reflog", "--json")
195
196 assert result.exit_code == 0, result.output
197 data = json.loads(result.output)
198 assert data["reflog_pushed"] == 1
199 transport.push_reflog.assert_called_once()
200 call_args = transport.push_reflog.call_args
201 assert call_args[0][2] == "main" # branch positional arg
202
203 def test_push_without_reflog_flag_does_not_call_transport(
204 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
205 ) -> None:
206 root, repo_id = _init_repo(tmp_path)
207 commit_id = _commit_file(root, "v1\n", "c1")
208 monkeypatch.setenv("MUSE_REPO_ROOT", str(root))
209 monkeypatch.chdir(root)
210
211 transport = _mock_push_transport(commit_id)
212
213 with patch("muse.cli.commands.push.make_transport", return_value=transport):
214 result = _run(root, "push", "local", "main", "--json")
215
216 assert result.exit_code == 0, result.output
217 data = json.loads(result.output)
218 assert data["reflog_pushed"] == 0
219 assert data["reflog_skipped"] == 0
220 transport.push_reflog.assert_not_called()
221
222 def test_push_reflog_config_flag(
223 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
224 ) -> None:
225 root, repo_id = _init_repo(tmp_path)
226 commit_id = _commit_file(root, "v1\n", "c1")
227 monkeypatch.setenv("MUSE_REPO_ROOT", str(root))
228 monkeypatch.chdir(root)
229
230 config_result = _run(root, "config", "set", "push.reflog", "true", "--json")
231 assert config_result.exit_code == 0, config_result.output
232
233 transport = _mock_push_transport(commit_id)
234 transport.push_reflog.return_value = {"stored": 1, "skipped": 0}
235
236 with patch("muse.cli.commands.push.make_transport", return_value=transport):
237 result = _run(root, "push", "local", "main", "--json")
238
239 assert result.exit_code == 0, result.output
240 transport.push_reflog.assert_called_once()
241
242
243 class TestPullFetchesReflog:
244 def test_pull_merges_reflog_entries(
245 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
246 ) -> None:
247 root, repo_id = _init_repo(tmp_path)
248 commit_id = _commit_file(root, "v1\n", "c1")
249 monkeypatch.setenv("MUSE_REPO_ROOT", str(root))
250 monkeypatch.chdir(root)
251
252 from muse.core.mpack import RemoteInfo
253
254 transport = MagicMock()
255 transport.fetch_remote_info.return_value = RemoteInfo(
256 domain="code", default_branch="main", branch_heads={"main": commit_id},
257 )
258 transport.fetch_mpack.return_value = {
259 "repo_id": repo_id, "domain": "code", "default_branch": "main",
260 "branch_heads": {"main": commit_id},
261 "commits": [], "snapshots": [], "blobs": [],
262 "blobs_received": 0, "shallow_commits": [],
263 }
264 transport.fetch_version_tags.return_value = {"tags": []}
265 transport.fetch_reflog.return_value = {"entries": [
266 {
267 "old_id": NULL_COMMIT_ID, "new_id": commit_id,
268 "author": "gabriel", "timestamp": "2026-01-01T00:00:00+00:00",
269 "operation": "commit: c1",
270 },
271 ]}
272
273 with patch("muse.cli.commands.pull.make_transport", return_value=transport):
274 result = _run(root, "pull", "local", "main", "--json")
275
276 assert result.exit_code == 0, result.output
277 transport.fetch_reflog.assert_called_once()
278
279 entries = read_remote_reflog(root, "local", "main")
280 assert len(entries) == 1
281 assert entries[0].new_id == commit_id.split(":", 1)[1]
282
283 def test_pull_reflog_fetch_failure_is_non_fatal(
284 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
285 ) -> None:
286 root, repo_id = _init_repo(tmp_path)
287 commit_id = _commit_file(root, "v1\n", "c1")
288 monkeypatch.setenv("MUSE_REPO_ROOT", str(root))
289 monkeypatch.chdir(root)
290
291 from muse.core.mpack import RemoteInfo
292 from muse.core.transport import TransportError
293
294 transport = MagicMock()
295 transport.fetch_remote_info.return_value = RemoteInfo(
296 domain="code", default_branch="main", branch_heads={"main": commit_id},
297 )
298 transport.fetch_mpack.return_value = {
299 "repo_id": repo_id, "domain": "code", "default_branch": "main",
300 "branch_heads": {"main": commit_id},
301 "commits": [], "snapshots": [], "blobs": [],
302 "blobs_received": 0, "shallow_commits": [],
303 }
304 transport.fetch_version_tags.return_value = {"tags": []}
305 transport.fetch_reflog.side_effect = TransportError("boom", 500)
306
307 with patch("muse.cli.commands.pull.make_transport", return_value=transport):
308 result = _run(root, "pull", "local", "main", "--json")
309
310 assert result.exit_code == 0, result.output
311
312
313 class TestReflogRemoteReadCli:
314 def test_reflog_remote_flag_reads_merged_entries(self, tmp_path: pathlib.Path) -> None:
315 root, repo_id = _init_repo(tmp_path)
316 merge_remote_reflog_entries(root, "local", "main", [{
317 "old_id": NULL_COMMIT_ID, "new_id": _fake_commit_id(1),
318 "author": "gabriel", "timestamp": "2026-01-01T00:00:00+00:00",
319 "operation": "commit: c1",
320 }])
321
322 result = _run(root, "reflog", "--remote", "local", "--branch", "main", "--json")
323 assert result.exit_code == 0, result.output
324 data = json.loads(result.output)
325 assert data["ref"] == "refs/remotes/local/main"
326 assert data["total"] == 1
327
328 def test_remote_without_branch_errors(self, tmp_path: pathlib.Path) -> None:
329 root, repo_id = _init_repo(tmp_path)
330 result = _run(root, "reflog", "--remote", "local", "--json")
331 assert result.exit_code == 1
332
333 def test_remote_and_tag_mutually_exclusive(self, tmp_path: pathlib.Path) -> None:
334 root, repo_id = _init_repo(tmp_path)
335 result = _run(root, "reflog", "--remote", "local", "--branch", "main", "--tag", "v1.0.0", "--json")
336 assert result.exit_code == 1
337
338 def test_invalid_remote_name_rejected(self, tmp_path: pathlib.Path) -> None:
339 root, repo_id = _init_repo(tmp_path)
340 result = _run(root, "reflog", "--remote", "../escape", "--branch", "main", "--json")
341 assert result.exit_code == 1
File History 1 commit
sha256:bbaa4948a0e72cfa68f5d62f76f176f0c11153b513826d6fc84df500a11db79c feat: muse#54 item 4 — remote reflog sharing via push/pull Sonnet 5 minor ⚠ 16 hours ago