gabriel / muse public
test_cmd_reflog_coverage.py python
649 lines 31.2 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
1 """Phase 1 — RL_01–RL_05: reflog coverage for ``muse pull`` and ``muse shelf``.
2
3 TDD: tests are written before the implementation. Each class maps to one or
4 more deliverables from issue #47, Phase 1.
5
6 Coverage tiers
7 --------------
8 RL_01 pull (fast-forward, bootstrap, three-way merge) calls append_reflog
9 RL_02 shelf pop calls append_reflog
10 RL_03 shelf apply calls append_reflog
11 RL_04 append_reflog failure is non-fatal in pull, shelf pop, shelf apply
12 RL_05 integration: fast-forward pull writes real entry to HEAD log on disk
13 integration: shelf pop writes real entry to HEAD log on disk
14 """
15 from __future__ import annotations
16
17 import datetime
18 import json
19 import pathlib
20 from unittest.mock import MagicMock, call, patch
21
22 import pytest
23
24 from muse.core.paths import muse_dir, logs_dir
25 from muse.core.types import blob_id
26 from tests.cli_test_helper import CliRunner
27
28 runner = CliRunner()
29 cli = None # argparse migration stub — CliRunner.invoke() requires this as first arg
30
31 # ---------------------------------------------------------------------------
32 # Shared repo helpers
33 # ---------------------------------------------------------------------------
34
35 def _make_repo(
36 tmp_path: pathlib.Path,
37 branch: str = "main",
38 *,
39 with_commit: bool = True,
40 ) -> tuple[pathlib.Path, str | None]:
41 """Create a minimal Muse repo. Returns (root, head_commit_id | None)."""
42 from muse._version import __version__
43 from muse.core.commits import CommitRecord, write_commit
44 from muse.core.ids import hash_commit, hash_snapshot
45 from muse.core.object_store import write_object
46 from muse.core.snapshots import SnapshotRecord, write_snapshot
47
48 dot = muse_dir(tmp_path)
49 for sub in ("refs/heads", "objects", "commits", "snapshots", "logs/refs/heads", "logs"):
50 (dot / sub).mkdir(parents=True, exist_ok=True)
51 (dot / "repo.json").write_text(
52 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
53 )
54 (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
55 (dot / "config.toml").write_text(
56 '[remotes.origin]\nurl = "https://hub.example.com/r"\n'
57 )
58
59 if not with_commit:
60 return tmp_path, None
61
62 blob = b"hello\n"
63 oid = blob_id(blob)
64 write_object(tmp_path, oid, blob)
65 snap_id = hash_snapshot({"a.txt": oid})
66 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest={"a.txt": oid}))
67 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
68 cid = hash_commit(parent_ids=[], snapshot_id=snap_id, message="init", committed_at_iso=ts.isoformat())
69 write_commit(tmp_path, CommitRecord(
70 commit_id=cid, branch=branch, snapshot_id=snap_id,
71 message="init", committed_at=ts,
72 ))
73 (dot / "refs" / "heads" / branch).write_text(cid)
74 return tmp_path, cid
75
76
77 def _make_next_commit(
78 root: pathlib.Path,
79 parent_id: str | None = None,
80 branch: str = "main",
81 ) -> str:
82 """Write a commit to the store (does NOT advance the branch ref).
83
84 Returns the new commit ID. The caller controls when (and if) the branch
85 ref is advanced — this lets us simulate 'commit exists in store but remote
86 hasn't been pulled yet'. Pass ``parent_id=None`` for an initial commit
87 (no parent).
88 """
89 from muse.core.commits import CommitRecord, write_commit
90 from muse.core.ids import hash_commit, hash_snapshot
91 from muse.core.object_store import write_object
92 from muse.core.snapshots import SnapshotRecord, write_snapshot
93
94 blob = b"world\n"
95 oid = blob_id(blob)
96 write_object(root, oid, blob)
97 snap_id = hash_snapshot({"a.txt": oid})
98 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"a.txt": oid}))
99 ts = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
100 parent_ids = [parent_id] if parent_id else []
101 cid = hash_commit(
102 parent_ids=parent_ids, snapshot_id=snap_id,
103 message="update", committed_at_iso=ts.isoformat(),
104 )
105 write_commit(root, CommitRecord(
106 commit_id=cid, branch=branch, snapshot_id=snap_id,
107 message="update", committed_at=ts,
108 parent_commit_id=parent_id if parent_id else None,
109 ))
110 return cid
111
112
113 def _env(root: pathlib.Path) -> dict[str, str]:
114 return {"MUSE_REPO_ROOT": str(root)}
115
116
117 def _remote_info(branch_heads: dict[str, str]) -> dict:
118 return {
119 "repo_id": "test-repo",
120 "domain": "code",
121 "default_branch": "main",
122 "branch_heads": branch_heads,
123 }
124
125
126 def _read_head_log(root: pathlib.Path) -> list[str]:
127 """Return non-empty lines from the HEAD reflog, newest-first."""
128 from muse.core.reflog import read_reflog
129 entries = read_reflog(root, branch=None, limit=100)
130 return [e.operation for e in entries]
131
132
133 def _read_branch_log(root: pathlib.Path, branch: str = "main") -> list[str]:
134 """Return operation strings from the branch reflog, newest-first."""
135 from muse.core.reflog import read_reflog
136 entries = read_reflog(root, branch=branch, limit=100)
137 return [e.operation for e in entries]
138
139
140 # ---------------------------------------------------------------------------
141 # RL_01 — pull writes a reflog entry (mocked transport)
142 # ---------------------------------------------------------------------------
143
144 class TestPullReflogFastForward:
145 """RL_01 fast-forward path: write_branch_ref advances to theirs_commit_id."""
146
147 def test_calls_append_reflog_on_fast_forward(self, tmp_path: pathlib.Path) -> None:
148 root, commit_a = _make_repo(tmp_path)
149 assert commit_a is not None
150 commit_b = _make_next_commit(root, commit_a)
151
152 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
153 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
154 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
155 with patch("muse.cli.commands.pull.make_transport") as mt:
156 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
157 mt.return_value.fetch_mpack.return_value = {
158 "commits": [], "snapshots": [], "blobs_received": 0
159 }
160 with patch("muse.cli.commands.pull.apply_mpack", return_value={
161 "commits_written": 1, "snapshots_written": 1,
162 "blobs_written": 0, "blobs_skipped": 0,
163 }):
164 with patch("muse.cli.commands.pull.set_remote_head"):
165 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
166 r = runner.invoke(cli, ["pull", "--json"], env=_env(root))
167
168 assert r.exit_code == 0, r.output
169 mock_rl.assert_called_once()
170 call_kwargs = mock_rl.call_args
171 assert call_kwargs[0][0] == root # repo_root
172 assert call_kwargs[0][1] == "main" # branch
173 assert call_kwargs[1]["old_id"] == commit_a
174 assert call_kwargs[1]["new_id"] == commit_b
175 assert "pull" in call_kwargs[1]["operation"]
176
177 def test_operation_string_includes_remote_and_branch(self, tmp_path: pathlib.Path) -> None:
178 root, commit_a = _make_repo(tmp_path)
179 assert commit_a is not None
180 commit_b = _make_next_commit(root, commit_a)
181
182 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
183 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
184 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
185 with patch("muse.cli.commands.pull.make_transport") as mt:
186 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
187 mt.return_value.fetch_mpack.return_value = {
188 "commits": [], "snapshots": [], "blobs_received": 0
189 }
190 with patch("muse.cli.commands.pull.apply_mpack", return_value={
191 "commits_written": 1, "snapshots_written": 1,
192 "blobs_written": 0, "blobs_skipped": 0,
193 }):
194 with patch("muse.cli.commands.pull.set_remote_head"):
195 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
196 runner.invoke(cli, ["pull", "origin", "main"], env=_env(root))
197
198 op = mock_rl.call_args[1]["operation"]
199 assert "origin" in op
200 assert "main" in op
201
202
203 class TestPullReflogBootstrap:
204 """RL_01 bootstrap path: ours_commit_id is None (empty repo)."""
205
206 def test_calls_append_reflog_on_bootstrap(self, tmp_path: pathlib.Path) -> None:
207 """Bootstrap pull (no local commits yet) must write a reflog entry."""
208 root, _ = _make_repo(tmp_path, with_commit=False)
209 # Create the remote commit in the store so read_commit succeeds.
210 # No parent — this is the initial commit on the remote.
211 commit_b = _make_next_commit(root, parent_id=None)
212
213 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
214 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
215 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
216 with patch("muse.cli.commands.pull.make_transport") as mt:
217 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
218 mt.return_value.fetch_mpack.return_value = {
219 "commits": [], "snapshots": [], "blobs_received": 0
220 }
221 with patch("muse.cli.commands.pull.apply_mpack", return_value={
222 "commits_written": 1, "snapshots_written": 1,
223 "blobs_written": 0, "blobs_skipped": 0,
224 }):
225 with patch("muse.cli.commands.pull.set_remote_head"):
226 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
227 r = runner.invoke(cli, ["pull", "--json"], env=_env(root))
228
229 assert r.exit_code == 0, r.output
230 mock_rl.assert_called_once()
231 call_kwargs = mock_rl.call_args
232 # old_id must be None (no previous commit)
233 assert call_kwargs[1]["old_id"] is None
234 assert call_kwargs[1]["new_id"] == commit_b
235
236
237 class TestPullReflogMerge:
238 """RL_01 three-way merge path: diverged branches produce a merge commit."""
239
240 def test_calls_append_reflog_on_three_way_merge(self, tmp_path: pathlib.Path) -> None:
241 """Three-way merge must call append_reflog with the new merge commit ID."""
242 from muse.core.commits import CommitRecord, write_commit
243 from muse.core.ids import hash_commit, hash_snapshot
244 from muse.core.object_store import write_object
245 from muse.core.snapshots import SnapshotRecord, write_snapshot
246
247 root, commit_a = _make_repo(tmp_path)
248 assert commit_a is not None
249
250 # Diverged remote commit (shares parent A, different content).
251 blob_r = b"remote\n"
252 oid_r = blob_id(blob_r)
253 write_object(root, oid_r, blob_r)
254 snap_r = hash_snapshot({"b.txt": oid_r})
255 write_snapshot(root, SnapshotRecord(snapshot_id=snap_r, manifest={"b.txt": oid_r}))
256 ts_r = datetime.datetime(2026, 1, 3, tzinfo=datetime.timezone.utc)
257 commit_remote = hash_commit(
258 parent_ids=[commit_a], snapshot_id=snap_r,
259 message="remote change", committed_at_iso=ts_r.isoformat(),
260 )
261 write_commit(root, CommitRecord(
262 commit_id=commit_remote, branch="main", snapshot_id=snap_r,
263 message="remote change", committed_at=ts_r, parent_commit_id=commit_a,
264 ))
265
266 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
267 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
268 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
269 with patch("muse.cli.commands.pull.make_transport") as mt:
270 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_remote})
271 mt.return_value.fetch_mpack.return_value = {
272 "commits": [], "snapshots": [], "blobs_received": 0
273 }
274 with patch("muse.cli.commands.pull.apply_mpack", return_value={
275 "commits_written": 1, "snapshots_written": 1,
276 "blobs_written": 0, "blobs_skipped": 0,
277 }):
278 with patch("muse.cli.commands.pull.set_remote_head"):
279 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
280 r = runner.invoke(cli, ["pull", "--json"], env=_env(root))
281
282 assert r.exit_code == 0, r.output
283 mock_rl.assert_called_once()
284 call_kwargs = mock_rl.call_args
285 assert call_kwargs[1]["old_id"] == commit_a
286 # new_id is a freshly-created merge commit — just verify it's not the old head
287 assert call_kwargs[1]["new_id"] != commit_a
288 assert "pull" in call_kwargs[1]["operation"]
289
290
291 class TestPullReflogNotCalled:
292 """append_reflog must NOT be called when the branch pointer does not move."""
293
294 def test_no_reflog_on_dry_run(self, tmp_path: pathlib.Path) -> None:
295 root, commit_a = _make_repo(tmp_path)
296 assert commit_a is not None
297 commit_b = _make_next_commit(root, commit_a)
298
299 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
300 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
301 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
302 with patch("muse.cli.commands.pull.make_transport") as mt:
303 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
304 mt.return_value.fetch_mpack.return_value = {
305 "commits": [], "snapshots": [], "blobs_received": 0
306 }
307 with patch("muse.cli.commands.pull.apply_mpack", return_value={
308 "commits_written": 1, "snapshots_written": 1,
309 "blobs_written": 0, "blobs_skipped": 0,
310 }):
311 with patch("muse.cli.commands.pull.set_remote_head"):
312 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
313 r = runner.invoke(cli, ["pull", "--dry-run"], env=_env(root))
314
315 assert r.exit_code == 0, r.output
316 mock_rl.assert_not_called()
317
318 def test_no_reflog_on_no_merge(self, tmp_path: pathlib.Path) -> None:
319 """--no-merge fetches but does not advance the local branch."""
320 root, commit_a = _make_repo(tmp_path)
321 assert commit_a is not None
322 commit_b = _make_next_commit(root, commit_a)
323
324 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
325 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
326 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
327 with patch("muse.cli.commands.pull.make_transport") as mt:
328 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
329 mt.return_value.fetch_mpack.return_value = {
330 "commits": [], "snapshots": [], "blobs_received": 0
331 }
332 with patch("muse.cli.commands.pull.apply_mpack", return_value={
333 "commits_written": 1, "snapshots_written": 1,
334 "blobs_written": 0, "blobs_skipped": 0,
335 }):
336 with patch("muse.cli.commands.pull.set_remote_head"):
337 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
338 r = runner.invoke(cli, ["pull", "--no-merge"], env=_env(root))
339
340 assert r.exit_code == 0, r.output
341 mock_rl.assert_not_called()
342
343 def test_no_reflog_when_already_up_to_date(self, tmp_path: pathlib.Path) -> None:
344 root, commit_a = _make_repo(tmp_path)
345 assert commit_a is not None
346
347 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
348 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
349 with patch("muse.cli.commands.pull.get_remote_head", return_value=commit_a):
350 with patch("muse.cli.commands.pull.make_transport") as mt:
351 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_a})
352 with patch("muse.cli.commands.pull.append_reflog") as mock_rl:
353 runner.invoke(cli, ["pull"], env=_env(root))
354
355 mock_rl.assert_not_called()
356
357
358 # ---------------------------------------------------------------------------
359 # RL_04 — pull reflog failure is non-fatal
360 # ---------------------------------------------------------------------------
361
362 class TestPullReflogNonFatal:
363 """RL_04: an exception from append_reflog must never abort pull."""
364
365 def test_ff_pull_completes_despite_reflog_error(self, tmp_path: pathlib.Path) -> None:
366 root, commit_a = _make_repo(tmp_path)
367 assert commit_a is not None
368 commit_b = _make_next_commit(root, commit_a)
369
370 def _explode(*args, **kwargs):
371 raise OSError("disk full")
372
373 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
374 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
375 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
376 with patch("muse.cli.commands.pull.make_transport") as mt:
377 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
378 mt.return_value.fetch_mpack.return_value = {
379 "commits": [], "snapshots": [], "blobs_received": 0
380 }
381 with patch("muse.cli.commands.pull.apply_mpack", return_value={
382 "commits_written": 1, "snapshots_written": 1,
383 "blobs_written": 0, "blobs_skipped": 0,
384 }):
385 with patch("muse.cli.commands.pull.set_remote_head"):
386 with patch("muse.cli.commands.pull.append_reflog", side_effect=_explode):
387 r = runner.invoke(cli, ["pull", "--json"], env=_env(root))
388
389 # Pull must succeed even though reflog write exploded.
390 assert r.exit_code == 0, r.output
391 # Branch pointer must have advanced.
392 from muse.core.refs import get_head_commit_id
393 assert get_head_commit_id(root, "main") == commit_b
394
395
396 # ---------------------------------------------------------------------------
397 # RL_02 — shelf pop writes a reflog entry
398 # ---------------------------------------------------------------------------
399
400 def _make_shelf_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
401 """Create a repo with a commit, save a shelf entry, return (root, entry_name)."""
402 root, commit_a = _make_repo(tmp_path)
403 assert commit_a is not None
404
405 # Write a file to disk so shelf save has something to capture.
406 (root / "work.txt").write_text("in progress")
407
408 # Save a shelf entry via the CLI.
409 r = runner.invoke(cli, ["shelf", "save", "my-work", "--json"], env=_env(root))
410 assert r.exit_code == 0, f"shelf save failed: {r.output}"
411 return root, "my-work"
412
413
414 class TestShelfPopReflog:
415 """RL_02: shelf pop writes a reflog entry."""
416
417 def test_calls_append_reflog_on_pop(self, tmp_path: pathlib.Path) -> None:
418 root, entry_name = _make_shelf_repo(tmp_path)
419
420 with patch("muse.cli.commands.shelf.append_reflog") as mock_rl:
421 r = runner.invoke(cli, ["shelf", "pop", entry_name, "--json"], env=_env(root))
422
423 assert r.exit_code == 0, r.output
424 mock_rl.assert_called_once()
425
426 def test_operation_string_contains_name(self, tmp_path: pathlib.Path) -> None:
427 root, entry_name = _make_shelf_repo(tmp_path)
428
429 with patch("muse.cli.commands.shelf.append_reflog") as mock_rl:
430 runner.invoke(cli, ["shelf", "pop", entry_name], env=_env(root))
431
432 op = mock_rl.call_args[1]["operation"]
433 assert entry_name in op
434 assert "shelf-pop" in op
435
436 def test_reflog_branch_is_current_branch(self, tmp_path: pathlib.Path) -> None:
437 root, entry_name = _make_shelf_repo(tmp_path)
438
439 with patch("muse.cli.commands.shelf.append_reflog") as mock_rl:
440 runner.invoke(cli, ["shelf", "pop", entry_name], env=_env(root))
441
442 branch_arg = mock_rl.call_args[0][1]
443 assert branch_arg == "main"
444
445 def test_new_id_is_current_head(self, tmp_path: pathlib.Path) -> None:
446 root, entry_name = _make_shelf_repo(tmp_path)
447 from muse.core.refs import get_head_commit_id
448 head = get_head_commit_id(root, "main")
449
450 with patch("muse.cli.commands.shelf.append_reflog") as mock_rl:
451 runner.invoke(cli, ["shelf", "pop", entry_name], env=_env(root))
452
453 new_id = mock_rl.call_args[1]["new_id"]
454 assert new_id == head
455
456
457 # ---------------------------------------------------------------------------
458 # RL_03 — shelf apply writes a reflog entry
459 # ---------------------------------------------------------------------------
460
461 class TestShelfApplyReflog:
462 """RL_03: shelf apply writes a reflog entry (entry is kept in the shelf)."""
463
464 def test_calls_append_reflog_on_apply(self, tmp_path: pathlib.Path) -> None:
465 root, entry_name = _make_shelf_repo(tmp_path)
466
467 with patch("muse.cli.commands.shelf.append_reflog") as mock_rl:
468 r = runner.invoke(cli, ["shelf", "apply", entry_name, "--json"], env=_env(root))
469
470 assert r.exit_code == 0, r.output
471 mock_rl.assert_called_once()
472
473 def test_operation_string_contains_name(self, tmp_path: pathlib.Path) -> None:
474 root, entry_name = _make_shelf_repo(tmp_path)
475
476 with patch("muse.cli.commands.shelf.append_reflog") as mock_rl:
477 runner.invoke(cli, ["shelf", "apply", entry_name], env=_env(root))
478
479 op = mock_rl.call_args[1]["operation"]
480 assert entry_name in op
481 assert "shelf-apply" in op
482
483 def test_entry_survives_after_apply(self, tmp_path: pathlib.Path) -> None:
484 """Unlike pop, apply keeps the shelf entry."""
485 root, entry_name = _make_shelf_repo(tmp_path)
486
487 with patch("muse.cli.commands.shelf.append_reflog"):
488 runner.invoke(cli, ["shelf", "apply", entry_name], env=_env(root))
489
490 r = runner.invoke(cli, ["shelf", "list", "--json"], env=_env(root))
491 data = json.loads(r.output)
492 names = [e["name"] for e in data.get("entries", [])]
493 assert entry_name in names, "apply must not remove the shelf entry"
494
495
496 # ---------------------------------------------------------------------------
497 # RL_04 — shelf reflog failure is non-fatal
498 # ---------------------------------------------------------------------------
499
500 class TestShelfReflogNonFatal:
501 """RL_04: an exception from append_reflog must never abort shelf pop or apply."""
502
503 def test_shelf_pop_completes_despite_reflog_error(self, tmp_path: pathlib.Path) -> None:
504 root, entry_name = _make_shelf_repo(tmp_path)
505 (root / "work.txt").unlink(missing_ok=True) # reset working tree
506
507 def _explode(*args, **kwargs):
508 raise OSError("read-only filesystem")
509
510 with patch("muse.cli.commands.shelf.append_reflog", side_effect=_explode):
511 r = runner.invoke(cli, ["shelf", "pop", entry_name, "--json"], env=_env(root))
512
513 assert r.exit_code == 0, r.output
514 # File must still have been restored.
515 assert (root / "work.txt").exists(), "shelf pop must restore files even if reflog fails"
516
517 def test_shelf_apply_completes_despite_reflog_error(self, tmp_path: pathlib.Path) -> None:
518 root, entry_name = _make_shelf_repo(tmp_path)
519 (root / "work.txt").unlink(missing_ok=True)
520
521 def _explode(*args, **kwargs):
522 raise PermissionError("no write access to .muse/logs")
523
524 with patch("muse.cli.commands.shelf.append_reflog", side_effect=_explode):
525 r = runner.invoke(cli, ["shelf", "apply", entry_name, "--json"], env=_env(root))
526
527 assert r.exit_code == 0, r.output
528 assert (root / "work.txt").exists(), "shelf apply must restore files even if reflog fails"
529
530
531 # ---------------------------------------------------------------------------
532 # RL_05 — integration: real log entries written to disk
533 # ---------------------------------------------------------------------------
534
535 class TestReflogIntegration:
536 """RL_05: verify that the actual .muse/logs/ files are updated."""
537
538 def test_ff_pull_writes_head_log_entry(self, tmp_path: pathlib.Path) -> None:
539 """Fast-forward pull must append a line to .muse/logs/HEAD."""
540 root, commit_a = _make_repo(tmp_path)
541 assert commit_a is not None
542 commit_b = _make_next_commit(root, commit_a)
543
544 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
545 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
546 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
547 with patch("muse.cli.commands.pull.make_transport") as mt:
548 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
549 mt.return_value.fetch_mpack.return_value = {
550 "commits": [], "snapshots": [], "blobs_received": 0
551 }
552 with patch("muse.cli.commands.pull.apply_mpack", return_value={
553 "commits_written": 1, "snapshots_written": 1,
554 "blobs_written": 0, "blobs_skipped": 0,
555 }):
556 with patch("muse.cli.commands.pull.set_remote_head"):
557 r = runner.invoke(cli, ["pull", "--json"], env=_env(root))
558
559 assert r.exit_code == 0, r.output
560
561 ops = _read_head_log(root)
562 assert ops, "HEAD log must have at least one entry after pull"
563 assert any("pull" in op for op in ops), f"No pull entry in HEAD log; found: {ops}"
564
565 def test_ff_pull_writes_branch_log_entry(self, tmp_path: pathlib.Path) -> None:
566 """Fast-forward pull must also append to the branch-specific log."""
567 root, commit_a = _make_repo(tmp_path)
568 assert commit_a is not None
569 commit_b = _make_next_commit(root, commit_a)
570
571 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
572 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
573 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
574 with patch("muse.cli.commands.pull.make_transport") as mt:
575 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
576 mt.return_value.fetch_mpack.return_value = {
577 "commits": [], "snapshots": [], "blobs_received": 0
578 }
579 with patch("muse.cli.commands.pull.apply_mpack", return_value={
580 "commits_written": 1, "snapshots_written": 1,
581 "blobs_written": 0, "blobs_skipped": 0,
582 }):
583 with patch("muse.cli.commands.pull.set_remote_head"):
584 runner.invoke(cli, ["pull"], env=_env(root))
585
586 ops = _read_branch_log(root, "main")
587 assert any("pull" in op for op in ops), f"No pull entry in main log; found: {ops}"
588
589 def test_ff_pull_old_and_new_ids_in_log(self, tmp_path: pathlib.Path) -> None:
590 """The log entry must reference the correct old and new commit IDs."""
591 from muse.core.reflog import read_reflog
592
593 root, commit_a = _make_repo(tmp_path)
594 assert commit_a is not None
595 commit_b = _make_next_commit(root, commit_a)
596
597 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
598 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
599 with patch("muse.cli.commands.pull.get_remote_head", return_value=None):
600 with patch("muse.cli.commands.pull.make_transport") as mt:
601 mt.return_value.fetch_remote_info.return_value = _remote_info({"main": commit_b})
602 mt.return_value.fetch_mpack.return_value = {
603 "commits": [], "snapshots": [], "blobs_received": 0
604 }
605 with patch("muse.cli.commands.pull.apply_mpack", return_value={
606 "commits_written": 1, "snapshots_written": 1,
607 "blobs_written": 0, "blobs_skipped": 0,
608 }):
609 with patch("muse.cli.commands.pull.set_remote_head"):
610 runner.invoke(cli, ["pull"], env=_env(root))
611
612 entries = read_reflog(root, branch=None, limit=10)
613 assert entries, "HEAD log must have entries"
614 entry = entries[0]
615 # old_id and new_id are stored as bare hex; commit_a and commit_b are sha256:-prefixed
616 assert commit_a.removeprefix("sha256:") in entry.old_id or entry.old_id == commit_a
617 assert commit_b.removeprefix("sha256:") in entry.new_id or entry.new_id == commit_b
618
619 def test_shelf_pop_writes_head_log_entry(self, tmp_path: pathlib.Path) -> None:
620 """shelf pop must write an entry to .muse/logs/HEAD."""
621 root, entry_name = _make_shelf_repo(tmp_path)
622
623 r = runner.invoke(cli, ["shelf", "pop", entry_name, "--json"], env=_env(root))
624 assert r.exit_code == 0, r.output
625
626 ops = _read_head_log(root)
627 assert any("shelf-pop" in op for op in ops), f"No shelf-pop entry in HEAD log; found: {ops}"
628
629 def test_shelf_apply_writes_head_log_entry(self, tmp_path: pathlib.Path) -> None:
630 """shelf apply must write an entry to .muse/logs/HEAD."""
631 root, entry_name = _make_shelf_repo(tmp_path)
632
633 r = runner.invoke(cli, ["shelf", "apply", entry_name, "--json"], env=_env(root))
634 assert r.exit_code == 0, r.output
635
636 ops = _read_head_log(root)
637 assert any("shelf-apply" in op for op in ops), f"No shelf-apply entry in HEAD log; found: {ops}"
638
639 def test_shelf_pop_log_references_entry_name(self, tmp_path: pathlib.Path) -> None:
640 """The shelf-pop log entry must name the shelf entry that was restored."""
641 from muse.core.reflog import read_reflog
642
643 root, entry_name = _make_shelf_repo(tmp_path)
644 runner.invoke(cli, ["shelf", "pop", entry_name], env=_env(root))
645
646 entries = read_reflog(root, branch=None, limit=10)
647 pop_entries = [e for e in entries if "shelf-pop" in e.operation]
648 assert pop_entries, "No shelf-pop entry found in HEAD log"
649 assert entry_name in pop_entries[0].operation
File History 4 commits
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
sha256:b7be56ec091919a612cffe7f3c8b600209d5155517443fdac0e16954c8c7d81f Merge 'task/git-export-ignored-file-deletion' into 'dev' — … Human 12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago