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