gabriel / muse public
test_cmd_symlog_refs.py python
920 lines 34.2 KB
Raw
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be Merge branch 'fix/hub-user-read-update-path' into dev Human 9 days ago
1 """Tests for ``muse symlog`` @{N} ref resolution — Phase 5 (SL_46–SL_55).
2
3 TDD: this file is written first. All tests are expected to fail until
4 Phase 5 is implemented.
5
6 Test IDs
7 --------
8 SL_46 resolve_symlog_addr(spec, repo_root) -> SymlogResolution | None
9 SL_47 muse symlog resolve "file.py::Symbol@{0}" --json
10 SL_48 muse code cat "billing.py::compute_total@{2}" --json (same shape)
11 SL_49 muse code cat "billing.py::compute_total@{0}" == HEAD version
12 SL_50 muse symlog diff "addr@{1}" "addr@{0}" --json (added/removed/context)
13 SL_51 muse symlog diff "addr@{2}" HEAD --json (HEAD = working-tree version)
14 SL_52 out-of-range @{N} exits USER_ERROR with "valid range: 0–N"
15 SL_53 muse symlog resolve --follow: followed_from populated in JSON
16 SL_54 integration: 3-commit history, code cat @{2} = commit A body
17 SL_55 integration: rename pipeline, follow chain has correct entries
18 """
19
20 from __future__ import annotations
21
22 import argparse
23 import json
24 import os
25 import pathlib
26 import textwrap
27 import time
28
29 import pytest
30
31 from muse.core.errors import ExitCode
32 from muse.core.symlog import (
33 NULL_CONTENT_ID,
34 append_symlog,
35 read_symlog,
36 symlog_path,
37 )
38 from tests.cli_test_helper import CliRunner
39
40 runner = CliRunner()
41
42 # ---------------------------------------------------------------------------
43 # Helpers — fake repo (no actual muse init needed for most unit tests)
44 # ---------------------------------------------------------------------------
45
46
47 @pytest.fixture()
48 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
49 """Minimal Muse repository skeleton — just enough for require_repo()."""
50 muse_dir = tmp_path / ".muse"
51 muse_dir.mkdir()
52 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
53 return tmp_path
54
55
56 # ---------------------------------------------------------------------------
57 # Helpers — real repo (actual muse init + commits)
58 # ---------------------------------------------------------------------------
59
60
61 def _invoke(repo: pathlib.Path, args: list[str]):
62 saved = os.getcwd()
63 try:
64 os.chdir(repo)
65 return runner.invoke(None, args)
66 finally:
67 os.chdir(saved)
68
69
70 def _init_repo(repo: pathlib.Path) -> None:
71 repo.mkdir(parents=True, exist_ok=True)
72 _invoke(repo, ["init"])
73
74
75 def _write_file(repo: pathlib.Path, rel: str, content: str) -> None:
76 p = repo / rel
77 p.parent.mkdir(parents=True, exist_ok=True)
78 p.write_text(textwrap.dedent(content))
79
80
81 def _commit(repo: pathlib.Path, msg: str) -> str:
82 """Stage all and commit; return commit_id from JSON output."""
83 _invoke(repo, ["code", "add", "."])
84 result = _invoke(repo, [
85 "commit", "-m", msg,
86 "--agent-id", "claude-code",
87 "--model-id", "claude-sonnet-4-6",
88 "--author", "claude-code",
89 "--json",
90 ])
91 data = json.loads(result.stdout)
92 return data["commit_id"]
93
94
95 def _make_real_repo(tmp_path: pathlib.Path) -> pathlib.Path:
96 _init_repo(tmp_path)
97 return tmp_path
98
99
100 # ---------------------------------------------------------------------------
101 # Helpers — fake symlog entries
102 # ---------------------------------------------------------------------------
103
104
105 _FAKE_CID_A = "sha256:" + "a" * 64
106 _FAKE_CID_B = "sha256:" + "b" * 64
107 _FAKE_CID_C = "sha256:" + "c" * 64
108 _FAKE_COMMIT_A = "sha256:" + "1" * 64
109 _FAKE_COMMIT_B = "sha256:" + "2" * 64
110 _FAKE_COMMIT_C = "sha256:" + "3" * 64
111
112
113 def _write_symlog_entries(
114 repo: pathlib.Path,
115 addr: str,
116 entries: list[tuple[str, str, str, str, str]],
117 ) -> None:
118 """Write entries to symlog for addr (oldest-first on disk).
119
120 Each tuple: (old_cid, new_cid, commit_id, operation, timestamp_str)
121 where timestamp_str is a unix timestamp as string.
122 """
123 p = symlog_path(repo, addr)
124 p.parent.mkdir(parents=True, exist_ok=True)
125 lines = []
126 for old_cid, new_cid, commit_id, operation, ts_str in entries:
127 lines.append(
128 f"{old_cid} {new_cid} {commit_id} "
129 f"claude-code {ts_str} +0000\t{operation}\n"
130 )
131 with p.open("a", encoding="utf-8") as fh:
132 fh.writelines(lines)
133
134
135 def _invoke_symlog(
136 capsys: pytest.CaptureFixture,
137 monkeypatch: pytest.MonkeyPatch,
138 args: list[str],
139 repo_root: pathlib.Path,
140 ) -> pytest.CaptureResult:
141 """Run ``muse symlog <args>`` inline; does NOT swallow SystemExit."""
142 from muse.cli.commands import symlog as symlog_cmd
143
144 monkeypatch.chdir(repo_root)
145 parser = argparse.ArgumentParser()
146 subparsers = parser.add_subparsers()
147 symlog_cmd.register(subparsers)
148 parsed = parser.parse_args(["symlog"] + args)
149 parsed.func(parsed)
150 return capsys.readouterr()
151
152
153 # ===========================================================================
154 # SL_46 — resolve_symlog_addr() core function
155 # ===========================================================================
156
157
158 class TestSL46ResolveSymlogAddr:
159 def test_returns_none_for_non_at_spec(self, repo: pathlib.Path) -> None:
160 """Returns None when spec has no @{N} suffix."""
161 from muse.core.symlog import resolve_symlog_addr
162
163 result = resolve_symlog_addr("billing.py::compute_total", repo)
164 assert result is None
165
166 def test_raises_value_error_for_addr_without_separator(self, repo: pathlib.Path) -> None:
167 """Raises ValueError when spec has @{N} but addr part has no :: separator."""
168 from muse.core.symlog import resolve_symlog_addr
169
170 # billing.py@{0} → addr = "billing.py" which has no :: → symlog_path raises ValueError
171 with pytest.raises((ValueError, FileNotFoundError)):
172 resolve_symlog_addr("billing.py@{0}", repo)
173
174 def test_returns_resolution_for_valid_spec(self, repo: pathlib.Path) -> None:
175 """Returns SymlogResolution with all fields for a valid @{N} spec."""
176 from muse.core.symlog import resolve_symlog_addr, SymlogResolution
177
178 addr = "src/billing.py::compute_total"
179 now_ts = int(time.time())
180 _write_symlog_entries(repo, addr, [
181 (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A,
182 "symbol-created: compute_total", str(now_ts - 2)),
183 (_FAKE_CID_A, _FAKE_CID_B, _FAKE_COMMIT_B,
184 "symbol-modified: fix rounding", str(now_ts - 1)),
185 ])
186
187 # @{0} = newest = B, @{1} = older = A
188 result = resolve_symlog_addr(f"{addr}@{{0}}", repo)
189 assert isinstance(result, SymlogResolution)
190 assert result.symbol == addr
191 assert result.index == 0
192 assert result.content_id == _FAKE_CID_B
193 assert result.commit_id == _FAKE_COMMIT_B
194 assert "symbol-modified" in result.operation
195 assert result.timestamp is not None
196 assert result.followed_from is None
197
198 def test_returns_correct_index_1_entry(self, repo: pathlib.Path) -> None:
199 """@{1} resolves to the second-newest entry."""
200 from muse.core.symlog import resolve_symlog_addr
201
202 addr = "src/billing.py::compute_total"
203 now_ts = int(time.time())
204 _write_symlog_entries(repo, addr, [
205 (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A,
206 "symbol-created: compute_total", str(now_ts - 2)),
207 (_FAKE_CID_A, _FAKE_CID_B, _FAKE_COMMIT_B,
208 "symbol-modified: fix rounding", str(now_ts - 1)),
209 ])
210
211 result = resolve_symlog_addr(f"{addr}@{{1}}", repo)
212 assert result is not None
213 assert result.index == 1
214 assert result.content_id == _FAKE_CID_A
215 assert result.commit_id == _FAKE_COMMIT_A
216 assert "symbol-created" in result.operation
217
218 def test_raises_file_not_found_for_missing_log(self, repo: pathlib.Path) -> None:
219 """Raises FileNotFoundError when no symlog exists for the symbol."""
220 from muse.core.symlog import resolve_symlog_addr
221
222 with pytest.raises(FileNotFoundError):
223 resolve_symlog_addr("missing.py::no_such_fn@{0}", repo)
224
225 def test_raises_index_error_for_out_of_range(self, repo: pathlib.Path) -> None:
226 """Raises IndexError(index, total) when index >= total entries."""
227 from muse.core.symlog import resolve_symlog_addr
228
229 addr = "src/billing.py::compute_total"
230 now_ts = int(time.time())
231 _write_symlog_entries(repo, addr, [
232 (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A,
233 "symbol-created: compute_total", str(now_ts)),
234 ])
235
236 with pytest.raises(IndexError) as exc_info:
237 resolve_symlog_addr(f"{addr}@{{5}}", repo)
238 bad_index, total = exc_info.value.args
239 assert bad_index == 5
240 assert total == 1
241
242
243 # ===========================================================================
244 # SL_47 — muse symlog resolve CLI
245 # ===========================================================================
246
247
248 class TestSL47SymlogResolveCmd:
249 _ADDR = "src/billing.py::compute_total"
250
251 def _setup_log(self, repo: pathlib.Path, count: int = 2) -> int:
252 now_ts = int(time.time())
253 entries = [
254 (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A,
255 "symbol-created: compute_total", str(now_ts - 2)),
256 (_FAKE_CID_A, _FAKE_CID_B, _FAKE_COMMIT_B,
257 "symbol-modified: add rounding", str(now_ts - 1)),
258 ]
259 _write_symlog_entries(repo, self._ADDR, entries[:count])
260 return now_ts
261
262 def test_json_all_fields_present(
263 self,
264 repo: pathlib.Path,
265 capsys: pytest.CaptureFixture,
266 monkeypatch: pytest.MonkeyPatch,
267 ) -> None:
268 """JSON output has all required fields."""
269 self._setup_log(repo)
270 out = _invoke_symlog(capsys, monkeypatch,
271 ["resolve", f"{self._ADDR}@{{0}}", "--json"], repo)
272 data = json.loads(out.out)
273 assert data["exit_code"] == 0
274 assert "duration_ms" in data
275 assert data["symbol"] == self._ADDR
276 assert data["index"] == 0
277 assert data["content_id"] == _FAKE_CID_B
278 assert data["commit_id"] == _FAKE_COMMIT_B
279 assert "operation" in data
280 assert "timestamp" in data
281
282 def test_human_text_output(
283 self,
284 repo: pathlib.Path,
285 capsys: pytest.CaptureFixture,
286 monkeypatch: pytest.MonkeyPatch,
287 ) -> None:
288 """Human text shows the symbol and key fields."""
289 self._setup_log(repo)
290 out = _invoke_symlog(capsys, monkeypatch,
291 ["resolve", f"{self._ADDR}@{{1}}"], repo)
292 text = out.out
293 assert "compute_total" in text
294 assert "@{1}" in text
295
296 def test_missing_log_exits_user_error(
297 self,
298 repo: pathlib.Path,
299 capsys: pytest.CaptureFixture,
300 monkeypatch: pytest.MonkeyPatch,
301 ) -> None:
302 """Missing symlog exits USER_ERROR."""
303 with pytest.raises(SystemExit) as exc_info:
304 _invoke_symlog(capsys, monkeypatch,
305 ["resolve", "missing.py::no_fn@{0}", "--json"], repo)
306 assert exc_info.value.code == ExitCode.USER_ERROR
307
308 def test_out_of_range_exits_user_error(
309 self,
310 repo: pathlib.Path,
311 capsys: pytest.CaptureFixture,
312 monkeypatch: pytest.MonkeyPatch,
313 ) -> None:
314 """Out-of-range @{N} exits USER_ERROR with valid range in message."""
315 self._setup_log(repo, count=2)
316 with pytest.raises(SystemExit) as exc_info:
317 _invoke_symlog(capsys, monkeypatch,
318 ["resolve", f"{self._ADDR}@{{9}}", "--json"], repo)
319 assert exc_info.value.code == ExitCode.USER_ERROR
320 err = capsys.readouterr().err
321 assert "valid range" in err.lower() or "valid range" in capsys.readouterr().err.lower()
322
323 def test_out_of_range_error_mentions_range(
324 self,
325 repo: pathlib.Path,
326 capsys: pytest.CaptureFixture,
327 monkeypatch: pytest.MonkeyPatch,
328 ) -> None:
329 """Error message mentions the valid range."""
330 self._setup_log(repo, count=2)
331 with pytest.raises(SystemExit):
332 _invoke_symlog(capsys, monkeypatch,
333 ["resolve", f"{self._ADDR}@{{5}}", "--json"], repo)
334 captured = capsys.readouterr()
335 err_text = captured.err
336 # Should say something like "valid range: @{0} to @{1}" or "0–1"
337 assert "valid range" in err_text.lower()
338
339
340 # ===========================================================================
341 # SL_48/49 — muse code cat "addr@{N}" (needs real repo + commits)
342 # ===========================================================================
343
344
345 class TestSL48CodeCatAtN:
346 """muse code cat "file.py::Symbol@{N}" returns same JSON shape."""
347
348 _FILE = "src/billing.py"
349 _ADDR = "src/billing.py::compute_total"
350 _BODY_A = textwrap.dedent("""\
351 def compute_total(items):
352 return sum(items)
353 """)
354 _BODY_B = textwrap.dedent("""\
355 def compute_total(items):
356 return round(sum(items), 2)
357 """)
358
359 @pytest.fixture()
360 def two_commit_repo(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str]:
361 """Repo with 2 commits; returns (repo, commit_a_id, commit_b_id)."""
362 repo = _make_real_repo(tmp_path)
363 _write_file(repo, self._FILE, self._BODY_A)
364 cid_a = _commit(repo, "feat: add compute_total")
365 _write_file(repo, self._FILE, self._BODY_B)
366 cid_b = _commit(repo, "fix: round to 2 dp")
367 return repo, cid_a, cid_b
368
369 def test_json_shape_identical_to_normal_code_cat(
370 self, two_commit_repo: tuple[pathlib.Path, str, str]
371 ) -> None:
372 """@{N} output has address, path, symbol, kind, source, source_ref."""
373 repo, _, _ = two_commit_repo
374 result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"])
375 assert result.exit_code == 0, result.stdout + result.stderr
376 data = json.loads(result.stdout)
377 assert "results" in data
378 assert len(data["results"]) == 1
379 entry = data["results"][0]
380 assert "address" in entry
381 assert "path" in entry
382 assert "symbol" in entry
383 assert "kind" in entry
384 assert "source" in entry
385 assert "source_ref" in entry
386
387 def test_at0_returns_newest_body(
388 self, two_commit_repo: tuple[pathlib.Path, str, str]
389 ) -> None:
390 """@{0} returns the body from the most recent commit that changed the symbol."""
391 repo, _, _ = two_commit_repo
392 result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"])
393 assert result.exit_code == 0, result.stdout
394 data = json.loads(result.stdout)
395 source = data["results"][0]["source"]
396 assert "round" in source
397
398 def test_at1_returns_older_body(
399 self, two_commit_repo: tuple[pathlib.Path, str, str]
400 ) -> None:
401 """@{1} returns the body from the first commit (original version)."""
402 repo, _, _ = two_commit_repo
403 result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{1}}", "--json"])
404 assert result.exit_code == 0, result.stdout
405 data = json.loads(result.stdout)
406 source = data["results"][0]["source"]
407 assert "round" not in source
408 assert "sum(items)" in source
409
410
411 class TestSL49CodeCatAt0EqualsHead:
412 """muse code cat "addr@{0}" == HEAD committed version."""
413
414 _FILE = "src/billing.py"
415 _ADDR = "src/billing.py::compute_total"
416 _BODY = textwrap.dedent("""\
417 def compute_total(items):
418 return round(sum(items), 2)
419 """)
420
421 @pytest.fixture()
422 def one_commit_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
423 repo = _make_real_repo(tmp_path)
424 _write_file(repo, self._FILE, self._BODY)
425 _commit(repo, "feat: add compute_total")
426 return repo
427
428 def test_at0_source_equals_head_source(
429 self, one_commit_repo: pathlib.Path
430 ) -> None:
431 """@{0} and normal cat (working tree = HEAD) return identical source."""
432 repo = one_commit_repo
433 result_at0 = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"])
434 result_head = _invoke(repo, ["code", "cat", self._ADDR, "--json"])
435 assert result_at0.exit_code == 0, result_at0.stdout
436 assert result_head.exit_code == 0, result_head.stdout
437 src_at0 = json.loads(result_at0.stdout)["results"][0]["source"]
438 src_head = json.loads(result_head.stdout)["results"][0]["source"]
439 assert src_at0 == src_head
440
441
442 # ===========================================================================
443 # SL_50/51 — muse symlog diff (needs real repo + commits)
444 # ===========================================================================
445
446
447 class TestSL50SymlogDiff:
448 """muse symlog diff 'addr@{1}' 'addr@{0}' --json."""
449
450 _FILE = "src/billing.py"
451 _ADDR = "src/billing.py::compute_total"
452 _BODY_A = textwrap.dedent("""\
453 def compute_total(items):
454 return sum(items)
455 """)
456 _BODY_B = textwrap.dedent("""\
457 def compute_total(items):
458 total = sum(items)
459 return round(total, 2)
460 """)
461
462 @pytest.fixture()
463 def two_commit_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
464 repo = _make_real_repo(tmp_path)
465 _write_file(repo, self._FILE, self._BODY_A)
466 _commit(repo, "feat: add compute_total")
467 _write_file(repo, self._FILE, self._BODY_B)
468 _commit(repo, "fix: round total")
469 return repo
470
471 def test_diff_json_schema(
472 self,
473 two_commit_repo: pathlib.Path,
474 capsys: pytest.CaptureFixture,
475 monkeypatch: pytest.MonkeyPatch,
476 ) -> None:
477 """JSON has left, right, symbol, diff_lines, added, removed, context."""
478 out = _invoke_symlog(
479 capsys, monkeypatch,
480 ["diff", f"{self._ADDR}@{{1}}", f"{self._ADDR}@{{0}}", "--json"],
481 two_commit_repo,
482 )
483 data = json.loads(out.out)
484 assert data["exit_code"] == 0
485 assert "duration_ms" in data
486 assert data["left"] == f"{self._ADDR}@{{1}}"
487 assert data["right"] == f"{self._ADDR}@{{0}}"
488 assert data["symbol"] == self._ADDR
489 assert "diff_lines" in data
490 assert isinstance(data["added"], int)
491 assert isinstance(data["removed"], int)
492 assert isinstance(data["context"], int)
493
494 def test_diff_counts_reflect_changes(
495 self,
496 two_commit_repo: pathlib.Path,
497 capsys: pytest.CaptureFixture,
498 monkeypatch: pytest.MonkeyPatch,
499 ) -> None:
500 """added > 0 and removed > 0 when symbol was modified between the two indices."""
501 out = _invoke_symlog(
502 capsys, monkeypatch,
503 ["diff", f"{self._ADDR}@{{1}}", f"{self._ADDR}@{{0}}", "--json"],
504 two_commit_repo,
505 )
506 data = json.loads(out.out)
507 assert data["added"] > 0 or data["removed"] > 0
508
509 def test_diff_lines_are_unified_diff(
510 self,
511 two_commit_repo: pathlib.Path,
512 capsys: pytest.CaptureFixture,
513 monkeypatch: pytest.MonkeyPatch,
514 ) -> None:
515 """diff_lines follows unified diff convention (+/-/ prefix)."""
516 out = _invoke_symlog(
517 capsys, monkeypatch,
518 ["diff", f"{self._ADDR}@{{1}}", f"{self._ADDR}@{{0}}", "--json"],
519 two_commit_repo,
520 )
521 data = json.loads(out.out)
522 lines = data["diff_lines"]
523 assert isinstance(lines, list)
524 # Some lines should start with + or -
525 prefixes = {ln[0] for ln in lines if ln}
526 assert "+" in prefixes or "-" in prefixes
527
528
529 class TestSL51SymlogDiffHead:
530 """muse symlog diff 'addr@{N}' HEAD --json uses working-tree version."""
531
532 _FILE = "src/billing.py"
533 _ADDR = "src/billing.py::compute_total"
534 _BODY_A = textwrap.dedent("""\
535 def compute_total(items):
536 return sum(items)
537 """)
538 _BODY_B = textwrap.dedent("""\
539 def compute_total(items):
540 return round(sum(items), 2)
541 """)
542
543 @pytest.fixture()
544 def two_commit_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
545 repo = _make_real_repo(tmp_path)
546 _write_file(repo, self._FILE, self._BODY_A)
547 _commit(repo, "feat: add compute_total")
548 _write_file(repo, self._FILE, self._BODY_B)
549 _commit(repo, "fix: round total")
550 return repo
551
552 def test_diff_at1_vs_head_shows_changes(
553 self,
554 two_commit_repo: pathlib.Path,
555 capsys: pytest.CaptureFixture,
556 monkeypatch: pytest.MonkeyPatch,
557 ) -> None:
558 """diff @{1} vs HEAD shows differences between old and current."""
559 out = _invoke_symlog(
560 capsys, monkeypatch,
561 ["diff", f"{self._ADDR}@{{1}}", "HEAD", "--json"],
562 two_commit_repo,
563 )
564 data = json.loads(out.out)
565 assert data["exit_code"] == 0
566 assert data["added"] > 0 or data["removed"] > 0
567
568 def test_diff_at0_vs_head_is_empty(
569 self,
570 two_commit_repo: pathlib.Path,
571 capsys: pytest.CaptureFixture,
572 monkeypatch: pytest.MonkeyPatch,
573 ) -> None:
574 """diff @{0} vs HEAD is empty when working tree == last commit."""
575 out = _invoke_symlog(
576 capsys, monkeypatch,
577 ["diff", f"{self._ADDR}@{{0}}", "HEAD", "--json"],
578 two_commit_repo,
579 )
580 data = json.loads(out.out)
581 assert data["exit_code"] == 0
582 # @{0} = latest committed version = HEAD working tree
583 assert data["added"] == 0
584 assert data["removed"] == 0
585
586
587 # ===========================================================================
588 # SL_52 — out-of-range @{N} in all commands exits USER_ERROR
589 # ===========================================================================
590
591
592 class TestSL52OutOfRange:
593 _ADDR = "src/billing.py::compute_total"
594
595 def _write_one_entry(self, repo: pathlib.Path) -> None:
596 now_ts = int(time.time())
597 _write_symlog_entries(repo, self._ADDR, [
598 (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A,
599 "symbol-created: compute_total", str(now_ts)),
600 ])
601
602 def test_resolve_out_of_range_user_error(
603 self,
604 repo: pathlib.Path,
605 capsys: pytest.CaptureFixture,
606 monkeypatch: pytest.MonkeyPatch,
607 ) -> None:
608 """muse symlog resolve with out-of-range @{N} exits USER_ERROR."""
609 self._write_one_entry(repo)
610 with pytest.raises(SystemExit) as exc_info:
611 _invoke_symlog(capsys, monkeypatch,
612 ["resolve", f"{self._ADDR}@{{9}}"], repo)
613 assert exc_info.value.code == ExitCode.USER_ERROR
614
615 def test_resolve_error_message_contains_valid_range(
616 self,
617 repo: pathlib.Path,
618 capsys: pytest.CaptureFixture,
619 monkeypatch: pytest.MonkeyPatch,
620 ) -> None:
621 """Error message mentions 'valid range'."""
622 self._write_one_entry(repo)
623 with pytest.raises(SystemExit):
624 _invoke_symlog(capsys, monkeypatch,
625 ["resolve", f"{self._ADDR}@{{9}}"], repo)
626 err = capsys.readouterr().err
627 assert "valid range" in err.lower()
628
629 def test_code_cat_out_of_range_user_error(
630 self, tmp_path: pathlib.Path
631 ) -> None:
632 """muse code cat 'addr@{N}' with out-of-range N exits USER_ERROR."""
633 repo = _make_real_repo(tmp_path)
634 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
635 _commit(repo, "feat: add compute_total")
636 # Only 1 entry (@{0}); @{9} is out of range
637 result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{9}}", "--json"])
638 assert result.exit_code == ExitCode.USER_ERROR
639
640 def test_diff_out_of_range_user_error(
641 self,
642 repo: pathlib.Path,
643 capsys: pytest.CaptureFixture,
644 monkeypatch: pytest.MonkeyPatch,
645 ) -> None:
646 """muse symlog diff with out-of-range @{N} exits USER_ERROR."""
647 self._write_one_entry(repo)
648 with pytest.raises(SystemExit) as exc_info:
649 _invoke_symlog(capsys, monkeypatch,
650 ["diff", f"{self._ADDR}@{{9}}", f"{self._ADDR}@{{0}}"],
651 repo)
652 assert exc_info.value.code == ExitCode.USER_ERROR
653
654
655 # ===========================================================================
656 # SL_53 — muse symlog resolve --follow: followed_from in JSON
657 # ===========================================================================
658
659
660 class TestSL53Follow:
661 _NEW_ADDR = "src/billing.py::compute_invoice_total"
662 _OLD_ADDR = "src/billing.py::compute_total"
663
664 def _write_rename_chain(self, repo: pathlib.Path) -> None:
665 """Write a born-from / renamed-to rename chain."""
666 now_ts = int(time.time())
667 # Old symbol: created in commit A, renamed-to in commit B
668 _write_symlog_entries(repo, self._OLD_ADDR, [
669 (NULL_CONTENT_ID, _FAKE_CID_A, _FAKE_COMMIT_A,
670 "symbol-created: compute_total", str(now_ts - 2)),
671 (_FAKE_CID_A, NULL_CONTENT_ID, _FAKE_COMMIT_B,
672 f"symbol-renamed-to: {self._NEW_ADDR}", str(now_ts - 1)),
673 ])
674 # New symbol: born-from in commit B
675 _write_symlog_entries(repo, self._NEW_ADDR, [
676 (NULL_CONTENT_ID, _FAKE_CID_B, _FAKE_COMMIT_B,
677 f"symbol-born-from: {self._OLD_ADDR}", str(now_ts - 1)),
678 ])
679
680 def test_follow_populates_followed_from(
681 self,
682 repo: pathlib.Path,
683 capsys: pytest.CaptureFixture,
684 monkeypatch: pytest.MonkeyPatch,
685 ) -> None:
686 """--follow resolves beyond a rename boundary; followed_from is populated."""
687 self._write_rename_chain(repo)
688 # @{0} = born-from (index 0, newest of new symbol)
689 # With --follow, @{1} = renamed-to (oldest of old symbol before the rename... wait)
690 # Actually with follow: new_addr[0]=born-from, old_addr[1]=renamed-to, old_addr[2]=created
691 # But old has [created, renamed-to] = 2 entries.
692 # With follow chain: [born-from, renamed-to, created] = 3 entries
693 # @{2} --follow = created (from old symbol) → followed_from should be set
694 out = _invoke_symlog(
695 capsys, monkeypatch,
696 ["resolve", f"{self._NEW_ADDR}@{{2}}", "--follow", "--json"],
697 repo,
698 )
699 data = json.loads(out.out)
700 assert data["exit_code"] == 0
701 assert data["followed_from"] == self._OLD_ADDR
702 assert data["index"] == 2
703 assert data["content_id"] == _FAKE_CID_A # old symbol's created content
704
705 def test_no_follow_does_not_cross_boundary(
706 self,
707 repo: pathlib.Path,
708 capsys: pytest.CaptureFixture,
709 monkeypatch: pytest.MonkeyPatch,
710 ) -> None:
711 """Without --follow, @{N} beyond the own log is out-of-range USER_ERROR."""
712 self._write_rename_chain(repo)
713 # New symbol only has 1 own entry (born-from)
714 with pytest.raises(SystemExit) as exc_info:
715 _invoke_symlog(capsys, monkeypatch,
716 ["resolve", f"{self._NEW_ADDR}@{{2}}", "--json"], repo)
717 assert exc_info.value.code == ExitCode.USER_ERROR
718
719 def test_own_entry_has_no_followed_from(
720 self,
721 repo: pathlib.Path,
722 capsys: pytest.CaptureFixture,
723 monkeypatch: pytest.MonkeyPatch,
724 ) -> None:
725 """@{0} (within own entries) has followed_from=null even with --follow."""
726 self._write_rename_chain(repo)
727 out = _invoke_symlog(
728 capsys, monkeypatch,
729 ["resolve", f"{self._NEW_ADDR}@{{0}}", "--follow", "--json"],
730 repo,
731 )
732 data = json.loads(out.out)
733 assert data["exit_code"] == 0
734 assert data["followed_from"] is None
735
736
737 # ===========================================================================
738 # SL_54 — Integration: 3-commit history, code cat @{2} = commit A body
739 # ===========================================================================
740
741
742 class TestSL54ThreeCommitHistory:
743 _FILE = "src/billing.py"
744 _ADDR = "src/billing.py::compute_total"
745 _BODY_A = textwrap.dedent("""\
746 def compute_total(items):
747 return sum(items)
748 """)
749 _BODY_B = textwrap.dedent("""\
750 def compute_total(items):
751 total = sum(items)
752 return total
753 """)
754 _BODY_C = textwrap.dedent("""\
755 def compute_total(items):
756 total = sum(items)
757 return round(total, 2)
758 """)
759
760 @pytest.fixture()
761 def three_commit_repo(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]:
762 repo = _make_real_repo(tmp_path)
763 _write_file(repo, self._FILE, self._BODY_A)
764 cid_a = _commit(repo, "feat: add compute_total") # symlog @{2}
765 _write_file(repo, self._FILE, self._BODY_B)
766 cid_b = _commit(repo, "refactor: extract total var") # symlog @{1}
767 _write_file(repo, self._FILE, self._BODY_C)
768 cid_c = _commit(repo, "fix: round to 2dp") # symlog @{0}
769 return repo, cid_a, cid_b, cid_c
770
771 def test_symlog_has_three_entries(
772 self, three_commit_repo: tuple[pathlib.Path, str, str, str]
773 ) -> None:
774 """The symbol should have 3 symlog entries after 3 commits."""
775 repo, _, _, _ = three_commit_repo
776 entries = read_symlog(repo, self._ADDR)
777 assert len(entries) == 3
778
779 def test_code_cat_at2_returns_commit_a_body(
780 self, three_commit_repo: tuple[pathlib.Path, str, str, str]
781 ) -> None:
782 """muse code cat 'addr@{2}' returns the body from commit A (first)."""
783 repo, _, _, _ = three_commit_repo
784 result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{2}}", "--json"])
785 assert result.exit_code == 0, result.stdout + result.stderr
786 data = json.loads(result.stdout)
787 source = data["results"][0]["source"]
788 # Commit A body: return sum(items) directly, no intermediate var
789 assert "total = sum" not in source
790 assert "round" not in source
791 assert "sum(items)" in source
792
793 def test_code_cat_at0_returns_commit_c_body(
794 self, three_commit_repo: tuple[pathlib.Path, str, str, str]
795 ) -> None:
796 """muse code cat 'addr@{0}' returns the most recent (commit C) body."""
797 repo, _, _, _ = three_commit_repo
798 result = _invoke(repo, ["code", "cat", f"{self._ADDR}@{{0}}", "--json"])
799 assert result.exit_code == 0, result.stdout
800 data = json.loads(result.stdout)
801 source = data["results"][0]["source"]
802 assert "round" in source
803
804 def test_diff_at2_at0_shows_changes(
805 self,
806 three_commit_repo: tuple[pathlib.Path, str, str, str],
807 capsys: pytest.CaptureFixture,
808 monkeypatch: pytest.MonkeyPatch,
809 ) -> None:
810 """diff @{2} → @{0} shows all changes across commits B and C."""
811 repo, _, _, _ = three_commit_repo
812 out = _invoke_symlog(
813 capsys, monkeypatch,
814 ["diff", f"{self._ADDR}@{{2}}", f"{self._ADDR}@{{0}}", "--json"],
815 repo,
816 )
817 data = json.loads(out.out)
818 assert data["exit_code"] == 0
819 # The diff must show additions (round, total = ...) and removals
820 assert data["added"] > 0
821 assert data["removed"] > 0
822
823
824 # ===========================================================================
825 # SL_55 — Integration: rename pipeline with --follow
826 # ===========================================================================
827
828
829 class TestSL55RenameFollow:
830 _FILE = "src/billing.py"
831 _OLD_ADDR = "src/billing.py::compute_total"
832 _NEW_ADDR = "src/billing.py::compute_invoice_total"
833 # Same body for both (pure rename — different name, same body → rename detection fires)
834 _BODY_FOO = textwrap.dedent("""\
835 def compute_total(items):
836 return sum(items)
837 """)
838 _BODY_BAR = textwrap.dedent("""\
839 def compute_invoice_total(items):
840 return sum(items)
841 """)
842
843 @pytest.fixture()
844 def rename_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
845 """Commit A: compute_total created. Commit B: renamed to compute_invoice_total."""
846 repo = _make_real_repo(tmp_path)
847 _write_file(repo, self._FILE, self._BODY_FOO)
848 _commit(repo, "feat: add compute_total") # commit A
849 _write_file(repo, self._FILE, self._BODY_BAR)
850 _commit(repo, "rename: compute_total → compute_invoice_total") # commit B
851 return repo
852
853 def test_bar_at0_returns_rename_commit_content(
854 self,
855 rename_repo: pathlib.Path,
856 capsys: pytest.CaptureFixture,
857 monkeypatch: pytest.MonkeyPatch,
858 ) -> None:
859 """bar@{0} resolves to the born-from entry at the rename commit."""
860 out = _invoke_symlog(
861 capsys, monkeypatch,
862 ["resolve", f"{self._NEW_ADDR}@{{0}}", "--json"],
863 rename_repo,
864 )
865 data = json.loads(out.out)
866 assert data["exit_code"] == 0
867 assert data["index"] == 0
868 # born-from entry: new_content_id = the new symbol's content at rename commit
869 assert not data["content_id"].startswith(NULL_CONTENT_ID[:10])
870
871 def test_follow_chain_has_correct_length(
872 self,
873 rename_repo: pathlib.Path,
874 ) -> None:
875 """With --follow, bar's full chain = [born-from, renamed-to, created] = 3."""
876 from muse.core.symlog import read_symlog as _read
877
878 chain = _read(rename_repo, self._NEW_ADDR, limit=100_000, follow=True)
879 # bar: [born-from]
880 # foo: [renamed-to, created]
881 # total = 3
882 assert len(chain) == 3
883
884 def test_bar_at2_follow_returns_commit_a_content(
885 self,
886 rename_repo: pathlib.Path,
887 capsys: pytest.CaptureFixture,
888 monkeypatch: pytest.MonkeyPatch,
889 ) -> None:
890 """bar@{2} --follow resolves to foo's created entry (commit A content_id)."""
891 out = _invoke_symlog(
892 capsys, monkeypatch,
893 ["resolve", f"{self._NEW_ADDR}@{{2}}", "--follow", "--json"],
894 rename_repo,
895 )
896 data = json.loads(out.out)
897 assert data["exit_code"] == 0
898 assert data["index"] == 2
899 assert data["followed_from"] == self._OLD_ADDR
900 # The content_id should be the created content, NOT null
901 assert not data["content_id"] == NULL_CONTENT_ID
902
903 def test_bar_follow_chain_index_1_is_renamed_to(
904 self,
905 rename_repo: pathlib.Path,
906 capsys: pytest.CaptureFixture,
907 monkeypatch: pytest.MonkeyPatch,
908 ) -> None:
909 """bar@{1} --follow is the renamed-to entry (from foo's log)."""
910 out = _invoke_symlog(
911 capsys, monkeypatch,
912 ["resolve", f"{self._NEW_ADDR}@{{1}}", "--follow", "--json"],
913 rename_repo,
914 )
915 data = json.loads(out.out)
916 assert data["exit_code"] == 0
917 assert data["index"] == 1
918 assert data["followed_from"] == self._OLD_ADDR
919 # renamed-to entry has NULL new_content_id
920 assert data["content_id"] == NULL_CONTENT_ID
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