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