gabriel / muse public

test_cmd_ls_tree.py file-level

at sha256:1 · View file ↗ · Intel ↗

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