gabriel / muse public
test_cmd_rev_list.py python
451 lines 15.3 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 151 days ago
1 """Tests for ``muse rev-list`` — raw commit ID stream with filters.
2
3 Coverage tiers:
4 - Unit: _walk_from, _parse_range, _parse_date, filter predicates
5 - Integration: --count, --max-count, --first-parent, --no-merges, --merges,
6 --author, --after, --before, --touches, --reverse, --json,
7 A..B range syntax
8 - End-to-end: full CLI invocation via CliRunner
9 - Security: ref injection, --touches path traversal, --author regex injection
10 - Stress: 500-commit chain with --count (flat memory), --touches on large repo
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 saved = os.getcwd()
33 try:
34 os.chdir(repo)
35 return runner.invoke(cli, ["rev-list", *args])
36 finally:
37 os.chdir(saved)
38
39
40 def _init(repo: pathlib.Path) -> None:
41 from muse.cli.app import main as cli
42 repo.mkdir(parents=True, exist_ok=True)
43 saved = os.getcwd()
44 try:
45 os.chdir(repo)
46 runner.invoke(cli, ["init"])
47 finally:
48 os.chdir(saved)
49
50
51 def _commit(
52 repo: pathlib.Path,
53 msg: str = "commit",
54 filename: str | None = None,
55 author: str | None = None,
56 ) -> str:
57 """Commit one file and return the commit_id."""
58 from muse.cli.app import main as cli
59 fname = filename or f"f_{abs(hash(msg)) % 99999}.py"
60 (repo / fname).write_text(f"# {msg}\n")
61 saved = os.getcwd()
62 try:
63 os.chdir(repo)
64 extra = ["--author", author] if author else []
65 result = runner.invoke(cli, ["commit", "-m", msg, "--json", *extra])
66 data = json.loads(result.stdout)
67 return data["commit_id"]
68 finally:
69 os.chdir(saved)
70
71
72 def _fresh_repo(tmp: pathlib.Path, n: int = 3) -> tuple[pathlib.Path, list[str]]:
73 """Create a repo with n commits, return (repo_path, [commit_ids oldest→newest])."""
74 repo = tmp / "repo"
75 _init(repo)
76 ids: list[str] = []
77 for i in range(n):
78 cid = _commit(repo, f"commit {i}", filename=f"file_{i}.py")
79 ids.append(cid)
80 return repo, ids
81
82
83 # ---------------------------------------------------------------------------
84 # Unit — internal helpers
85 # ---------------------------------------------------------------------------
86
87
88 def test_walk_from_uses_deque() -> None:
89 """_walk_from must use collections.deque; no variable.pop(0) calls in code."""
90 import inspect, ast
91 from muse.cli.commands import rev_list as mod
92 src = inspect.getsource(mod._walk_from)
93 assert "deque" in src, "_walk_from must use collections.deque"
94 # Parse the AST to check for list.pop(0) calls — this skips docstrings.
95 tree = ast.parse(src)
96 for node in ast.walk(tree):
97 if (
98 isinstance(node, ast.Call)
99 and isinstance(node.func, ast.Attribute)
100 and node.func.attr == "pop"
101 and len(node.args) == 1
102 and isinstance(node.args[0], ast.Constant)
103 and node.args[0].value == 0
104 ):
105 raise AssertionError("_walk_from must not call .pop(0) — use deque.popleft()")
106
107
108 def test_parse_range_dotdot() -> None:
109 """'A..B' must be parsed into (exclude='A', include='B')."""
110 from muse.cli.commands.rev_list import _parse_range
111 exc, inc = _parse_range("abc..def")
112 assert exc == "abc"
113 assert inc == "def"
114
115
116 def test_parse_range_single() -> None:
117 """A plain ref with no '..' must parse to (exclude=None, include=ref)."""
118 from muse.cli.commands.rev_list import _parse_range
119 exc, inc = _parse_range("HEAD")
120 assert exc is None
121 assert inc == "HEAD"
122
123
124 def test_parse_date_valid() -> None:
125 from muse.cli.commands.rev_list import _parse_date
126 import datetime
127 dt = _parse_date("2026-01-15")
128 assert dt.year == 2026
129 assert dt.month == 1
130 assert dt.day == 15
131 assert dt.tzinfo == datetime.timezone.utc
132
133
134 def test_parse_date_invalid_raises() -> None:
135 from muse.cli.commands.rev_list import _parse_date
136 with pytest.raises(ValueError, match="date"):
137 _parse_date("not-a-date")
138
139
140 # ---------------------------------------------------------------------------
141 # Integration — basic output
142 # ---------------------------------------------------------------------------
143
144
145 def test_rev_list_emits_one_id_per_line(tmp_path: pathlib.Path) -> None:
146 repo, ids = _fresh_repo(tmp_path, n=3)
147 result = _invoke(repo, "HEAD")
148 assert result.exit_code == 0
149 lines = [l for l in result.stdout.strip().splitlines() if l]
150 assert len(lines) == 3
151 # Each line must be a valid 64-char hex string
152 for line in lines:
153 assert len(line) == 64
154 int(line, 16)
155
156
157 def test_rev_list_newest_first(tmp_path: pathlib.Path) -> None:
158 repo, ids = _fresh_repo(tmp_path, n=3)
159 result = _invoke(repo, "HEAD")
160 lines = [l for l in result.stdout.strip().splitlines() if l]
161 # ids list is oldest→newest; rev-list default is newest→oldest
162 assert lines[0] == ids[-1]
163 assert lines[-1] == ids[0]
164
165
166 def test_rev_list_count(tmp_path: pathlib.Path) -> None:
167 repo, ids = _fresh_repo(tmp_path, n=5)
168 result = _invoke(repo, "--count", "HEAD")
169 assert result.exit_code == 0
170 assert result.stdout.strip() == "5"
171
172
173 def test_rev_list_max_count(tmp_path: pathlib.Path) -> None:
174 repo, ids = _fresh_repo(tmp_path, n=5)
175 result = _invoke(repo, "-n", "2", "HEAD")
176 lines = [l for l in result.stdout.strip().splitlines() if l]
177 assert len(lines) == 2
178 assert lines[0] == ids[-1] # most recent
179
180
181 def test_rev_list_reverse(tmp_path: pathlib.Path) -> None:
182 repo, ids = _fresh_repo(tmp_path, n=3)
183 result = _invoke(repo, "--reverse", "HEAD")
184 lines = [l for l in result.stdout.strip().splitlines() if l]
185 assert lines[0] == ids[0] # oldest first
186 assert lines[-1] == ids[-1] # newest last
187
188
189 def test_rev_list_json(tmp_path: pathlib.Path) -> None:
190 repo, ids = _fresh_repo(tmp_path, n=3)
191 result = _invoke(repo, "--json", "HEAD")
192 assert result.exit_code == 0
193 data = json.loads(result.stdout)
194 assert "commit_ids" in data
195 assert len(data["commit_ids"]) == 3
196 assert data["commit_ids"][0] == ids[-1]
197
198
199 # ---------------------------------------------------------------------------
200 # Integration — filter flags
201 # ---------------------------------------------------------------------------
202
203
204 def _make_merge_commit(repo: pathlib.Path) -> None:
205 """Create a divergent history and merge it, producing a real merge commit."""
206 from muse.cli.app import main as cli
207 saved = os.getcwd()
208 try:
209 os.chdir(repo)
210 runner.invoke(cli, ["checkout", "-b", "feat"])
211 _commit(repo, "feat work", filename="feat_only.py")
212 runner.invoke(cli, ["checkout", "main"])
213 # Commit on main so histories diverge → true merge commit (not FF)
214 _commit(repo, "main diverge", filename="main_only.py")
215 runner.invoke(cli, ["merge", "feat"])
216 finally:
217 os.chdir(saved)
218
219
220 def test_rev_list_no_merges(tmp_path: pathlib.Path) -> None:
221 """--no-merges excludes commits that have two parents."""
222 repo, ids = _fresh_repo(tmp_path, n=2)
223 _make_merge_commit(repo)
224
225 result_all = _invoke(repo, "--count", "HEAD")
226 result_no_merges = _invoke(repo, "--no-merges", "--count", "HEAD")
227 total = int(result_all.stdout.strip())
228 no_merge_count = int(result_no_merges.stdout.strip())
229 assert no_merge_count < total
230
231
232 def test_rev_list_merges_only(tmp_path: pathlib.Path) -> None:
233 """--merges emits only merge commits."""
234 repo, ids = _fresh_repo(tmp_path, n=2)
235 _make_merge_commit(repo)
236
237 result = _invoke(repo, "--merges", "--count", "HEAD")
238 assert int(result.stdout.strip()) >= 1
239
240
241 def test_rev_list_author_filter(tmp_path: pathlib.Path) -> None:
242 repo = tmp_path / "repo"
243 _init(repo)
244 _commit(repo, "alice commit", author="Alice")
245 _commit(repo, "bob commit", author="Bob")
246 _commit(repo, "alice again", author="Alice")
247
248 result = _invoke(repo, "--author", "Alice", "--count", "HEAD")
249 assert result.exit_code == 0
250 assert result.stdout.strip() == "2"
251
252
253 def test_rev_list_after_filter(tmp_path: pathlib.Path) -> None:
254 """--after excludes commits before the date."""
255 repo, ids = _fresh_repo(tmp_path, n=3)
256 # All commits are in the future (2026) so --after 2020-01-01 keeps all
257 result_all = _invoke(repo, "--count", "HEAD")
258 result_after = _invoke(repo, "--after", "2020-01-01", "--count", "HEAD")
259 assert result_all.stdout.strip() == result_after.stdout.strip()
260
261 # --after 2099-01-01 should keep nothing
262 result_future = _invoke(repo, "--after", "2099-01-01", "--count", "HEAD")
263 assert result_future.stdout.strip() == "0"
264
265
266 def test_rev_list_before_filter(tmp_path: pathlib.Path) -> None:
267 """--before excludes commits after the date."""
268 repo, ids = _fresh_repo(tmp_path, n=3)
269 result_before = _invoke(repo, "--before", "2099-01-01", "--count", "HEAD")
270 assert int(result_before.stdout.strip()) == 3
271
272 result_past = _invoke(repo, "--before", "2020-01-01", "--count", "HEAD")
273 assert result_past.stdout.strip() == "0"
274
275
276 def test_rev_list_touches_filter(tmp_path: pathlib.Path) -> None:
277 """--touches only emits commits that changed the specified path."""
278 repo = tmp_path / "repo"
279 _init(repo)
280 _commit(repo, "add alpha", filename="alpha.py")
281 _commit(repo, "add beta", filename="beta.py")
282 _commit(repo, "modify alpha", filename="alpha.py")
283
284 result = _invoke(repo, "--touches", "alpha.py", "--count", "HEAD")
285 assert result.exit_code == 0
286 assert result.stdout.strip() == "2"
287
288
289 def test_rev_list_touches_directory_prefix(tmp_path: pathlib.Path) -> None:
290 """--touches src/ matches all files under src/."""
291 repo = tmp_path / "repo"
292 _init(repo)
293 (repo / "src").mkdir()
294 _commit(repo, "src file", filename="src/main.py")
295 _commit(repo, "root file", filename="root.py")
296 _commit(repo, "src again", filename="src/utils.py")
297
298 result = _invoke(repo, "--touches", "src/", "--count", "HEAD")
299 assert result.exit_code == 0
300 assert result.stdout.strip() == "2"
301
302
303 # ---------------------------------------------------------------------------
304 # Integration — range syntax
305 # ---------------------------------------------------------------------------
306
307
308 def test_rev_list_range_syntax(tmp_path: pathlib.Path) -> None:
309 """A..B emits commits reachable from B but not from A."""
310 from muse.cli.app import main as cli
311 repo, base_ids = _fresh_repo(tmp_path, n=2)
312
313 saved = os.getcwd()
314 try:
315 os.chdir(repo)
316 runner.invoke(cli, ["checkout", "-b", "feat"])
317 finally:
318 os.chdir(saved)
319
320 feat_id1 = _commit(repo, "feat 1", filename="feat1.py")
321 feat_id2 = _commit(repo, "feat 2", filename="feat2.py")
322
323 result = _invoke(repo, "main..feat")
324 lines = [l for l in result.stdout.strip().splitlines() if l]
325 assert len(lines) == 2
326 assert feat_id2 in lines
327 assert feat_id1 in lines
328 # Base commits must NOT appear
329 for base_id in base_ids:
330 assert base_id not in lines
331
332
333 def test_rev_list_range_count(tmp_path: pathlib.Path) -> None:
334 """--count with range counts only the range, not the full history."""
335 from muse.cli.app import main as cli
336 repo, _ = _fresh_repo(tmp_path, n=3)
337 saved = os.getcwd()
338 try:
339 os.chdir(repo)
340 runner.invoke(cli, ["checkout", "-b", "feat"])
341 finally:
342 os.chdir(saved)
343 _commit(repo, "feat A", filename="fa.py")
344 _commit(repo, "feat B", filename="fb.py")
345
346 result = _invoke(repo, "--count", "main..feat")
347 assert result.stdout.strip() == "2"
348
349
350 # ---------------------------------------------------------------------------
351 # Integration — first-parent
352 # ---------------------------------------------------------------------------
353
354
355 def test_rev_list_first_parent(tmp_path: pathlib.Path) -> None:
356 """--first-parent only follows the first-parent chain."""
357 from muse.cli.app import main as cli
358 repo, base_ids = _fresh_repo(tmp_path, n=2)
359 saved = os.getcwd()
360 try:
361 os.chdir(repo)
362 runner.invoke(cli, ["checkout", "-b", "feat"])
363 _commit(repo, "feat work", filename="feat.py")
364 runner.invoke(cli, ["checkout", "main"])
365 runner.invoke(cli, ["merge", "feat"])
366 finally:
367 os.chdir(saved)
368
369 result_all = _invoke(repo, "--count", "HEAD")
370 result_fp = _invoke(repo, "--first-parent", "--count", "HEAD")
371 assert int(result_fp.stdout.strip()) <= int(result_all.stdout.strip())
372
373
374 # ---------------------------------------------------------------------------
375 # Security
376 # ---------------------------------------------------------------------------
377
378
379 def test_rev_list_ref_not_found_exits_nonzero(tmp_path: pathlib.Path) -> None:
380 repo, _ = _fresh_repo(tmp_path, n=1)
381 result = _invoke(repo, "nonexistent-branch")
382 assert result.exit_code != 0
383
384
385 def test_rev_list_author_regex_special_chars_handled(tmp_path: pathlib.Path) -> None:
386 """Malformed regex in --author must produce a clean error, not a crash."""
387 repo, _ = _fresh_repo(tmp_path, n=1)
388 result = _invoke(repo, "--author", "[invalid-regex", "--count", "HEAD")
389 # Should either work (literal match fallback) or exit with a clean error code
390 assert result.exit_code in (0, 1, 2)
391
392
393 def test_rev_list_touches_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
394 """--touches with path traversal sequences must be rejected."""
395 repo, _ = _fresh_repo(tmp_path, n=1)
396 result = _invoke(repo, "--touches", "../../../etc/passwd", "--count", "HEAD")
397 assert result.exit_code != 0
398
399
400 # ---------------------------------------------------------------------------
401 # Stress
402 # ---------------------------------------------------------------------------
403
404
405 def test_rev_list_count_flat_memory_large_chain(tmp_path: pathlib.Path) -> None:
406 """--count on a 500-commit chain must complete without building a list."""
407 import tracemalloc
408 repo = tmp_path / "repo"
409 _init(repo)
410 for i in range(500):
411 (repo / f"f{i}.py").write_text(f"# {i}\n")
412 saved = os.getcwd()
413 try:
414 os.chdir(repo)
415 runner.invoke(cli_main(), ["commit", "-m", f"c{i}"])
416 finally:
417 os.chdir(saved)
418
419 tracemalloc.start()
420 result = _invoke(repo, "--count", "HEAD")
421 _, peak = tracemalloc.get_traced_memory()
422 tracemalloc.stop()
423
424 assert result.stdout.strip() == "500"
425 # Peak memory for --count should stay well under 50 MB
426 assert peak < 50 * 1024 * 1024, f"Peak memory {peak // 1024} KB exceeds limit"
427
428
429 def cli_main():
430 from muse.cli.app import main
431 return main
432
433
434 def test_rev_list_stress_touches_large_repo(tmp_path: pathlib.Path) -> None:
435 """--touches on a 100-file, 50-commit repo completes without error."""
436 repo = tmp_path / "repo"
437 _init(repo)
438 for i in range(50):
439 fname = f"file_{i % 10}.py" # 10 files, cycling
440 (repo / fname).write_text(f"# iteration {i}\n")
441 saved = os.getcwd()
442 try:
443 os.chdir(repo)
444 runner.invoke(cli_main(), ["commit", "-m", f"c{i}"])
445 finally:
446 os.chdir(saved)
447
448 result = _invoke(repo, "--touches", "file_0.py", "--count", "HEAD")
449 assert result.exit_code == 0
450 count = int(result.stdout.strip())
451 assert count >= 5 # file_0 touched at commits 0, 10, 20, 30, 40
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 151 days ago