gabriel / muse public
test_log_dotdot_range.py python
211 lines 6.8 KB
Raw
sha256:8063bdadd934911129e35dee8db004797c136aa88c7c446248dc5f9d3fd0337a feat(log): support A..B range syntax — commits on B not rea… Sonnet 4.6 patch 20 hours ago
1 """TDD tests for ``muse log a..b`` range syntax (issue #28).
2
3 ``muse log A..B`` shows commits reachable from B but not reachable from A.
4 This is the standard range meaning: commits unique to B relative to A.
5
6 Coverage tiers:
7 - Unit: _parse_range extracted from log.py
8 - Integration: muse log A..B with two-branch repo
9 - Edge cases: A..B where A==B (empty), unknown ref on either side,
10 A..B with --json, A..B with -n limit, plain ref still works
11 """
12 from __future__ import annotations
13
14 import json
15 import os
16 import pathlib
17
18 import pytest
19
20 from tests.cli_test_helper import CliRunner, InvokeResult
21
22 runner = CliRunner()
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29
30 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
31 from muse.cli.app import main as cli
32
33 saved = os.getcwd()
34 try:
35 os.chdir(repo)
36 return runner.invoke(cli, list(args))
37 finally:
38 os.chdir(saved)
39
40
41 def _init(repo: pathlib.Path) -> None:
42 repo.mkdir(parents=True, exist_ok=True)
43 _invoke(repo, "init")
44
45
46 def _commit(repo: pathlib.Path, msg: str, filename: str | None = None) -> str:
47 """Commit a file and return the commit_id."""
48 fname = filename or f"f_{abs(hash(msg)) % 100000}.py"
49 (repo / fname).write_text(f"# {msg}\n")
50 _invoke(repo, "code", "add", ".")
51 result = _invoke(repo, "commit", "-m", msg, "--json")
52 data = json.loads(result.output)
53 return data["commit_id"]
54
55
56 def _checkout(repo: pathlib.Path, branch: str, *, new: bool = False) -> None:
57 args = ["checkout", "-b", branch] if new else ["checkout", branch]
58 _invoke(repo, *args)
59
60
61 def _log_json(repo: pathlib.Path, *extra: str) -> list[dict]:
62 result = _invoke(repo, "log", "--json", *extra)
63 return json.loads(result.output)["commits"]
64
65
66 def _make_fork_repo(tmp: pathlib.Path) -> tuple[pathlib.Path, str, str, str]:
67 """Build a repo with a shared base and a diverging feature branch.
68
69 History:
70 main: A → B → C (base=A, main-only=B,C)
71 feat/x: A → D → E (feat-only=D,E)
72 ^
73 shared base (commit A)
74
75 Returns (repo, commit_A_id, commit_C_id, commit_E_id).
76 """
77 repo = tmp / "repo"
78 _init(repo)
79
80 # Shared base commit
81 a_id = _commit(repo, "base: shared commit A", "a.py")
82
83 # main branch: B then C
84 b_id = _commit(repo, "main: commit B", "b.py")
85 c_id = _commit(repo, "main: commit C", "c.py")
86
87 # feature branch: checkout at A, add D and E
88 _checkout(repo, "feat/x", new=True)
89 # The branch was created from current HEAD (C on main).
90 # We need to branch from A. Easier: just add unique commits on feat/x.
91 d_id = _commit(repo, "feat: commit D", "d.py")
92 e_id = _commit(repo, "feat: commit E", "e.py")
93
94 return repo, a_id, c_id, e_id
95
96
97 # ---------------------------------------------------------------------------
98 # Unit — _parse_range lives in rev_list; verify log reuses same semantics
99 # ---------------------------------------------------------------------------
100
101
102 def test_LOG_DR1_plain_ref_still_works(tmp_path: pathlib.Path) -> None:
103 """Plain ``muse log dev`` (no ..) continues to work as before."""
104 repo = tmp_path / "repo"
105 _init(repo)
106 _commit(repo, "first", "f.py")
107 _commit(repo, "second", "g.py")
108
109 commits = _log_json(repo, "main")
110 assert len(commits) == 2
111 assert commits[0]["message"] == "second"
112
113
114 def test_LOG_DR2_dotdot_shows_only_feature_commits(tmp_path: pathlib.Path) -> None:
115 """``muse log main..feat/x`` shows only commits on feat/x not on main."""
116 repo = tmp_path / "repo"
117 _init(repo)
118 _commit(repo, "shared base", "base.py")
119 _commit(repo, "main commit", "m.py")
120
121 _checkout(repo, "feat/x", new=True)
122 _commit(repo, "feat commit D", "d.py")
123 _commit(repo, "feat commit E", "e.py")
124
125 commits = _log_json(repo, "main..feat/x")
126 messages = [c["message"] for c in commits]
127
128 assert "feat commit D" in messages
129 assert "feat commit E" in messages
130 assert "shared base" not in messages
131 assert "main commit" not in messages
132
133
134 def test_LOG_DR3_dotdot_empty_when_same_ref(tmp_path: pathlib.Path) -> None:
135 """``muse log main..main`` returns no commits (nothing unique)."""
136 repo = tmp_path / "repo"
137 _init(repo)
138 _commit(repo, "only commit", "f.py")
139
140 commits = _log_json(repo, "main..main")
141 assert commits == [], f"Expected empty list, got {commits}"
142
143
144 def test_LOG_DR4_dotdot_json_output_schema(tmp_path: pathlib.Path) -> None:
145 """``muse log A..B --json`` returns valid envelope with commits list."""
146 repo = tmp_path / "repo"
147 _init(repo)
148 _commit(repo, "base", "base.py")
149 _checkout(repo, "feat/y", new=True)
150 _commit(repo, "feat only", "feat.py")
151
152 result = _invoke(repo, "log", "--json", "main..feat/y")
153 assert result.exit_code == 0, result.output
154 data = json.loads(result.output)
155 assert "commits" in data
156 assert isinstance(data["commits"], list)
157 assert len(data["commits"]) == 1
158 assert data["commits"][0]["message"] == "feat only"
159
160
161 def test_LOG_DR5_dotdot_respects_n_limit(tmp_path: pathlib.Path) -> None:
162 """``muse log main..feat/z -n 1`` returns at most 1 commit."""
163 repo = tmp_path / "repo"
164 _init(repo)
165 _commit(repo, "base", "base.py")
166 _checkout(repo, "feat/z", new=True)
167 _commit(repo, "feat first", "f1.py")
168 _commit(repo, "feat second", "f2.py")
169 _commit(repo, "feat third", "f3.py")
170
171 commits = _log_json(repo, "main..feat/z", "-n", "1")
172 assert len(commits) == 1
173
174
175 def test_LOG_DR6_unknown_exclude_ref_errors(tmp_path: pathlib.Path) -> None:
176 """``muse log nonexistent..main`` exits non-zero with a clear error."""
177 repo = tmp_path / "repo"
178 _init(repo)
179 _commit(repo, "only commit", "f.py")
180
181 result = _invoke(repo, "log", "--json", "nonexistent..main")
182 assert result.exit_code != 0
183 data = json.loads(result.output)
184 assert "error" in data
185
186
187 def test_LOG_DR7_unknown_include_ref_errors(tmp_path: pathlib.Path) -> None:
188 """``muse log main..nonexistent`` exits non-zero with a clear error."""
189 repo = tmp_path / "repo"
190 _init(repo)
191 _commit(repo, "only commit", "f.py")
192
193 result = _invoke(repo, "log", "--json", "main..nonexistent")
194 assert result.exit_code != 0
195 data = json.loads(result.output)
196 assert "error" in data
197
198
199 def test_LOG_DR8_dotdot_commit_ids_as_refs(tmp_path: pathlib.Path) -> None:
200 """``muse log <commit_id>..<commit_id>`` works with raw commit IDs."""
201 repo = tmp_path / "repo"
202 _init(repo)
203 base_id = _commit(repo, "base", "base.py")
204 _commit(repo, "second", "second.py")
205 tip_id = _commit(repo, "third", "third.py")
206
207 commits = _log_json(repo, f"{base_id}..{tip_id}")
208 messages = [c["message"] for c in commits]
209 assert "third" in messages
210 assert "second" in messages
211 assert "base" not in messages
File History 1 commit
sha256:8063bdadd934911129e35dee8db004797c136aa88c7c446248dc5f9d3fd0337a feat(log): support A..B range syntax — commits on B not rea… Sonnet 4.6 patch 20 hours ago