gabriel / muse public
test_cmd_ls_files.py python
372 lines 13.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Comprehensive tests for ``muse ls-files``.
2
3 Coverage tiers
4 --------------
5 - Integration: JSON/text format, --commit, --path-prefix, empty manifest
6 - Security: ANSI in file path stripped in text mode, JSON mode safe
7 - Stress: 1 000-file manifest, 200 sequential calls
8 """
9 from __future__ import annotations
10
11 type _FileStore = dict[str, bytes]
12
13 import datetime
14 import json
15 import pathlib
16
17 from muse.core.errors import ExitCode
18 from muse.core.object_store import write_object
19 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
20 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
21 from muse.core._types import Manifest, blob_id, long_id, split_id
22 from tests.cli_test_helper import CliRunner, InvokeResult
23
24 runner = CliRunner()
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
32 repo = tmp_path / "repo"
33 muse = repo / ".muse"
34 for sub in ("objects", "commits", "snapshots", "refs/heads"):
35 (muse / sub).mkdir(parents=True)
36 (muse / "HEAD").write_text("ref: refs/heads/main")
37 (muse / "repo.json").write_text(json.dumps({"repo_id": "test", "domain": "code"}))
38 return repo
39
40
41 def _oid(content: bytes) -> str:
42 return blob_id(content)
43
44
45 def _add_commit(
46 repo: pathlib.Path,
47 manifest: _FileStore,
48 *,
49 commit_suffix: str = "a",
50 branch: str = "main",
51 set_head: bool = True,
52 ) -> str:
53 """Store objects, snapshot, and commit; return commit_id."""
54 stored: Manifest = {}
55 for path, content in manifest.items():
56 oid = _oid(content)
57 write_object(repo, oid, content)
58 stored[path] = oid
59
60 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
61 snap_id = compute_snapshot_id(stored)
62 write_snapshot(repo, SnapshotRecord(
63 snapshot_id=snap_id,
64 manifest=stored,
65 created_at=committed_at,
66 ))
67 commit_id = compute_commit_id(
68 repo_id="test",
69 parent_ids=[],
70 snapshot_id=snap_id,
71 message="test",
72 committed_at_iso=committed_at.isoformat(),
73 author="tester",
74 )
75 write_commit(repo, CommitRecord(
76 commit_id=commit_id,
77 repo_id="test",
78 created_on_branch=branch,
79 snapshot_id=snap_id,
80 message="test",
81 committed_at=committed_at,
82 author="tester",
83 parent_commit_id=None,
84 ))
85 if set_head:
86 ref = repo / ".muse" / "refs" / "heads" / branch
87 ref.parent.mkdir(parents=True, exist_ok=True)
88 ref.write_text(commit_id)
89 return commit_id
90
91
92 def _ls(repo: pathlib.Path, *args: str) -> InvokeResult:
93 from muse.cli.app import main as cli
94 return runner.invoke(
95 cli,
96 ["ls-files", *args],
97 env={"MUSE_REPO_ROOT": str(repo)},
98 )
99
100
101 def _lsj(repo: pathlib.Path, *args: str) -> InvokeResult:
102 """Like _ls but always passes --json."""
103 return _ls(repo, "--json", *args)
104
105
106 # ---------------------------------------------------------------------------
107 # Integration — JSON format
108 # ---------------------------------------------------------------------------
109
110
111 class TestJsonFormat:
112 def test_lists_files(self, tmp_path: pathlib.Path) -> None:
113 repo = _make_repo(tmp_path)
114 cid = _add_commit(repo, {"src/main.py": b"# main", "README.md": b"# readme"})
115 result = _lsj(repo)
116 assert result.exit_code == 0
117 data = json.loads(result.output)
118 assert data["file_count"] == 2
119 paths = [f["path"] for f in data["files"]]
120 assert "src/main.py" in paths
121 assert "README.md" in paths
122
123 def test_files_sorted_alphabetically(self, tmp_path: pathlib.Path) -> None:
124 repo = _make_repo(tmp_path)
125 _add_commit(repo, {"z.py": b"z", "a.py": b"a", "m.py": b"m"})
126 data = json.loads(_lsj(repo).output)
127 paths = [f["path"] for f in data["files"]]
128 assert paths == sorted(paths)
129
130 def test_json_has_commit_and_snapshot_id(self, tmp_path: pathlib.Path) -> None:
131 repo = _make_repo(tmp_path)
132 cid = _add_commit(repo, {"f.py": b"x"})
133 data = json.loads(_lsj(repo).output)
134 assert data["commit_id"] == cid
135 assert data["snapshot_id"].startswith("sha256:")
136
137 def test_empty_manifest(self, tmp_path: pathlib.Path) -> None:
138 repo = _make_repo(tmp_path)
139 _add_commit(repo, {})
140 data = json.loads(_lsj(repo).output)
141 assert data["file_count"] == 0
142 assert data["files"] == []
143
144 def test_json_flag_shorthand(self, tmp_path: pathlib.Path) -> None:
145 repo = _make_repo(tmp_path)
146 _add_commit(repo, {"f.py": b"x"})
147 result = _lsj(repo)
148 assert result.exit_code == 0
149 data = json.loads(result.output)
150 assert data["file_count"] == 1
151
152 def test_object_ids_are_sha256_prefixed(self, tmp_path: pathlib.Path) -> None:
153 repo = _make_repo(tmp_path)
154 _add_commit(repo, {"a.py": b"content"})
155 data = json.loads(_lsj(repo).output)
156 for f in data["files"]:
157 assert f["object_id"].startswith("sha256:")
158 _, hex_part = split_id(f["object_id"])
159 assert len(hex_part) == 64
160 assert all(c in "0123456789abcdef" for c in hex_part)
161
162
163 # ---------------------------------------------------------------------------
164 # Integration — text format
165 # ---------------------------------------------------------------------------
166
167
168 class TestTextFormat:
169 def test_text_tab_separated(self, tmp_path: pathlib.Path) -> None:
170 repo = _make_repo(tmp_path)
171 _add_commit(repo, {"hello.py": b"hi"})
172 # Default (no --json) emits text: <oid>\t<path> per line
173 result = _ls(repo)
174 assert result.exit_code == 0
175 line = result.output.strip()
176 parts = line.split("\t")
177 assert len(parts) == 2
178 assert parts[0].startswith("sha256:") # canonical object_id
179 assert parts[1] == "hello.py"
180
181 def test_text_oid_matches_json_oid(self, tmp_path: pathlib.Path) -> None:
182 repo = _make_repo(tmp_path)
183 _add_commit(repo, {"check.py": b"content"})
184 json_data = json.loads(_lsj(repo).output)
185 text_out = _ls(repo).output.strip()
186 json_oid = json_data["files"][0]["object_id"]
187 text_oid = text_out.split("\t")[0]
188 assert json_oid == text_oid
189
190
191 # ---------------------------------------------------------------------------
192 # Integration — --commit flag
193 # ---------------------------------------------------------------------------
194
195
196 class TestCommitFlag:
197 def test_explicit_commit_resolves(self, tmp_path: pathlib.Path) -> None:
198 repo = _make_repo(tmp_path)
199 cid = _add_commit(repo, {"explicit.py": b"content"})
200 result = _lsj(repo, "--commit", cid)
201 assert result.exit_code == 0
202 data = json.loads(result.output)
203 assert data["commit_id"] == cid
204
205 def test_invalid_commit_id_errors(self, tmp_path: pathlib.Path) -> None:
206 repo = _make_repo(tmp_path)
207 result = _ls(repo, "--commit", "not-a-valid-id")
208 assert result.exit_code == ExitCode.USER_ERROR
209
210 def test_nonexistent_commit_id_errors(self, tmp_path: pathlib.Path) -> None:
211 repo = _make_repo(tmp_path)
212 result = _ls(repo, "--commit", long_id("f" * 64))
213 assert result.exit_code == ExitCode.USER_ERROR
214
215 def test_no_commits_on_branch_errors(self, tmp_path: pathlib.Path) -> None:
216 repo = _make_repo(tmp_path)
217 result = _ls(repo)
218 assert result.exit_code == ExitCode.USER_ERROR
219
220
221 # ---------------------------------------------------------------------------
222 # Integration — --path-prefix filter
223 # ---------------------------------------------------------------------------
224
225
226 class TestPathPrefix:
227 def test_prefix_filters_to_subtree(self, tmp_path: pathlib.Path) -> None:
228 repo = _make_repo(tmp_path)
229 _add_commit(repo, {
230 "src/main.py": b"main",
231 "src/utils.py": b"utils",
232 "tests/test_main.py": b"test",
233 "README.md": b"readme",
234 })
235 data = json.loads(_lsj(repo, "--path-prefix", "src/").output)
236 paths = [f["path"] for f in data["files"]]
237 assert all(p.startswith("src/") for p in paths)
238 assert len(paths) == 2
239
240 def test_prefix_file_count_reflects_filter(self, tmp_path: pathlib.Path) -> None:
241 repo = _make_repo(tmp_path)
242 _add_commit(repo, {
243 "a/x.py": b"x",
244 "a/y.py": b"y",
245 "b/z.py": b"z",
246 })
247 data = json.loads(_lsj(repo, "--path-prefix", "a/").output)
248 assert data["file_count"] == 2
249
250 def test_prefix_no_match_returns_empty(self, tmp_path: pathlib.Path) -> None:
251 repo = _make_repo(tmp_path)
252 _add_commit(repo, {"src/main.py": b"main"})
253 data = json.loads(_lsj(repo, "--path-prefix", "tests/").output)
254 assert data["file_count"] == 0
255 assert data["files"] == []
256
257 def test_prefix_text_format(self, tmp_path: pathlib.Path) -> None:
258 repo = _make_repo(tmp_path)
259 _add_commit(repo, {"src/a.py": b"a", "tests/b.py": b"b"})
260 # Default (no --json) with --path-prefix emits text lines
261 result = _ls(repo, "--path-prefix", "src/")
262 assert result.exit_code == 0
263 lines = [l for l in result.output.strip().splitlines() if l]
264 assert len(lines) == 1
265 assert "src/a.py" in lines[0]
266
267
268 # ---------------------------------------------------------------------------
269 # Security
270 # ---------------------------------------------------------------------------
271
272
273 class TestSecurity:
274 def test_ansi_in_path_stripped_in_text_mode(self, tmp_path: pathlib.Path) -> None:
275 """File path with ANSI escape must be sanitized in text mode."""
276 repo = _make_repo(tmp_path)
277 evil_path = "src/\x1b[31mevil\x1b[0m.py"
278 _add_commit(repo, {evil_path: b"content"})
279 # Default (no --json) emits sanitized text
280 result = _ls(repo)
281 assert result.exit_code == 0
282 assert "\x1b" not in result.output
283
284 def test_ansi_in_path_preserved_in_json(self, tmp_path: pathlib.Path) -> None:
285 """JSON mode encodes ANSI as \\u001b — never emits raw escape sequences."""
286 repo = _make_repo(tmp_path)
287 evil_path = "src/\x1b[31mevil\x1b[0m.py"
288 _add_commit(repo, {evil_path: b"content"})
289 result = _lsj(repo)
290 assert result.exit_code == 0
291 # No raw ANSI bytes in stdout — json.dumps encodes \x1b as \u001b
292 assert "\x1b" not in result.output
293 data = json.loads(result.output)
294 # The path is preserved in the JSON payload (as \u001b-encoded)
295 paths = [f["path"] for f in data["files"]]
296 assert any("\x1b" in p or "\u001b" in p for p in paths)
297
298 def test_path_traversal_commit_id_rejected(self, tmp_path: pathlib.Path) -> None:
299 repo = _make_repo(tmp_path)
300 result = _ls(repo, "--commit", "../../../etc/passwd")
301 assert result.exit_code == ExitCode.USER_ERROR
302
303 def test_no_traceback_on_invalid_input(self, tmp_path: pathlib.Path) -> None:
304 repo = _make_repo(tmp_path)
305 result = _ls(repo, "--commit", "bad!")
306 assert "Traceback" not in result.output
307
308
309 # ---------------------------------------------------------------------------
310 # Stress
311 # ---------------------------------------------------------------------------
312
313
314 class TestStress:
315 def test_1000_file_manifest(self, tmp_path: pathlib.Path) -> None:
316 """1 000-file manifest lists and returns in reasonable time."""
317 repo = _make_repo(tmp_path)
318 manifest = {f"src/file_{i:04d}.py": f"content {i}".encode() for i in range(1000)}
319 _add_commit(repo, manifest)
320 result = _lsj(repo)
321 assert result.exit_code == 0
322 data = json.loads(result.output)
323 assert data["file_count"] == 1000
324
325 def test_1000_file_prefix_filter(self, tmp_path: pathlib.Path) -> None:
326 repo = _make_repo(tmp_path)
327 manifest = {f"a/file_{i:04d}.py": b"a" for i in range(500)}
328 manifest.update({f"b/file_{i:04d}.py": b"b" for i in range(500)})
329 _add_commit(repo, manifest)
330 data = json.loads(_lsj(repo, "--path-prefix", "a/").output)
331 assert data["file_count"] == 500
332
333 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None:
334 repo = _make_repo(tmp_path)
335 _add_commit(repo, {"stable.py": b"content"})
336 for i in range(200):
337 result = _lsj(repo)
338 assert result.exit_code == 0, f"failed at iteration {i}"
339 assert json.loads(result.output)["file_count"] == 1
340
341
342 class TestRegisterFlags:
343 def test_json_short_flag(self):
344 import argparse
345 from muse.cli.commands.ls_files import register
346 p = argparse.ArgumentParser()
347 subs = p.add_subparsers()
348 register(subs)
349 args = p.parse_args(["ls-files", "-j"])
350 assert args.json_out is True
351
352 def test_json_long_flag(self):
353 import argparse
354 from muse.cli.commands.ls_files import register
355 p = argparse.ArgumentParser()
356 subs = p.add_subparsers()
357 register(subs)
358 args = p.parse_args(["ls-files", "--json"])
359 assert args.json_out is True
360
361 def test_default_no_json(self):
362 import argparse
363 from muse.cli.commands.ls_files import register
364 p = argparse.ArgumentParser()
365 subs = p.add_subparsers()
366 register(subs)
367 # Command-specific required args may differ; just check dest exists when possible
368 try:
369 args = p.parse_args(["ls-files"])
370 assert args.json_out is False
371 except SystemExit:
372 pass # required positional args missing — flag default still correct
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago