gabriel / muse public
test_cmd_ls_tree.py python
497 lines 17.3 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 148 days ago
1 """Tests for ``muse ls-tree`` — directory-aware tree listing from a snapshot.
2
3 Coverage tiers:
4 - Unit: _build_tree_entries, _synthetic_tree_id helpers
5 - Integration: root listing (files + synthetic dirs), path-scoped listing,
6 -r/--recursive (all blobs, no synthetic dirs), --name-only,
7 -l/--long (includes object size), -d/--dirs-only,
8 branch ref, commit ID ref, --json schema, text format,
9 mode strings (100644 for blob, 040000 for tree)
10 - End-to-end: full CLI via CliRunner
11 - Security: path traversal in path arg rejected, ANSI in ref rejected
12 - Edge cases: empty repo, nonexistent ref, path not in tree
13 - Stress: 500-file repo, tree listing root and deep prefix
14 """
15
16 from __future__ import annotations
17
18 import datetime
19 import hashlib
20 import json
21 import pathlib
22
23 import pytest
24
25 from tests.cli_test_helper import CliRunner
26
27 from muse.core.object_store import write_object
28 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
29 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
30 from muse.core._types import Manifest
31
32 runner = CliRunner()
33
34 _REPO_ID = "ls-tree-test"
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _sha(data: bytes) -> str:
43 return hashlib.sha256(data).hexdigest()
44
45
46 _counter = 0
47
48
49 def _init_repo(path: pathlib.Path) -> pathlib.Path:
50 muse = path / ".muse"
51 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
52 (muse / d).mkdir(parents=True, exist_ok=True)
53 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
54 (muse / "repo.json").write_text(
55 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
56 )
57 return path
58
59
60 def _env(repo: pathlib.Path) -> dict[str, str]:
61 return {"MUSE_REPO_ROOT": str(repo)}
62
63
64 def _commit_files(
65 root: pathlib.Path,
66 files: dict[str, bytes],
67 branch: str = "main",
68 ) -> str:
69 global _counter
70 _counter += 1
71 manifest: Manifest = {}
72 for rel_path, content in files.items():
73 obj_id = _sha(content)
74 write_object(root, obj_id, content)
75 manifest[rel_path] = obj_id
76 abs_path = root / rel_path
77 abs_path.parent.mkdir(parents=True, exist_ok=True)
78 abs_path.write_bytes(content)
79 snap_id = compute_snapshot_id(manifest)
80 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
81 committed_at = datetime.datetime.now(datetime.timezone.utc)
82 commit_id = compute_commit_id(
83 [], snap_id, f"commit {_counter}", committed_at.isoformat()
84 )
85 write_commit(
86 root,
87 CommitRecord(
88 commit_id=commit_id,
89 repo_id=_REPO_ID,
90 branch=branch,
91 snapshot_id=snap_id,
92 message=f"commit {_counter}",
93 committed_at=committed_at,
94 ),
95 )
96 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
97 return commit_id
98
99
100 def _invoke(repo: pathlib.Path, *args: str):
101 from muse.cli.app import main as cli
102 return runner.invoke(cli, ["ls-tree", *args], env=_env(repo))
103
104
105 # ---------------------------------------------------------------------------
106 # Unit — _build_tree_entries
107 # ---------------------------------------------------------------------------
108
109
110 def test_build_tree_entries_separates_blobs_and_dirs() -> None:
111 from muse.cli.commands.ls_tree import _build_tree_entries
112 manifest = {
113 "README.md": "a" * 64,
114 "src/main.py": "b" * 64,
115 "src/utils.py": "c" * 64,
116 "docs/guide.md": "d" * 64,
117 }
118 entries = _build_tree_entries(manifest, path_prefix="", recursive=False)
119 types = {e["path"]: e["type"] for e in entries}
120 assert types["README.md"] == "blob"
121 assert types["src/"] == "tree"
122 assert types["docs/"] == "tree"
123 # Should not show src/main.py at root level (not recursive)
124 assert "src/main.py" not in types
125 assert "src/utils.py" not in types
126
127
128 def test_build_tree_entries_recursive_only_blobs() -> None:
129 from muse.cli.commands.ls_tree import _build_tree_entries
130 manifest = {
131 "README.md": "a" * 64,
132 "src/main.py": "b" * 64,
133 "src/sub/helper.py": "c" * 64,
134 }
135 entries = _build_tree_entries(manifest, path_prefix="", recursive=True)
136 types = [e["type"] for e in entries]
137 assert all(t == "blob" for t in types), f"Got non-blob entries: {types}"
138 paths = [e["path"] for e in entries]
139 assert "src/main.py" in paths
140 assert "src/sub/helper.py" in paths
141
142
143 def test_build_tree_entries_path_prefix_scoping() -> None:
144 from muse.cli.commands.ls_tree import _build_tree_entries
145 manifest = {
146 "src/main.py": "b" * 64,
147 "src/sub/helper.py": "c" * 64,
148 "root.py": "d" * 64,
149 }
150 entries = _build_tree_entries(manifest, path_prefix="src/", recursive=False)
151 paths = [e["path"] for e in entries]
152 assert "src/main.py" in paths
153 assert "src/sub/" in paths
154 assert "root.py" not in paths
155
156
157 def test_build_tree_entries_sorted() -> None:
158 from muse.cli.commands.ls_tree import _build_tree_entries
159 manifest = {
160 "z.py": "a" * 64,
161 "a.py": "b" * 64,
162 "m.py": "c" * 64,
163 }
164 entries = _build_tree_entries(manifest, path_prefix="", recursive=False)
165 paths = [e["path"] for e in entries]
166 assert paths == sorted(paths)
167
168
169 def test_synthetic_tree_id_is_deterministic() -> None:
170 from muse.cli.commands.ls_tree import _synthetic_tree_id
171 manifest = {"src/a.py": "x" * 64, "src/b.py": "y" * 64}
172 id1 = _synthetic_tree_id(manifest, "src/")
173 id2 = _synthetic_tree_id(manifest, "src/")
174 assert id1 == id2
175 assert len(id1) == 64
176
177
178 def test_synthetic_tree_id_differs_for_different_content() -> None:
179 from muse.cli.commands.ls_tree import _synthetic_tree_id
180 manifest_a = {"src/a.py": "x" * 64}
181 manifest_b = {"src/b.py": "y" * 64}
182 assert _synthetic_tree_id(manifest_a, "src/") != _synthetic_tree_id(manifest_b, "src/")
183
184
185 # ---------------------------------------------------------------------------
186 # Integration — root listing (non-recursive)
187 # ---------------------------------------------------------------------------
188
189
190 def test_ls_tree_root_shows_blob_for_root_file(tmp_path: pathlib.Path) -> None:
191 root = _init_repo(tmp_path)
192 _commit_files(root, {"README.md": b"# readme\n"})
193 result = _invoke(root, "HEAD", "--json")
194 assert result.exit_code == 0
195 data = json.loads(result.stdout)
196 paths = [e["path"] for e in data["entries"]]
197 assert "README.md" in paths
198
199
200 def test_ls_tree_root_shows_synthetic_tree_for_subdir(tmp_path: pathlib.Path) -> None:
201 root = _init_repo(tmp_path)
202 _commit_files(root, {"src/main.py": b"# main\n", "README.md": b"# r\n"})
203 result = _invoke(root, "HEAD", "--json")
204 assert result.exit_code == 0
205 data = json.loads(result.stdout)
206 types = {e["path"]: e["type"] for e in data["entries"]}
207 assert types.get("README.md") == "blob"
208 assert types.get("src/") == "tree"
209 # src/main.py should NOT appear at root level
210 assert "src/main.py" not in types
211
212
213 def test_ls_tree_root_blob_mode_is_100644(tmp_path: pathlib.Path) -> None:
214 root = _init_repo(tmp_path)
215 _commit_files(root, {"a.py": b"# a\n"})
216 result = _invoke(root, "HEAD", "--json")
217 data = json.loads(result.stdout)
218 blob = next(e for e in data["entries"] if e["type"] == "blob")
219 assert blob["mode"] == "100644"
220
221
222 def test_ls_tree_root_tree_mode_is_040000(tmp_path: pathlib.Path) -> None:
223 root = _init_repo(tmp_path)
224 _commit_files(root, {"src/a.py": b"# a\n"})
225 result = _invoke(root, "HEAD", "--json")
226 data = json.loads(result.stdout)
227 tree = next(e for e in data["entries"] if e["type"] == "tree")
228 assert tree["mode"] == "040000"
229
230
231 def test_ls_tree_entries_sorted_alphabetically(tmp_path: pathlib.Path) -> None:
232 root = _init_repo(tmp_path)
233 _commit_files(root, {"z.py": b"# z\n", "a.py": b"# a\n", "src/m.py": b"# m\n"})
234 result = _invoke(root, "HEAD", "--json")
235 data = json.loads(result.stdout)
236 paths = [e["path"] for e in data["entries"]]
237 assert paths == sorted(paths)
238
239
240 # ---------------------------------------------------------------------------
241 # Integration — path-scoped listing
242 # ---------------------------------------------------------------------------
243
244
245 def test_ls_tree_path_arg_scopes_to_directory(tmp_path: pathlib.Path) -> None:
246 root = _init_repo(tmp_path)
247 _commit_files(root, {
248 "src/main.py": b"# main\n",
249 "src/sub/helper.py": b"# helper\n",
250 "root.py": b"# root\n",
251 })
252 result = _invoke(root, "HEAD", "src/", "--json")
253 assert result.exit_code == 0
254 data = json.loads(result.stdout)
255 paths = [e["path"] for e in data["entries"]]
256 assert "src/main.py" in paths
257 assert "src/sub/" in paths
258 assert "root.py" not in paths
259
260
261 def test_ls_tree_path_arg_nonexistent_shows_empty(tmp_path: pathlib.Path) -> None:
262 root = _init_repo(tmp_path)
263 _commit_files(root, {"a.py": b"# a\n"})
264 result = _invoke(root, "HEAD", "nonexistent/", "--json")
265 assert result.exit_code == 0
266 data = json.loads(result.stdout)
267 assert data["entries"] == []
268
269
270 # ---------------------------------------------------------------------------
271 # Integration — --recursive
272 # ---------------------------------------------------------------------------
273
274
275 def test_ls_tree_recursive_lists_all_blobs(tmp_path: pathlib.Path) -> None:
276 root = _init_repo(tmp_path)
277 _commit_files(root, {
278 "a.py": b"# a\n",
279 "src/b.py": b"# b\n",
280 "src/deep/c.py": b"# c\n",
281 })
282 result = _invoke(root, "-r", "HEAD", "--json")
283 assert result.exit_code == 0
284 data = json.loads(result.stdout)
285 paths = [e["path"] for e in data["entries"]]
286 assert "a.py" in paths
287 assert "src/b.py" in paths
288 assert "src/deep/c.py" in paths
289
290
291 def test_ls_tree_recursive_no_tree_entries(tmp_path: pathlib.Path) -> None:
292 root = _init_repo(tmp_path)
293 _commit_files(root, {"src/a.py": b"# a\n", "src/b.py": b"# b\n"})
294 result = _invoke(root, "-r", "HEAD", "--json")
295 data = json.loads(result.stdout)
296 assert all(e["type"] == "blob" for e in data["entries"])
297
298
299 def test_ls_tree_recursive_with_path_prefix(tmp_path: pathlib.Path) -> None:
300 root = _init_repo(tmp_path)
301 _commit_files(root, {
302 "src/a.py": b"# a\n",
303 "lib/b.py": b"# b\n",
304 })
305 result = _invoke(root, "-r", "HEAD", "src/", "--json")
306 data = json.loads(result.stdout)
307 paths = [e["path"] for e in data["entries"]]
308 assert "src/a.py" in paths
309 assert "lib/b.py" not in paths
310
311
312 # ---------------------------------------------------------------------------
313 # Integration — --name-only
314 # ---------------------------------------------------------------------------
315
316
317 def test_ls_tree_name_only_text_no_metadata(tmp_path: pathlib.Path) -> None:
318 root = _init_repo(tmp_path)
319 _commit_files(root, {"a.py": b"# a\n", "src/b.py": b"# b\n"})
320 result = _invoke(root, "HEAD", "--name-only")
321 assert result.exit_code == 0
322 # Should have just names, no tabs or object IDs
323 for line in result.stdout.strip().splitlines():
324 assert "\t" not in line
325 assert len(line) < 100 # no 64-char SHA
326
327
328 def test_ls_tree_name_only_json(tmp_path: pathlib.Path) -> None:
329 root = _init_repo(tmp_path)
330 _commit_files(root, {"a.py": b"# a\n"})
331 result = _invoke(root, "HEAD", "--name-only", "--json")
332 data = json.loads(result.stdout)
333 # entries should have 'path' but no 'object_id'
334 for e in data["entries"]:
335 assert "path" in e
336 assert "object_id" not in e
337
338
339 # ---------------------------------------------------------------------------
340 # Integration — --long (-l)
341 # ---------------------------------------------------------------------------
342
343
344 def test_ls_tree_long_includes_size_for_blobs(tmp_path: pathlib.Path) -> None:
345 root = _init_repo(tmp_path)
346 content = b"hello world\n"
347 _commit_files(root, {"hello.py": content})
348 result = _invoke(root, "-l", "HEAD", "--json")
349 assert result.exit_code == 0
350 data = json.loads(result.stdout)
351 blob = next(e for e in data["entries"] if e["type"] == "blob")
352 assert blob["size"] == len(content)
353
354
355 def test_ls_tree_long_tree_size_is_none(tmp_path: pathlib.Path) -> None:
356 root = _init_repo(tmp_path)
357 _commit_files(root, {"src/a.py": b"# a\n"})
358 result = _invoke(root, "-l", "HEAD", "--json")
359 data = json.loads(result.stdout)
360 tree = next(e for e in data["entries"] if e["type"] == "tree")
361 assert tree["size"] is None
362
363
364 # ---------------------------------------------------------------------------
365 # Integration — -d / --dirs-only
366 # ---------------------------------------------------------------------------
367
368
369 def test_ls_tree_dirs_only_shows_only_trees(tmp_path: pathlib.Path) -> None:
370 root = _init_repo(tmp_path)
371 _commit_files(root, {"root.py": b"# r\n", "src/a.py": b"# a\n", "lib/b.py": b"# b\n"})
372 result = _invoke(root, "-d", "HEAD", "--json")
373 assert result.exit_code == 0
374 data = json.loads(result.stdout)
375 assert all(e["type"] == "tree" for e in data["entries"])
376 types = [e["path"] for e in data["entries"]]
377 assert "src/" in types
378 assert "lib/" in types
379 assert "root.py" not in types
380
381
382 # ---------------------------------------------------------------------------
383 # Integration — ref targeting (branch name and commit ID)
384 # ---------------------------------------------------------------------------
385
386
387 def test_ls_tree_branch_name_ref(tmp_path: pathlib.Path) -> None:
388 root = _init_repo(tmp_path)
389 _commit_files(root, {"a.py": b"# a\n"}, branch="main")
390 result = _invoke(root, "main", "--json")
391 assert result.exit_code == 0
392 data = json.loads(result.stdout)
393 assert any(e["path"] == "a.py" for e in data["entries"])
394
395
396 def test_ls_tree_commit_id_ref(tmp_path: pathlib.Path) -> None:
397 root = _init_repo(tmp_path)
398 commit_id = _commit_files(root, {"b.py": b"# b\n"})
399 result = _invoke(root, commit_id, "--json")
400 assert result.exit_code == 0
401 data = json.loads(result.stdout)
402 assert any(e["path"] == "b.py" for e in data["entries"])
403
404
405 def test_ls_tree_nonexistent_ref_exits_nonzero(tmp_path: pathlib.Path) -> None:
406 root = _init_repo(tmp_path)
407 _commit_files(root, {"a.py": b"# a\n"})
408 result = _invoke(root, "no-such-branch", "--json")
409 assert result.exit_code != 0
410
411
412 def test_ls_tree_empty_repo_exits_nonzero(tmp_path: pathlib.Path) -> None:
413 root = _init_repo(tmp_path)
414 result = _invoke(root, "HEAD", "--json")
415 assert result.exit_code != 0
416
417
418 # ---------------------------------------------------------------------------
419 # Integration — text format
420 # ---------------------------------------------------------------------------
421
422
423 def test_ls_tree_text_format_tab_separated(tmp_path: pathlib.Path) -> None:
424 root = _init_repo(tmp_path)
425 _commit_files(root, {"a.py": b"# a\n"})
426 result = _invoke(root, "HEAD")
427 assert result.exit_code == 0
428 lines = [l for l in result.stdout.strip().splitlines() if l]
429 assert len(lines) >= 1
430 # Default text format: "<mode> <type> <object_id>\t<path>"
431 for line in lines:
432 assert "\t" in line
433 meta, path = line.split("\t", 1)
434 parts = meta.split()
435 assert len(parts) == 3
436 assert parts[0] in ("100644", "040000")
437 assert parts[1] in ("blob", "tree")
438
439
440 def test_ls_tree_json_output_has_commit_id(tmp_path: pathlib.Path) -> None:
441 root = _init_repo(tmp_path)
442 commit_id = _commit_files(root, {"a.py": b"# a\n"})
443 result = _invoke(root, "HEAD", "--json")
444 data = json.loads(result.stdout)
445 assert data["commit_id"] == commit_id
446 assert "entries" in data
447 assert "treeish" in data
448
449
450 # ---------------------------------------------------------------------------
451 # Security
452 # ---------------------------------------------------------------------------
453
454
455 def test_ls_tree_path_traversal_in_path_arg_rejected(tmp_path: pathlib.Path) -> None:
456 root = _init_repo(tmp_path)
457 _commit_files(root, {"a.py": b"# a\n"})
458 result = _invoke(root, "HEAD", "../../../etc/")
459 assert result.exit_code != 0
460
461
462 def test_ls_tree_ansi_in_ref_rejected(tmp_path: pathlib.Path) -> None:
463 root = _init_repo(tmp_path)
464 _commit_files(root, {"a.py": b"# a\n"})
465 result = _invoke(root, "\x1b[31mbad\x1b[0m")
466 assert result.exit_code != 0
467
468
469 # ---------------------------------------------------------------------------
470 # Stress
471 # ---------------------------------------------------------------------------
472
473
474 def test_ls_tree_500_files_root_listing(tmp_path: pathlib.Path) -> None:
475 """Root listing of a 500-file repo must complete and show correct dir entries."""
476 root = _init_repo(tmp_path)
477 files = {}
478 for i in range(10):
479 for j in range(50):
480 files[f"pkg_{i}/module_{j}.py"] = f"# {i},{j}\n".encode()
481 _commit_files(root, files)
482 result = _invoke(root, "HEAD", "--json")
483 assert result.exit_code == 0
484 data = json.loads(result.stdout)
485 # Root level should have 10 synthetic tree entries, one per pkg_*
486 trees = [e for e in data["entries"] if e["type"] == "tree"]
487 assert len(trees) == 10
488
489
490 def test_ls_tree_500_files_recursive(tmp_path: pathlib.Path) -> None:
491 root = _init_repo(tmp_path)
492 files = {f"pkg_{i}/mod_{j}.py": b"# x\n" for i in range(10) for j in range(50)}
493 _commit_files(root, files)
494 result = _invoke(root, "-r", "HEAD", "--json")
495 assert result.exit_code == 0
496 data = json.loads(result.stdout)
497 assert len(data["entries"]) == 500
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 148 days ago