"""Phase 5 — RL_21–RL_27: @{N} ref resolution. TDD: tests written before implementation. Coverage tiers -------------- RL_21 muse rev-parse @{0} --json resolves to the commit_id at HEAD reflog index 0 RL_22 muse reset @{1} --json resets HEAD to reflog index 1; muse read confirms it RL_23 muse read @{2} --json reads the commit at HEAD reflog index 2 RL_24 dev@{0} resolves to the dev branch reflog's newest entry commit ID RL_25 Out-of-range index exits USER_ERROR with valid-range message RL_26 muse diff @{1} HEAD --json shows the diff between reflog state 1 and HEAD RL_27 End-to-end recovery: commit A → commit B → muse reset @{1} → back at A """ from __future__ import annotations import datetime import json import pathlib import time import pytest from muse.core.paths import muse_dir, logs_dir from muse.core.types import NULL_COMMIT_ID from tests.cli_test_helper import CliRunner runner = CliRunner() cli = None # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _env(root: pathlib.Path) -> dict[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _head_log(root: pathlib.Path) -> pathlib.Path: return logs_dir(root) / "HEAD" def _branch_log(root: pathlib.Path, branch: str) -> pathlib.Path: return logs_dir(root) / "refs" / "heads" / branch def _write_reflog_entry( log_path: pathlib.Path, old_id: str, new_id: str, operation: str, ) -> None: """Append one raw reflog line (oldest-first order, newest appended last).""" ts = int(time.time()) line = f"{old_id} {new_id} user {ts} +0000\t{operation}\n" log_path.parent.mkdir(parents=True, exist_ok=True) with log_path.open("a", encoding="utf-8") as fh: fh.write(line) def _make_commit( root: pathlib.Path, parent_id: str | None, branch: str, message: str, filename: str, content: bytes, ) -> str: """Write a commit to the object store and return its commit_id. Does NOT advance the branch ref — callers do that. """ from muse.core.commits import CommitRecord, write_commit from muse.core.ids import hash_commit, hash_snapshot from muse.core.object_store import write_object from muse.core.snapshots import SnapshotRecord, write_snapshot from muse.core.types import blob_id oid = blob_id(content) write_object(root, oid, content) snap_manifest = {filename: oid} snap_id = hash_snapshot(snap_manifest) write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=snap_manifest)) ts = datetime.datetime.now(tz=datetime.timezone.utc) parent_ids = [parent_id] if parent_id else [] cid = hash_commit( parent_ids=parent_ids, snapshot_id=snap_id, message=message, committed_at_iso=ts.isoformat(), ) write_commit(root, CommitRecord( commit_id=cid, branch=branch, snapshot_id=snap_id, message=message, committed_at=ts, parent_commit_id=parent_id, )) return cid def _set_branch(root: pathlib.Path, branch: str, commit_id: str) -> None: """Unconditionally advance a branch ref to commit_id.""" (muse_dir(root) / "refs" / "heads" / branch).write_text(commit_id) def _make_base_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path: """Create the minimal directory structure for a Muse repo (no commits).""" from muse._version import __version__ dot = muse_dir(tmp_path) for sub in ("refs/heads", "objects/sha256", "commits", "snapshots", "logs/refs/heads", "logs"): (dot / sub).mkdir(parents=True, exist_ok=True) (dot / "repo.json").write_text( json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"}) ) (dot / "HEAD").write_text(f"ref: refs/heads/{branch}\n") (dot / "config.toml").write_text("") return tmp_path def _setup_repo_with_history( tmp_path: pathlib.Path, n_commits: int = 3, branch: str = "main", ) -> tuple[pathlib.Path, list[str]]: """Create a repo with n_commits and matching reflog entries. Returns (root, commit_ids) where commit_ids[0] is the oldest and commit_ids[-1] is the current HEAD (newest). Reflog is written oldest-first so: index 0 (newest) = commit_ids[-1] index 1 = commit_ids[-2] index 2 = commit_ids[-3] """ root = _make_base_repo(tmp_path, branch=branch) commit_ids: list[str] = [] prev: str | None = None for i in range(n_commits): cid = _make_commit( root, prev, branch, message=f"commit-{i}", filename=f"file{i}.txt", content=f"content-{i}\n".encode(), ) commit_ids.append(cid) old = prev if prev is not None else NULL_COMMIT_ID _write_reflog_entry(_head_log(root), old, cid, f"commit: commit-{i}") _write_reflog_entry(_branch_log(root, branch), old, cid, f"commit: commit-{i}") prev = cid # Set branch to the newest commit. _set_branch(root, branch, commit_ids[-1]) return root, commit_ids # --------------------------------------------------------------------------- # RL_21 — muse rev-parse @{0} resolves to commit_id at HEAD reflog index 0 # --------------------------------------------------------------------------- class TestRevParseAtN: """RL_21: rev-parse @{N} resolves via the HEAD reflog.""" def test_at_zero_resolves_to_newest_commit(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) newest = cids[-1] r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == newest def test_at_one_resolves_to_second_newest(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke(cli, ["rev-parse", "@{1}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == cids[-2] def test_at_zero_text_mode_prints_commit_id(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=2) r = runner.invoke(cli, ["rev-parse", "@{0}"], env=_env(root)) assert r.exit_code == 0, r.output assert r.output.strip() == cids[-1] def test_ref_field_echoes_at_spec(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=2) r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) data = json.loads(r.output) assert data["ref"] == "@{0}" # --------------------------------------------------------------------------- # RL_22 — muse reset @{1} resets HEAD to reflog index 1 # --------------------------------------------------------------------------- class TestResetAtN: """RL_22: reset @{N} moves the branch pointer to the commit at index N.""" def test_reset_at_one_moves_to_second_commit(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke(cli, ["reset", "@{1}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["new_commit_id"] == cids[-2] def test_reset_at_two_moves_to_oldest_commit(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) runner.invoke(cli, ["reset", "@{2}"], env=_env(root)) r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) data = json.loads(r.output) assert data["commit_id"] == cids[0] def test_branch_ref_advances_after_reset(self, tmp_path: pathlib.Path) -> None: """After muse reset @{1}, the branch ref file must contain the target.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=3) target = cids[-2] runner.invoke(cli, ["reset", "@{1}"], env=_env(root)) from muse.core.refs import read_ref from muse.core.paths import ref_path actual = read_ref(ref_path(root, "main")) assert actual == target # --------------------------------------------------------------------------- # RL_23 — muse read @{2} reads the commit at HEAD reflog index 2 # --------------------------------------------------------------------------- class TestReadAtN: """RL_23: muse read @{N} reads the commit at reflog index N.""" def test_read_at_zero_shows_newest_commit(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke(cli, ["read", "@{0}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == cids[-1] def test_read_at_two_shows_oldest_commit(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke(cli, ["read", "@{2}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == cids[0] def test_read_at_n_shows_correct_message(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke(cli, ["read", "@{1}", "--json"], env=_env(root)) data = json.loads(r.output) assert data["message"] == "commit-1" # --------------------------------------------------------------------------- # RL_24 — dev@{0} resolves to the dev branch reflog's newest commit ID # --------------------------------------------------------------------------- class TestBranchAtN: """RL_24: @{N} resolves via the named branch reflog.""" def test_branch_at_zero_resolves_to_newest(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="dev") r = runner.invoke(cli, ["rev-parse", "dev@{0}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == cids[-1] def test_branch_at_one_resolves_via_branch_log(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="dev") r = runner.invoke(cli, ["rev-parse", "dev@{1}", "--json"], env=_env(root)) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == cids[-2] def test_refs_heads_prefix_form_accepted(self, tmp_path: pathlib.Path) -> None: """refs/heads/dev@{0} is accepted alongside the short form dev@{0}.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="dev") r = runner.invoke( cli, ["rev-parse", "refs/heads/dev@{0}", "--json"], env=_env(root) ) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["commit_id"] == cids[-1] def test_head_log_and_branch_log_resolve_independently( self, tmp_path: pathlib.Path ) -> None: """@{0} and main@{0} can differ when HEAD has moved since last branch op.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=3, branch="main") # @{0} uses HEAD log → newest commit on main r_head = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) # main@{0} uses main branch log → also newest (in this scenario they match) r_branch = runner.invoke( cli, ["rev-parse", "main@{0}", "--json"], env=_env(root) ) assert r_head.exit_code == 0 and r_branch.exit_code == 0 assert json.loads(r_head.output)["commit_id"] == cids[-1] assert json.loads(r_branch.output)["commit_id"] == cids[-1] # --------------------------------------------------------------------------- # RL_25 — Out-of-range index exits USER_ERROR with valid-range message # --------------------------------------------------------------------------- class TestAtNOutOfRange: """RL_25: @{N} where N >= total entries → USER_ERROR with range in message.""" def test_out_of_range_rev_parse_exits_user_error( self, tmp_path: pathlib.Path ) -> None: root, _ = _setup_repo_with_history(tmp_path, n_commits=2) r = runner.invoke(cli, ["rev-parse", "@{99}", "--json"], env=_env(root)) assert r.exit_code == 1, r.output def test_out_of_range_error_mentions_valid_range( self, tmp_path: pathlib.Path ) -> None: """Error message must include the valid range so the user knows what's available.""" root, _ = _setup_repo_with_history(tmp_path, n_commits=2) # 2 entries → valid indices are 0 and 1 r = runner.invoke(cli, ["rev-parse", "@{99}", "--json"], env=_env(root)) combined = r.output + (r.stderr or "") # "0" and "1" (the valid range bounds) must appear assert "0" in combined and "1" in combined def test_out_of_range_reset_exits_user_error( self, tmp_path: pathlib.Path ) -> None: root, _ = _setup_repo_with_history(tmp_path, n_commits=2) r = runner.invoke(cli, ["reset", "@{99}", "--json"], env=_env(root)) assert r.exit_code != 0 def test_no_log_file_gives_user_error(self, tmp_path: pathlib.Path) -> None: root = _make_base_repo(tmp_path) # No reflog written r = runner.invoke(cli, ["rev-parse", "@{0}", "--json"], env=_env(root)) assert r.exit_code == 1 def test_branch_at_n_out_of_range(self, tmp_path: pathlib.Path) -> None: root, _ = _setup_repo_with_history(tmp_path, n_commits=1, branch="main") r = runner.invoke(cli, ["rev-parse", "main@{5}", "--json"], env=_env(root)) assert r.exit_code == 1 # --------------------------------------------------------------------------- # RL_26 — muse diff @{1} HEAD shows the diff between reflog state 1 and HEAD # --------------------------------------------------------------------------- class TestDiffAtN: """RL_26: muse diff @{1} HEAD resolves both sides via reflog.""" def test_diff_at_one_head_exits_zero(self, tmp_path: pathlib.Path) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke( cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root) ) assert r.exit_code == 0, r.output def test_diff_at_one_head_has_required_json_fields( self, tmp_path: pathlib.Path ) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke( cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root) ) data = json.loads(r.output) for field in ("added", "modified", "deleted"): assert field in data, f"missing field: {field!r}" def test_diff_at_one_head_shows_changes(self, tmp_path: pathlib.Path) -> None: """Each commit adds a new file so diff @{1} HEAD should show file2.txt added.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke( cli, ["diff", "@{1}", "HEAD", "--json"], env=_env(root) ) data = json.loads(r.output) # commit-2 added file2.txt; commit-1 is @{1} assert "file2.txt" in data["added"] def test_diff_at_zero_head_is_empty(self, tmp_path: pathlib.Path) -> None: """Diff between @{0} and HEAD should have no changes (same commit).""" root, cids = _setup_repo_with_history(tmp_path, n_commits=3) r = runner.invoke( cli, ["diff", "@{0}", "HEAD", "--json"], env=_env(root) ) data = json.loads(r.output) assert data["total_changes"] == 0 # --------------------------------------------------------------------------- # RL_27 — End-to-end recovery: commit A → commit B → reset @{1} → back at A # --------------------------------------------------------------------------- class TestEndToEndRecovery: """RL_27: the full undo-safety-net workflow using @{N} as a commit ref.""" def test_reset_at_one_restores_previous_commit( self, tmp_path: pathlib.Path ) -> None: """Commit A, commit B, then muse reset @{1} restores commit A.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=2) commit_a = cids[0] # oldest — reflog index 1 commit_b = cids[1] # newest — reflog index 0 (current HEAD) # Sanity: HEAD is currently at commit B. r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) assert json.loads(r.output)["commit_id"] == commit_b # Recovery step: reset to the previous commit (index 1 = commit A). r_reset = runner.invoke(cli, ["reset", "@{1}", "--json"], env=_env(root)) assert r_reset.exit_code == 0, r_reset.output # Verify: HEAD is now at commit A. r_read = runner.invoke(cli, ["read", "--json"], env=_env(root)) assert r_read.exit_code == 0, r_read.output data = json.loads(r_read.output) assert data["commit_id"] == commit_a def test_message_at_restored_commit_is_correct( self, tmp_path: pathlib.Path ) -> None: """After muse reset @{1}, muse read shows the message from commit A.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=2) runner.invoke(cli, ["reset", "@{1}"], env=_env(root)) r = runner.invoke(cli, ["read", "--json"], env=_env(root)) data = json.loads(r.output) assert data["message"] == "commit-0" def test_rev_parse_after_reset_shows_old_id( self, tmp_path: pathlib.Path ) -> None: root, cids = _setup_repo_with_history(tmp_path, n_commits=3) target = cids[0] # will be at @{2} runner.invoke(cli, ["reset", "@{2}"], env=_env(root)) r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) assert json.loads(r.output)["commit_id"] == target def test_three_commit_chain_recovery(self, tmp_path: pathlib.Path) -> None: """Commit A → B → C, then reset @{2} restores A.""" root, cids = _setup_repo_with_history(tmp_path, n_commits=3) commit_a, commit_b, commit_c = cids runner.invoke(cli, ["reset", "@{2}"], env=_env(root)) r = runner.invoke(cli, ["rev-parse", "HEAD", "--json"], env=_env(root)) assert json.loads(r.output)["commit_id"] == commit_a # --------------------------------------------------------------------------- # Edge cases — resolve_reflog_ref # --------------------------------------------------------------------------- class TestResolveReflogRefUnit: """Unit tests for the resolve_reflog_ref core function.""" def test_returns_none_for_non_at_spec(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import resolve_reflog_ref root = _make_base_repo(tmp_path) assert resolve_reflog_ref("main", root) is None assert resolve_reflog_ref("HEAD", root) is None assert resolve_reflog_ref("sha256:abc", root) is None def test_returns_new_id_for_valid_index(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import resolve_reflog_ref root, cids = _setup_repo_with_history(tmp_path, n_commits=2) result = resolve_reflog_ref("@{0}", root) assert result == cids[-1] def test_raises_file_not_found_when_no_log(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import resolve_reflog_ref root = _make_base_repo(tmp_path) with pytest.raises(FileNotFoundError): resolve_reflog_ref("@{0}", root) def test_raises_index_error_out_of_range(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import resolve_reflog_ref root, _ = _setup_repo_with_history(tmp_path, n_commits=2) with pytest.raises(IndexError) as exc_info: resolve_reflog_ref("@{99}", root) idx, total = exc_info.value.args assert idx == 99 assert total == 2 def test_branch_spec_reads_branch_log(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import resolve_reflog_ref root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="feat") result = resolve_reflog_ref("feat@{0}", root) assert result == cids[-1] def test_refs_heads_prefix_stripped(self, tmp_path: pathlib.Path) -> None: from muse.core.reflog import resolve_reflog_ref root, cids = _setup_repo_with_history(tmp_path, n_commits=2, branch="main") result = resolve_reflog_ref("refs/heads/main@{0}", root) assert result == cids[-1]