gabriel / muse public

test_reflog_ref_resolution.py file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 chore(timeline): remove unused RationalRate import in entity.py · · Jul 10, 2026
1 """Phase 5 — RL_21–RL_27: @{N} ref resolution.
2
3 TDD: tests written before implementation.
4
5 Coverage tiers
6 --------------
7 RL_21 muse rev-parse @{0} --json resolves to the commit_id at HEAD reflog index 0
8 RL_22 muse reset @{1} --json resets HEAD to reflog index 1; muse read confirms it
9 RL_23 muse read @{2} --json reads the commit at HEAD reflog index 2
10 RL_24 dev@{0} resolves to the dev branch reflog's newest entry commit ID
11 RL_25 Out-of-range index exits USER_ERROR with valid-range message
12 RL_26 muse diff @{1} HEAD --json shows the diff between reflog state 1 and HEAD
13 RL_27 End-to-end recovery: commit A → commit B → muse reset @{1} → back at A
14 """
15 from __future__ import annotations
16
17 import datetime
18 import json
19 import pathlib
20 import time
21
22 import pytest
23
24 from muse.core.paths import muse_dir, logs_dir
25 from muse.core.types import NULL_COMMIT_ID
26 from tests.cli_test_helper import CliRunner
27
28 runner = CliRunner()
29 cli = None
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35 def _env(root: pathlib.Path) -> dict[str, str]:
36 return {"MUSE_REPO_ROOT": str(root)}
37
38
39 def _head_log(root: pathlib.Path) -> pathlib.Path:
40 return logs_dir(root) / "HEAD"
41
42
43 def _branch_log(root: pathlib.Path, branch: str) -> pathlib.Path:
44 return logs_dir(root) / "refs" / "heads" / branch
45
46
47 def _write_reflog_entry(
48 log_path: pathlib.Path,
49 old_id: str,
50 new_id: str,
51 operation: str,
52 ) -> None:
53 """Append one raw reflog line (oldest-first order, newest appended last)."""
54 ts = int(time.time())
55 line = f"{old_id} {new_id} user {ts} +0000\t{operation}\n"
56 log_path.parent.mkdir(parents=True, exist_ok=True)
57 with log_path.open("a", encoding="utf-8") as fh:
58 fh.write(line)
59
60
61 def _make_commit(
62 root: pathlib.Path,
63 parent_id: str | None,
64 branch: str,
65 message: str,
66 filename: str,
67 content: bytes,
68 ) -> str:
69 """Write a commit to the object store and return its commit_id.
70
71 Does NOT advance the branch ref — callers do that.
72 """
73 from muse.core.commits import CommitRecord, write_commit
74 from muse.core.ids import hash_commit, hash_snapshot
75 from muse.core.object_store import write_object
76 from muse.core.snapshots import SnapshotRecord, write_snapshot
77 from muse.core.types import blob_id
78
79 oid = blob_id(content)
80 write_object(root, oid, content)
81 snap_manifest = {filename: oid}
82 snap_id = hash_snapshot(snap_manifest)
83 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=snap_manifest))
84 ts = datetime.datetime.now(tz=datetime.timezone.utc)
85 parent_ids = [parent_id] if parent_id else []
86 cid = hash_commit(
87 parent_ids=parent_ids,
88 snapshot_id=snap_id,
89 message=message,
90 committed_at_iso=ts.isoformat(),
91 )
92 write_commit(root, CommitRecord(
93 commit_id=cid, branch=branch, snapshot_id=snap_id,
94 message=message, committed_at=ts,
95 parent_commit_id=parent_id,
96 ))
97 return cid
98
99
100 def _set_branch(root: pathlib.Path, branch: str, commit_id: str) -> None:
101 """Unconditionally advance a branch ref to commit_id."""
102 (muse_dir(root) / "refs" / "heads" / branch).write_text(commit_id)
103
104
105 def _make_base_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
106 """Create the minimal directory structure for a Muse repo (no commits)."""
107 from muse._version import __version__
108
109 dot = muse_dir(tmp_path)
110 for sub in ("refs/heads", "objects/sha256", "commits", "snapshots",
111 "logs/refs/heads", "logs"):
112 (dot / sub).mkdir(parents=True, exist_ok=True)
113 (dot / "repo.json").write_text(
114 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
115 )
116 (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
117 (dot / "config.toml").write_text("")
118 return tmp_path
119
120
121 def _setup_repo_with_history(
122 tmp_path: pathlib.Path,
123 n_commits: int = 3,
124 branch: str = "main",
125 ) -> tuple[pathlib.Path, list[str]]:
126 """Create a repo with n_commits and matching reflog entries.
127
128 Returns (root, commit_ids) where commit_ids[0] is the oldest and
129 commit_ids[-1] is the current HEAD (newest).
130
131 Reflog is written oldest-first so:
132 index 0 (newest) = commit_ids[-1]
133 index 1 = commit_ids[-2]
134 index 2 = commit_ids[-3]
135 """
136 root = _make_base_repo(tmp_path, branch=branch)
137 commit_ids: list[str] = []
138 prev: str | None = None
139
140 for i in range(n_commits):
141 cid = _make_commit(
142 root, prev, branch,
143 message=f"commit-{i}",
144 filename=f"file{i}.txt",
145 content=f"content-{i}\n".encode(),
146 )
147 commit_ids.append(cid)
148 old = prev if prev is not None else NULL_COMMIT_ID
149 _write_reflog_entry(_head_log(root), old, cid, f"commit: commit-{i}")
150 _write_reflog_entry(_branch_log(root, branch), old, cid, f"commit: commit-{i}")
151 prev = cid
152
153 # Set branch to the newest commit.
154 _set_branch(root, branch, commit_ids[-1])
155 return root, commit_ids
156
157
158 # ---------------------------------------------------------------------------
159 # RL_21 — muse rev-parse @{0} resolves to commit_id at HEAD reflog index 0
160 # ---------------------------------------------------------------------------
161
162 class TestRevParseAtN:
163 """RL_21: rev-parse @{N} resolves via the HEAD reflog."""
164
165 def test_at_zero_resolves_to_newest_commit(self, tmp_path: pathlib.Path) -> None:
166 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
167 newest = cids[-1]
168
169 r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root))
170
171 assert r.exit_code == 0, r.output
172 data = json.loads(r.output)
173 assert data["commit_id"] == newest
174
175 def test_at_one_resolves_to_second_newest(self, tmp_path: pathlib.Path) -> None:
176 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
177
178 r = runner.invoke(cli, ["rev-parse", "@{1}", "--json"], env=_env(root))
179
180 assert r.exit_code == 0, r.output
181 data = json.loads(r.output)
182 assert data["commit_id"] == cids[-2]
183
184 def test_at_zero_text_mode_prints_commit_id(self, tmp_path: pathlib.Path) -> None:
185 root, cids = _setup_repo_with_history(tmp_path, n_commits=2)
186
187 r = runner.invoke(cli, ["rev-parse", "@{0}"], env=_env(root))
188
189 assert r.exit_code == 0, r.output
190 assert r.output.strip() == cids[-1]
191
192 def test_ref_field_echoes_at_spec(self, tmp_path: pathlib.Path) -> None:
193 root, cids = _setup_repo_with_history(tmp_path, n_commits=2)
194
195 r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root))
196
197 data = json.loads(r.output)
198 assert data["ref"] == "@{0}"
199
200
201 # ---------------------------------------------------------------------------
202 # RL_22 — muse reset @{1} resets HEAD to reflog index 1
203 # ---------------------------------------------------------------------------
204
205 class TestResetAtN:
206 """RL_22: reset @{N} moves the branch pointer to the commit at index N."""
207
208 def test_reset_at_one_moves_to_second_commit(self, tmp_path: pathlib.Path) -> None:
209 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
210
211 r = runner.invoke(cli, ["reset", "@{1}", "--json"], env=_env(root))
212
213 assert r.exit_code == 0, r.output
214 data = json.loads(r.output)
215 assert data["new_commit_id"] == cids[-2]
216
217 def test_reset_at_two_moves_to_oldest_commit(self, tmp_path: pathlib.Path) -> None:
218 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
219
220 runner.invoke(cli, ["reset", "@{2}"], env=_env(root))
221
222 r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root))
223 data = json.loads(r.output)
224 assert data["commit_id"] == cids[0]
225
226 def test_branch_ref_advances_after_reset(self, tmp_path: pathlib.Path) -> None:
227 """After muse reset @{1}, the branch ref file must contain the target."""
228 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
229 target = cids[-2]
230
231 runner.invoke(cli, ["reset", "@{1}"], env=_env(root))
232
233 from muse.core.refs import read_ref
234 from muse.core.paths import ref_path
235 actual = read_ref(ref_path(root, "main"))
236 assert actual == target
237
238
239 # ---------------------------------------------------------------------------
240 # RL_23 — muse read @{2} reads the commit at HEAD reflog index 2
241 # ---------------------------------------------------------------------------
242
243 class TestReadAtN:
244 """RL_23: muse read @{N} reads the commit at reflog index N."""
245
246 def test_read_at_zero_shows_newest_commit(self, tmp_path: pathlib.Path) -> None:
247 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
248
249 r = runner.invoke(cli, ["read", "@{0}", "--json"], env=_env(root))
250
251 assert r.exit_code == 0, r.output
252 data = json.loads(r.output)
253 assert data["commit_id"] == cids[-1]
254
255 def test_read_at_two_shows_oldest_commit(self, tmp_path: pathlib.Path) -> None:
256 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
257
258 r = runner.invoke(cli, ["read", "@{2}", "--json"], env=_env(root))
259
260 assert r.exit_code == 0, r.output
261 data = json.loads(r.output)
262 assert data["commit_id"] == cids[0]
263
264 def test_read_at_n_shows_correct_message(self, tmp_path: pathlib.Path) -> None:
265 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
266
267 r = runner.invoke(cli, ["read", "@{1}", "--json"], env=_env(root))
268
269 data = json.loads(r.output)
270 assert data["message"] == "commit-1"
271
272
273 # ---------------------------------------------------------------------------
274 # RL_24 — dev@{0} resolves to the dev branch reflog's newest commit ID
275 # ---------------------------------------------------------------------------
276
277 class TestBranchAtN:
278 """RL_24: <branch>@{N} resolves via the named branch reflog."""
279
280 def test_branch_at_zero_resolves_to_newest(self, tmp_path: pathlib.Path) -> None:
281 root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="dev")
282
283 r = runner.invoke(cli, ["rev-parse", "dev@{0}", "--json"], env=_env(root))
284
285 assert r.exit_code == 0, r.output
286 data = json.loads(r.output)
287 assert data["commit_id"] == cids[-1]
288
289 def test_branch_at_one_resolves_via_branch_log(self, tmp_path: pathlib.Path) -> None:
290 root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="dev")
291
292 r = runner.invoke(cli, ["rev-parse", "dev@{1}", "--json"], env=_env(root))
293
294 assert r.exit_code == 0, r.output
295 data = json.loads(r.output)
296 assert data["commit_id"] == cids[-2]
297
298 def test_refs_heads_prefix_form_accepted(self, tmp_path: pathlib.Path) -> None:
299 """refs/heads/dev@{0} is accepted alongside the short form dev@{0}."""
300 root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="dev")
301
302 r = runner.invoke(
303 cli, ["rev-parse", "refs/heads/dev@{0}", "--json"], env=_env(root)
304 )
305
306 assert r.exit_code == 0, r.output
307 data = json.loads(r.output)
308 assert data["commit_id"] == cids[-1]
309
310 def test_head_log_and_branch_log_resolve_independently(
311 self, tmp_path: pathlib.Path
312 ) -> None:
313 """@{0} and main@{0} can differ when HEAD has moved since last branch op."""
314 root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="main")
315
316 # @{0} uses HEAD log → newest commit on main
317 r_head = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root))
318 # main@{0} uses main branch log → also newest (in this scenario they match)
319 r_branch = runner.invoke(
320 cli, ["rev-parse", "main@{0}", "--json"], env=_env(root)
321 )
322
323 assert r_head.exit_code == 0 and r_branch.exit_code == 0
324 assert json.loads(r_head.output)["commit_id"] == cids[-1]
325 assert json.loads(r_branch.output)["commit_id"] == cids[-1]
326
327
328 # ---------------------------------------------------------------------------
329 # RL_25 — Out-of-range index exits USER_ERROR with valid-range message
330 # ---------------------------------------------------------------------------
331
332 class TestAtNOutOfRange:
333 """RL_25: @{N} where N >= total entries → USER_ERROR with range in message."""
334
335 def test_out_of_range_rev_parse_exits_user_error(
336 self, tmp_path: pathlib.Path
337 ) -> None:
338 root, _ = _setup_repo_with_history(tmp_path, n_commits=2)
339
340 r = runner.invoke(cli, ["rev-parse", "@{99}", "--json"], env=_env(root))
341
342 assert r.exit_code == 1, r.output
343
344 def test_out_of_range_error_mentions_valid_range(
345 self, tmp_path: pathlib.Path
346 ) -> None:
347 """Error message must include the valid range so the user knows what's available."""
348 root, _ = _setup_repo_with_history(tmp_path, n_commits=2)
349 # 2 entries → valid indices are 0 and 1
350
351 r = runner.invoke(cli, ["rev-parse", "@{99}", "--json"], env=_env(root))
352
353 combined = r.output + (r.stderr or "")
354 # "0" and "1" (the valid range bounds) must appear
355 assert "0" in combined and "1" in combined
356
357 def test_out_of_range_reset_exits_user_error(
358 self, tmp_path: pathlib.Path
359 ) -> None:
360 root, _ = _setup_repo_with_history(tmp_path, n_commits=2)
361
362 r = runner.invoke(cli, ["reset", "@{99}", "--json"], env=_env(root))
363
364 assert r.exit_code != 0
365
366 def test_no_log_file_gives_user_error(self, tmp_path: pathlib.Path) -> None:
367 root = _make_base_repo(tmp_path)
368 # No reflog written
369
370 r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root))
371
372 assert r.exit_code == 1
373
374 def test_branch_at_n_out_of_range(self, tmp_path: pathlib.Path) -> None:
375 root, _ = _setup_repo_with_history(tmp_path, n_commits=1, branch="main")
376
377 r = runner.invoke(cli, ["rev-parse", "main@{5}", "--json"], env=_env(root))
378
379 assert r.exit_code == 1
380
381
382 # ---------------------------------------------------------------------------
383 # RL_26 — muse diff @{1} HEAD shows the diff between reflog state 1 and HEAD
384 # ---------------------------------------------------------------------------
385
386 class TestDiffAtN:
387 """RL_26: muse diff @{1} HEAD resolves both sides via reflog."""
388
389 def test_diff_at_one_head_exits_zero(self, tmp_path: pathlib.Path) -> None:
390 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
391
392 r = runner.invoke(
393 cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root)
394 )
395
396 assert r.exit_code == 0, r.output
397
398 def test_diff_at_one_head_has_required_json_fields(
399 self, tmp_path: pathlib.Path
400 ) -> None:
401 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
402
403 r = runner.invoke(
404 cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root)
405 )
406
407 data = json.loads(r.output)
408 for field in ("added", "modified", "deleted"):
409 assert field in data, f"missing field: {field!r}"
410
411 def test_diff_at_one_head_shows_changes(self, tmp_path: pathlib.Path) -> None:
412 """Each commit adds a new file so diff @{1} HEAD should show file2.txt added."""
413 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
414
415 r = runner.invoke(
416 cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root)
417 )
418
419 data = json.loads(r.output)
420 # commit-2 added file2.txt; commit-1 is @{1}
421 assert "file2.txt" in data["added"]
422
423 def test_diff_at_zero_head_is_empty(self, tmp_path: pathlib.Path) -> None:
424 """Diff between @{0} and HEAD should have no changes (same commit)."""
425 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
426
427 r = runner.invoke(
428 cli, ["diff", "@{0}", "HEAD", "--json"], env=_env(root)
429 )
430
431 data = json.loads(r.output)
432 assert data["total_changes"] == 0
433
434
435 # ---------------------------------------------------------------------------
436 # RL_27 — End-to-end recovery: commit A → commit B → reset @{1} → back at A
437 # ---------------------------------------------------------------------------
438
439 class TestEndToEndRecovery:
440 """RL_27: the full undo-safety-net workflow using @{N} as a commit ref."""
441
442 def test_reset_at_one_restores_previous_commit(
443 self, tmp_path: pathlib.Path
444 ) -> None:
445 """Commit A, commit B, then muse reset @{1} restores commit A."""
446 root, cids = _setup_repo_with_history(tmp_path, n_commits=2)
447 commit_a = cids[0] # oldest — reflog index 1
448 commit_b = cids[1] # newest — reflog index 0 (current HEAD)
449
450 # Sanity: HEAD is currently at commit B.
451 r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root))
452 assert json.loads(r.output)["commit_id"] == commit_b
453
454 # Recovery step: reset to the previous commit (index 1 = commit A).
455 r_reset = runner.invoke(cli, ["reset", "@{1}", "--json"], env=_env(root))
456 assert r_reset.exit_code == 0, r_reset.output
457
458 # Verify: HEAD is now at commit A.
459 r_read = runner.invoke(cli, ["read", "--json"], env=_env(root))
460 assert r_read.exit_code == 0, r_read.output
461 data = json.loads(r_read.output)
462 assert data["commit_id"] == commit_a
463
464 def test_message_at_restored_commit_is_correct(
465 self, tmp_path: pathlib.Path
466 ) -> None:
467 """After muse reset @{1}, muse read shows the message from commit A."""
468 root, cids = _setup_repo_with_history(tmp_path, n_commits=2)
469
470 runner.invoke(cli, ["reset", "@{1}"], env=_env(root))
471
472 r = runner.invoke(cli, ["read", "--json"], env=_env(root))
473 data = json.loads(r.output)
474 assert data["message"] == "commit-0"
475
476 def test_rev_parse_after_reset_shows_old_id(
477 self, tmp_path: pathlib.Path
478 ) -> None:
479 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
480 target = cids[0] # will be at @{2}
481
482 runner.invoke(cli, ["reset", "@{2}"], env=_env(root))
483
484 r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root))
485 assert json.loads(r.output)["commit_id"] == target
486
487 def test_three_commit_chain_recovery(self, tmp_path: pathlib.Path) -> None:
488 """Commit A → B → C, then reset @{2} restores A."""
489 root, cids = _setup_repo_with_history(tmp_path, n_commits=3)
490 commit_a, commit_b, commit_c = cids
491
492 runner.invoke(cli, ["reset", "@{2}"], env=_env(root))
493
494 r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root))
495 assert json.loads(r.output)["commit_id"] == commit_a
496
497
498 # ---------------------------------------------------------------------------
499 # Edge cases — resolve_reflog_ref
500 # ---------------------------------------------------------------------------
501
502 class TestResolveReflogRefUnit:
503 """Unit tests for the resolve_reflog_ref core function."""
504
505 def test_returns_none_for_non_at_spec(self, tmp_path: pathlib.Path) -> None:
506 from muse.core.reflog import resolve_reflog_ref
507
508 root = _make_base_repo(tmp_path)
509 assert resolve_reflog_ref("main", root) is None
510 assert resolve_reflog_ref("HEAD", root) is None
511 assert resolve_reflog_ref("sha256:abc", root) is None
512
513 def test_returns_new_id_for_valid_index(self, tmp_path: pathlib.Path) -> None:
514 from muse.core.reflog import resolve_reflog_ref
515
516 root, cids = _setup_repo_with_history(tmp_path, n_commits=2)
517
518 result = resolve_reflog_ref("@{0}", root)
519 assert result == cids[-1]
520
521 def test_raises_file_not_found_when_no_log(self, tmp_path: pathlib.Path) -> None:
522 from muse.core.reflog import resolve_reflog_ref
523
524 root = _make_base_repo(tmp_path)
525
526 with pytest.raises(FileNotFoundError):
527 resolve_reflog_ref("@{0}", root)
528
529 def test_raises_index_error_out_of_range(self, tmp_path: pathlib.Path) -> None:
530 from muse.core.reflog import resolve_reflog_ref
531
532 root, _ = _setup_repo_with_history(tmp_path, n_commits=2)
533
534 with pytest.raises(IndexError) as exc_info:
535 resolve_reflog_ref("@{99}", root)
536 idx, total = exc_info.value.args
537 assert idx == 99
538 assert total == 2
539
540 def test_branch_spec_reads_branch_log(self, tmp_path: pathlib.Path) -> None:
541 from muse.core.reflog import resolve_reflog_ref
542
543 root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="feat")
544
545 result = resolve_reflog_ref("feat@{0}", root)
546 assert result == cids[-1]
547
548 def test_refs_heads_prefix_stripped(self, tmp_path: pathlib.Path) -> None:
549 from muse.core.reflog import resolve_reflog_ref
550
551 root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="main")
552
553 result = resolve_reflog_ref("refs/heads/main@{0}", root)
554 assert result == cids[-1]