"""Tests for ``muse symlog`` @{N} ref resolution — Phase 5 (SL_46–SL_55). TDD: this file is written first. All tests are expected to fail until Phase 5 is implemented. Test IDs -------- SL_46 resolve_symlog_addr(spec, repo_root) -> SymlogResolution | None SL_47 muse symlog resolve "file.py::Symbol@{0}" --json SL_48 muse code cat "billing.py::compute_total@{2}" --json (same shape) SL_49 muse code cat "billing.py::compute_total@{0}" == HEAD version SL_50 muse symlog diff "addr@{1}" "addr@{0}" --json (added/removed/context) SL_51 muse symlog diff "addr@{2}" HEAD --json (HEAD = working-tree version) SL_52 out-of-range @{N} exits USER_ERROR with "valid range: 0–N" SL_53 muse symlog resolve --follow: followed_from populated in JSON SL_54 integration: 3-commit history, code cat @{2} = commit A body SL_55 integration: rename pipeline, follow chain has correct entries """ from __future__ import annotations import argparse import json import os import pathlib import textwrap import time import pytest from muse.core.errors import ExitCode from muse.core.symlog import ( NULL_CONTENT_ID, append_symlog, read_symlog, symlog_path, ) from tests.cli_test_helper import CliRunner runner = CliRunner() # --------------------------------------------------------------------------- # Helpers — fake repo (no actual muse init needed for most unit tests) # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: """Minimal Muse repository skeleton — just enough for require_repo().""" muse_dir = tmp_path / ".muse" muse_dir.mkdir() (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") return tmp_path # --------------------------------------------------------------------------- # Helpers — real repo (actual muse init + commits) # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]): saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _init_repo(repo: pathlib.Path) -> None: repo.mkdir(parents=True, exist_ok=True) _invoke(repo, ["init"]) def _write_file(repo: pathlib.Path, rel: str, content: str) -> None: p = repo / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(textwrap.dedent(content)) def _commit(repo: pathlib.Path, msg: str) -> str: """Stage all and commit; return commit_id from JSON output.""" _invoke(repo, ["code", "add", "."]) result = _invoke(repo, [ "commit", "-m", msg, "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--author", "claude-code", "--json", ]) data = json.loads(result.stdout) return data["commit_id"] def _make_real_repo(tmp_path: pathlib.Path) -> pathlib.Path: _init_repo(tmp_path) return tmp_path # --------------------------------------------------------------------------- # Helpers — fake symlog entries # --------------------------------------------------------------------------- _FAKE_CID_A = "sha256:" + "a" * 64 _FAKE_CID_B = "sha256:" + "b" * 64 _FAKE_CID_C = "sha256:" + "c" * 64 _FAKE_COMMIT_A = "sha256:" + "1" * 64 _FAKE_COMMIT_B = "sha256:" + "2" * 64 _FAKE_COMMIT_C = "sha256:" + "3" * 64 def _write_symlog_entries( repo: pathlib.Path, addr: str, entries: list[tuple[str, str, str, str, str]], ) -> None: """Write entries to symlog for addr (oldest-first on disk). Each tuple: (old_cid, new_cid, commit_id, operation, timestamp_str) where timestamp_str is a unix timestamp as string. """ p = symlog_path(repo, addr) p.parent.mkdir(parents=True, exist_ok=True) lines = [] for old_cid, new_cid, commit_id, operation, ts_str in entries: lines.append( f"{old_cid} {new_cid} {commit_id} " f"claude-code {ts_str} +0000\t{operation}\n" ) with p.open("a", encoding="utf-8") as fh: fh.writelines(lines) def _invoke_symlog( capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, args: list[str], repo_root: pathlib.Path, ) -> pytest.CaptureResult: """Run ``muse symlog `` inline; does NOT swallow SystemExit.""" from muse.cli.commands import symlog as symlog_cmd monkeypatch.chdir(repo_root) parser = argparse.ArgumentParser() subparsers = parser.add_subparsers() symlog_cmd.register(subparsers) parsed = parser.parse_args(["symlog"] + args) parsed.func(parsed) return capsys.readouterr() # =========================================================================== # SL_46 — resolve_symlog_addr() core function # =========================================================================== class TestSL46ResolveSymlogAddr: def test_returns_none_for_non_at_spec(self, repo: pathlib.Path) -> None: """Returns None when spec has no @{N} suffix.""" from muse.core.symlog import resolve_symlog_addr result = resolve_symlog_addr("billing.py::compute_total", repo) assert result is None def test_raises_value_error_for_addr_without_separator(self, repo: pathlib.Path) -> None: """Raises ValueError when spec has @{N} but addr part has no :: separator.""" from muse.core.symlog import resolve_symlog_addr # billing.py@{0} → addr = "billing.py" which has no :: → symlog_path raises ValueError with pytest.raises((ValueError, FileNotFoundError)): resolve_symlog_addr("billing.py@{0}", repo) def test_returns_resolution_for_valid_spec(self, repo: pathlib.Path) -> None: """Returns SymlogResolution with all fields for a valid @{N} spec.""" from muse.core.symlog import resolve_symlog_addr, SymlogResolution addr = "src/billing.py::compute_total" now_ts = int(time.time()) _write_symlog_entries(repo, addr, [ (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A, "symbol-created: compute_total", str(now_ts - 2)), (_FAKE_CID_A, _FAKE_CID_B, _FAKE_COMMIT_B, "symbol-modified: fix rounding", str(now_ts - 1)), ]) # @{0} = newest = B, @{1} = older = A result = resolve_symlog_addr(f"{addr}@{{0}}", repo) assert isinstance(result, SymlogResolution) assert result.symbol == addr assert result.index == 0 assert result.content_id == _FAKE_CID_B assert result.commit_id == _FAKE_COMMIT_B assert "symbol-modified" in result.operation assert result.timestamp is not None assert result.followed_from is None def test_returns_correct_index_1_entry(self, repo: pathlib.Path) -> None: """@{1} resolves to the second-newest entry.""" from muse.core.symlog import resolve_symlog_addr addr = "src/billing.py::compute_total" now_ts = int(time.time()) _write_symlog_entries(repo, addr, [ (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A, "symbol-created: compute_total", str(now_ts - 2)), (_FAKE_CID_A, _FAKE_CID_B, _FAKE_COMMIT_B, "symbol-modified: fix rounding", str(now_ts - 1)), ]) result = resolve_symlog_addr(f"{addr}@{{1}}", repo) assert result is not None assert result.index == 1 assert result.content_id == _FAKE_CID_A assert result.commit_id == _FAKE_COMMIT_A assert "symbol-created" in result.operation def test_raises_file_not_found_for_missing_log(self, repo: pathlib.Path) -> None: """Raises FileNotFoundError when no symlog exists for the symbol.""" from muse.core.symlog import resolve_symlog_addr with pytest.raises(FileNotFoundError): resolve_symlog_addr("missing.py::no_such_fn@{0}", repo) def test_raises_index_error_for_out_of_range(self, repo: pathlib.Path) -> None: """Raises IndexError(index, total) when index >= total entries.""" from muse.core.symlog import resolve_symlog_addr addr = "src/billing.py::compute_total" now_ts = int(time.time()) _write_symlog_entries(repo, addr, [ (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A, "symbol-created: compute_total", str(now_ts)), ]) with pytest.raises(IndexError) as exc_info: resolve_symlog_addr(f"{addr}@{{5}}", repo) bad_index, total = exc_info.value.args assert bad_index == 5 assert total == 1 # =========================================================================== # SL_47 — muse symlog resolve CLI # =========================================================================== class TestSL47SymlogResolveCmd: _ADDR = "src/billing.py::compute_total" def _setup_log(self, repo: pathlib.Path, count: int = 2) -> int: now_ts = int(time.time()) entries = [ (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A, "symbol-created: compute_total", str(now_ts - 2)), (_FAKE_CID_A, _FAKE_CID_B, _FAKE_COMMIT_B, "symbol-modified: add rounding", str(now_ts - 1)), ] _write_symlog_entries(repo, self._ADDR, entries[:count]) return now_ts def test_json_all_fields_present( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """JSON output has all required fields.""" self._setup_log(repo) out = _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._ADDR}@{{0}}", "--json"], repo) data = json.loads(out.out) assert data["exit_code"] == 0 assert "duration_ms" in data assert data["symbol"] == self._ADDR assert data["index"] == 0 assert data["content_id"] == _FAKE_CID_B assert data["commit_id"] == _FAKE_COMMIT_B assert "operation" in data assert "timestamp" in data def test_human_text_output( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Human text shows the symbol and key fields.""" self._setup_log(repo) out = _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._ADDR}@{{1}}"], repo) text = out.out assert "compute_total" in text assert "@{1}" in text def test_missing_log_exits_user_error( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Missing symlog exits USER_ERROR.""" with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, ["resolve", "missing.py::no_fn@{0}", "--json"], repo) assert exc_info.value.code == ExitCode.USER_ERROR def test_out_of_range_exits_user_error( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Out-of-range @{N} exits USER_ERROR with valid range in message.""" self._setup_log(repo, count=2) with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._ADDR}@{{9}}", "--json"], repo) assert exc_info.value.code == ExitCode.USER_ERROR err = capsys.readouterr().err assert "valid range" in err.lower() or "valid range" in capsys.readouterr().err.lower() def test_out_of_range_error_mentions_range( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Error message mentions the valid range.""" self._setup_log(repo, count=2) with pytest.raises(SystemExit): _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._ADDR}@{{5}}", "--json"], repo) captured = capsys.readouterr() err_text = captured.err # Should say something like "valid range: @{0} to @{1}" or "0–1" assert "valid range" in err_text.lower() # =========================================================================== # SL_48/49 — muse code cat "addr@{N}" (needs real repo + commits) # =========================================================================== class TestSL48CodeCatAtN: """muse code cat "file.py::Symbol@{N}" returns same JSON shape.""" _FILE = "src/billing.py" _ADDR = "src/billing.py::compute_total" _BODY_A = textwrap.dedent("""\ def compute_total(items): return sum(items) """) _BODY_B = textwrap.dedent("""\ def compute_total(items): return round(sum(items), 2) """) @pytest.fixture() def two_commit_repo(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str]: """Repo with 2 commits; returns (repo, commit_a_id, commit_b_id).""" repo = _make_real_repo(tmp_path) _write_file(repo, self._FILE, self._BODY_A) cid_a = _commit(repo, "feat: add compute_total") _write_file(repo, self._FILE, self._BODY_B) cid_b = _commit(repo, "fix: round to 2 dp") return repo, cid_a, cid_b def test_json_shape_identical_to_normal_code_cat( self, two_commit_repo: tuple[pathlib.Path, str, str] ) -> None: """@{N} output has address, path, symbol, kind, source, source_ref.""" repo, _, _ = two_commit_repo result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"]) assert result.exit_code == 0, result.stdout + result.stderr data = json.loads(result.stdout) assert "results" in data assert len(data["results"]) == 1 entry = data["results"][0] assert "address" in entry assert "path" in entry assert "symbol" in entry assert "kind" in entry assert "source" in entry assert "source_ref" in entry def test_at0_returns_newest_body( self, two_commit_repo: tuple[pathlib.Path, str, str] ) -> None: """@{0} returns the body from the most recent commit that changed the symbol.""" repo, _, _ = two_commit_repo result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"]) assert result.exit_code == 0, result.stdout data = json.loads(result.stdout) source = data["results"][0]["source"] assert "round" in source def test_at1_returns_older_body( self, two_commit_repo: tuple[pathlib.Path, str, str] ) -> None: """@{1} returns the body from the first commit (original version).""" repo, _, _ = two_commit_repo result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{1}}", "--json"]) assert result.exit_code == 0, result.stdout data = json.loads(result.stdout) source = data["results"][0]["source"] assert "round" not in source assert "sum(items)" in source class TestSL49CodeCatAt0EqualsHead: """muse code cat "addr@{0}" == HEAD committed version.""" _FILE = "src/billing.py" _ADDR = "src/billing.py::compute_total" _BODY = textwrap.dedent("""\ def compute_total(items): return round(sum(items), 2) """) @pytest.fixture() def one_commit_repo(self, tmp_path: pathlib.Path) -> pathlib.Path: repo = _make_real_repo(tmp_path) _write_file(repo, self._FILE, self._BODY) _commit(repo, "feat: add compute_total") return repo def test_at0_source_equals_head_source( self, one_commit_repo: pathlib.Path ) -> None: """@{0} and normal cat (working tree = HEAD) return identical source.""" repo = one_commit_repo result_at0 = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"]) result_head = _invoke(repo, ["code", "cat", self._ADDR, "--json"]) assert result_at0.exit_code == 0, result_at0.stdout assert result_head.exit_code == 0, result_head.stdout src_at0 = json.loads(result_at0.stdout)["results"][0]["source"] src_head = json.loads(result_head.stdout)["results"][0]["source"] assert src_at0 == src_head # =========================================================================== # SL_50/51 — muse symlog diff (needs real repo + commits) # =========================================================================== class TestSL50SymlogDiff: """muse symlog diff 'addr@{1}' 'addr@{0}' --json.""" _FILE = "src/billing.py" _ADDR = "src/billing.py::compute_total" _BODY_A = textwrap.dedent("""\ def compute_total(items): return sum(items) """) _BODY_B = textwrap.dedent("""\ def compute_total(items): total = sum(items) return round(total, 2) """) @pytest.fixture() def two_commit_repo(self, tmp_path: pathlib.Path) -> pathlib.Path: repo = _make_real_repo(tmp_path) _write_file(repo, self._FILE, self._BODY_A) _commit(repo, "feat: add compute_total") _write_file(repo, self._FILE, self._BODY_B) _commit(repo, "fix: round total") return repo def test_diff_json_schema( self, two_commit_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """JSON has left, right, symbol, diff_lines, added, removed, context.""" out = _invoke_symlog( capsys, monkeypatch, ["diff", f"{self._ADDR}@{{1}}", f"{self._ADDR}@{{0}}", "--json"], two_commit_repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert "duration_ms" in data assert data["left"] == f"{self._ADDR}@{{1}}" assert data["right"] == f"{self._ADDR}@{{0}}" assert data["symbol"] == self._ADDR assert "diff_lines" in data assert isinstance(data["added"], int) assert isinstance(data["removed"], int) assert isinstance(data["context"], int) def test_diff_counts_reflect_changes( self, two_commit_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """added > 0 and removed > 0 when symbol was modified between the two indices.""" out = _invoke_symlog( capsys, monkeypatch, ["diff", f"{self._ADDR}@{{1}}", f"{self._ADDR}@{{0}}", "--json"], two_commit_repo, ) data = json.loads(out.out) assert data["added"] > 0 or data["removed"] > 0 def test_diff_lines_are_unified_diff( self, two_commit_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """diff_lines follows unified diff convention (+/-/ prefix).""" out = _invoke_symlog( capsys, monkeypatch, ["diff", f"{self._ADDR}@{{1}}", f"{self._ADDR}@{{0}}", "--json"], two_commit_repo, ) data = json.loads(out.out) lines = data["diff_lines"] assert isinstance(lines, list) # Some lines should start with + or - prefixes = {ln[0] for ln in lines if ln} assert "+" in prefixes or "-" in prefixes class TestSL51SymlogDiffHead: """muse symlog diff 'addr@{N}' HEAD --json uses working-tree version.""" _FILE = "src/billing.py" _ADDR = "src/billing.py::compute_total" _BODY_A = textwrap.dedent("""\ def compute_total(items): return sum(items) """) _BODY_B = textwrap.dedent("""\ def compute_total(items): return round(sum(items), 2) """) @pytest.fixture() def two_commit_repo(self, tmp_path: pathlib.Path) -> pathlib.Path: repo = _make_real_repo(tmp_path) _write_file(repo, self._FILE, self._BODY_A) _commit(repo, "feat: add compute_total") _write_file(repo, self._FILE, self._BODY_B) _commit(repo, "fix: round total") return repo def test_diff_at1_vs_head_shows_changes( self, two_commit_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """diff @{1} vs HEAD shows differences between old and current.""" out = _invoke_symlog( capsys, monkeypatch, ["diff", f"{self._ADDR}@{{1}}", "HEAD", "--json"], two_commit_repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert data["added"] > 0 or data["removed"] > 0 def test_diff_at0_vs_head_is_empty( self, two_commit_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """diff @{0} vs HEAD is empty when working tree == last commit.""" out = _invoke_symlog( capsys, monkeypatch, ["diff", f"{self._ADDR}@{{0}}", "HEAD", "--json"], two_commit_repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 # @{0} = latest committed version = HEAD working tree assert data["added"] == 0 assert data["removed"] == 0 # =========================================================================== # SL_52 — out-of-range @{N} in all commands exits USER_ERROR # =========================================================================== class TestSL52OutOfRange: _ADDR = "src/billing.py::compute_total" def _write_one_entry(self, repo: pathlib.Path) -> None: now_ts = int(time.time()) _write_symlog_entries(repo, self._ADDR, [ (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A, "symbol-created: compute_total", str(now_ts)), ]) def test_resolve_out_of_range_user_error( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """muse symlog resolve with out-of-range @{N} exits USER_ERROR.""" self._write_one_entry(repo) with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._ADDR}@{{9}}"], repo) assert exc_info.value.code == ExitCode.USER_ERROR def test_resolve_error_message_contains_valid_range( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Error message mentions 'valid range'.""" self._write_one_entry(repo) with pytest.raises(SystemExit): _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._ADDR}@{{9}}"], repo) err = capsys.readouterr().err assert "valid range" in err.lower() def test_code_cat_out_of_range_user_error( self, tmp_path: pathlib.Path ) -> None: """muse code cat 'addr@{N}' with out-of-range N exits USER_ERROR.""" repo = _make_real_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "feat: add compute_total") # Only 1 entry (@{0}); @{9} is out of range result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{9}}", "--json"]) assert result.exit_code == ExitCode.USER_ERROR def test_diff_out_of_range_user_error( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """muse symlog diff with out-of-range @{N} exits USER_ERROR.""" self._write_one_entry(repo) with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, ["diff", f"{self._ADDR}@{{9}}", f"{self._ADDR}@{{0}}"], repo) assert exc_info.value.code == ExitCode.USER_ERROR # =========================================================================== # SL_53 — muse symlog resolve --follow: followed_from in JSON # =========================================================================== class TestSL53Follow: _NEW_ADDR = "src/billing.py::compute_invoice_total" _OLD_ADDR = "src/billing.py::compute_total" def _write_rename_chain(self, repo: pathlib.Path) -> None: """Write a born-from / renamed-to rename chain.""" now_ts = int(time.time()) # Old symbol: created in commit A, renamed-to in commit B _write_symlog_entries(repo, self._OLD_ADDR, [ (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A, "symbol-created: compute_total", str(now_ts - 2)), (_FAKE_CID_A, NULL_CONTENT_ID, _FAKE_COMMIT_B, f"symbol-renamed-to: {self._NEW_ADDR}", str(now_ts - 1)), ]) # New symbol: born-from in commit B _write_symlog_entries(repo, self._NEW_ADDR, [ (NULL_CONTENT_ID, _FAKE_CID_B, _FAKE_COMMIT_B, f"symbol-born-from: {self._OLD_ADDR}", str(now_ts - 1)), ]) def test_follow_populates_followed_from( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """--follow resolves beyond a rename boundary; followed_from is populated.""" self._write_rename_chain(repo) # @{0} = born-from (index 0, newest of new symbol) # With --follow, @{1} = renamed-to (oldest of old symbol before the rename... wait) # Actually with follow: new_addr[0]=born-from, old_addr[1]=renamed-to, old_addr[2]=created # But old has [created, renamed-to] = 2 entries. # With follow chain: [born-from, renamed-to, created] = 3 entries # @{2} --follow = created (from old symbol) → followed_from should be set out = _invoke_symlog( capsys, monkeypatch, ["resolve", f"{self._NEW_ADDR}@{{2}}", "--follow", "--json"], repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert data["followed_from"] == self._OLD_ADDR assert data["index"] == 2 assert data["content_id"] == _FAKE_CID_A # old symbol's created content def test_no_follow_does_not_cross_boundary( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """Without --follow, @{N} beyond the own log is out-of-range USER_ERROR.""" self._write_rename_chain(repo) # New symbol only has 1 own entry (born-from) with pytest.raises(SystemExit) as exc_info: _invoke_symlog(capsys, monkeypatch, ["resolve", f"{self._NEW_ADDR}@{{2}}", "--json"], repo) assert exc_info.value.code == ExitCode.USER_ERROR def test_own_entry_has_no_followed_from( self, repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """@{0} (within own entries) has followed_from=null even with --follow.""" self._write_rename_chain(repo) out = _invoke_symlog( capsys, monkeypatch, ["resolve", f"{self._NEW_ADDR}@{{0}}", "--follow", "--json"], repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert data["followed_from"] is None # =========================================================================== # SL_54 — Integration: 3-commit history, code cat @{2} = commit A body # =========================================================================== class TestSL54ThreeCommitHistory: _FILE = "src/billing.py" _ADDR = "src/billing.py::compute_total" _BODY_A = textwrap.dedent("""\ def compute_total(items): return sum(items) """) _BODY_B = textwrap.dedent("""\ def compute_total(items): total = sum(items) return total """) _BODY_C = textwrap.dedent("""\ def compute_total(items): total = sum(items) return round(total, 2) """) @pytest.fixture() def three_commit_repo(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]: repo = _make_real_repo(tmp_path) _write_file(repo, self._FILE, self._BODY_A) cid_a = _commit(repo, "feat: add compute_total") # symlog @{2} _write_file(repo, self._FILE, self._BODY_B) cid_b = _commit(repo, "refactor: extract total var") # symlog @{1} _write_file(repo, self._FILE, self._BODY_C) cid_c = _commit(repo, "fix: round to 2dp") # symlog @{0} return repo, cid_a, cid_b, cid_c def test_symlog_has_three_entries( self, three_commit_repo: tuple[pathlib.Path, str, str, str] ) -> None: """The symbol should have 3 symlog entries after 3 commits.""" repo, _, _, _ = three_commit_repo entries = read_symlog(repo, self._ADDR) assert len(entries) == 3 def test_code_cat_at2_returns_commit_a_body( self, three_commit_repo: tuple[pathlib.Path, str, str, str] ) -> None: """muse code cat 'addr@{2}' returns the body from commit A (first).""" repo, _, _, _ = three_commit_repo result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{2}}", "--json"]) assert result.exit_code == 0, result.stdout + result.stderr data = json.loads(result.stdout) source = data["results"][0]["source"] # Commit A body: return sum(items) directly, no intermediate var assert "total = sum" not in source assert "round" not in source assert "sum(items)" in source def test_code_cat_at0_returns_commit_c_body( self, three_commit_repo: tuple[pathlib.Path, str, str, str] ) -> None: """muse code cat 'addr@{0}' returns the most recent (commit C) body.""" repo, _, _, _ = three_commit_repo result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"]) assert result.exit_code == 0, result.stdout data = json.loads(result.stdout) source = data["results"][0]["source"] assert "round" in source def test_diff_at2_at0_shows_changes( self, three_commit_repo: tuple[pathlib.Path, str, str, str], capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """diff @{2} → @{0} shows all changes across commits B and C.""" repo, _, _, _ = three_commit_repo out = _invoke_symlog( capsys, monkeypatch, ["diff", f"{self._ADDR}@{{2}}", f"{self._ADDR}@{{0}}", "--json"], repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 # The diff must show additions (round, total = ...) and removals assert data["added"] > 0 assert data["removed"] > 0 # =========================================================================== # SL_55 — Integration: rename pipeline with --follow # =========================================================================== class TestSL55RenameFollow: _FILE = "src/billing.py" _OLD_ADDR = "src/billing.py::compute_total" _NEW_ADDR = "src/billing.py::compute_invoice_total" # Same body for both (pure rename — different name, same body → rename detection fires) _BODY_FOO = textwrap.dedent("""\ def compute_total(items): return sum(items) """) _BODY_BAR = textwrap.dedent("""\ def compute_invoice_total(items): return sum(items) """) @pytest.fixture() def rename_repo(self, tmp_path: pathlib.Path) -> pathlib.Path: """Commit A: compute_total created. Commit B: renamed to compute_invoice_total.""" repo = _make_real_repo(tmp_path) _write_file(repo, self._FILE, self._BODY_FOO) _commit(repo, "feat: add compute_total") # commit A _write_file(repo, self._FILE, self._BODY_BAR) _commit(repo, "rename: compute_total → compute_invoice_total") # commit B return repo def test_bar_at0_returns_rename_commit_content( self, rename_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """bar@{0} resolves to the born-from entry at the rename commit.""" out = _invoke_symlog( capsys, monkeypatch, ["resolve", f"{self._NEW_ADDR}@{{0}}", "--json"], rename_repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert data["index"] == 0 # born-from entry: new_content_id = the new symbol's content at rename commit assert not data["content_id"].startswith(NULL_CONTENT_ID[:10]) def test_follow_chain_has_correct_length( self, rename_repo: pathlib.Path, ) -> None: """With --follow, bar's full chain = [born-from, renamed-to, created] = 3.""" from muse.core.symlog import read_symlog as _read chain = _read(rename_repo, self._NEW_ADDR, limit=100_000, follow=True) # bar: [born-from] # foo: [renamed-to, created] # total = 3 assert len(chain) == 3 def test_bar_at2_follow_returns_commit_a_content( self, rename_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """bar@{2} --follow resolves to foo's created entry (commit A content_id).""" out = _invoke_symlog( capsys, monkeypatch, ["resolve", f"{self._NEW_ADDR}@{{2}}", "--follow", "--json"], rename_repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert data["index"] == 2 assert data["followed_from"] == self._OLD_ADDR # The content_id should be the created content, NOT null assert not data["content_id"] == NULL_CONTENT_ID def test_bar_follow_chain_index_1_is_renamed_to( self, rename_repo: pathlib.Path, capsys: pytest.CaptureFixture, monkeypatch: pytest.MonkeyPatch, ) -> None: """bar@{1} --follow is the renamed-to entry (from foo's log).""" out = _invoke_symlog( capsys, monkeypatch, ["resolve", f"{self._NEW_ADDR}@{{1}}", "--follow", "--json"], rename_repo, ) data = json.loads(out.out) assert data["exit_code"] == 0 assert data["index"] == 1 assert data["followed_from"] == self._OLD_ADDR # renamed-to entry has NULL new_content_id assert data["content_id"] == NULL_CONTENT_ID