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