gabriel / muse public
test_cmd_sparse_checkout.py python
388 lines 15.2 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 124 days ago
1 """Tests for ``muse sparse-checkout`` — partial working-tree materialization.
2
3 Coverage tiers:
4 - Unit: init creates config; set replaces patterns; add appends patterns;
5 list shows patterns; disable removes config; cone matching;
6 pattern (glob) matching; filter_manifest_sparse; auto-read from root
7 - Integration: checkout respects sparse config; disable restores full tree;
8 cone mode directory filtering; pattern mode glob filtering;
9 JSON output for list; init --no-cone switches to pattern mode
10 - Security: ANSI injection in pattern name rejected; path traversal in pattern rejected
11 - Stress: 200-file manifest filtered to cone subdirectory (≤ 20 files)
12 """
13
14 from __future__ import annotations
15 from collections.abc import Mapping
16
17 import datetime
18 import json
19 import pathlib
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24 from muse.core.object_store import write_object
25 from muse.core.paths import muse_dir, sparse_checkout_path
26 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
27 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
28 from muse.core.types import Manifest, blob_id, load_json_file
29
30 runner = CliRunner()
31
32 _REPO_ID = "sparse-checkout-test"
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40
41 def _init_repo(path: pathlib.Path) -> pathlib.Path:
42 dot_muse = muse_dir(path)
43 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
44 (dot_muse / d).mkdir(parents=True, exist_ok=True)
45 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
46 (dot_muse / "repo.json").write_text(
47 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
48 )
49 return path
50
51
52 def _env(repo: pathlib.Path) -> Mapping[str, str]:
53 return {"MUSE_REPO_ROOT": str(repo)}
54
55
56 def _write_files(root: pathlib.Path, files: Mapping[str, bytes]) -> Manifest:
57 manifest: Manifest = {}
58 for rel_path, content in files.items():
59 obj_id = blob_id(content)
60 write_object(root, obj_id, content)
61 manifest[rel_path] = obj_id
62 abs_path = root / rel_path
63 abs_path.parent.mkdir(parents=True, exist_ok=True)
64 abs_path.write_bytes(content)
65 return manifest
66
67
68 def _invoke(args: list[str], repo: pathlib.Path) -> tuple[int, str, str]:
69 """Invoke muse with MUSE_REPO_ROOT set; return (exit_code, stdout, stderr)."""
70 result = runner.invoke(None, args, env=_env(repo))
71 return result.exit_code, result.stdout, result.stderr
72
73
74 def _sparse_config(repo: pathlib.Path) -> pathlib.Path:
75 return sparse_checkout_path(repo)
76
77
78 # ---------------------------------------------------------------------------
79 # Unit — init
80 # ---------------------------------------------------------------------------
81
82
83 class TestInit:
84 def test_init_creates_config_file(self, tmp_path: pathlib.Path) -> None:
85 repo = _init_repo(tmp_path / "repo")
86 rc, out, err = _invoke(["sparse-checkout", "init"], repo)
87 assert rc == 0
88 assert _sparse_config(repo).exists()
89
90 def test_init_default_mode_is_cone(self, tmp_path: pathlib.Path) -> None:
91 repo = _init_repo(tmp_path / "repo")
92 _invoke(["sparse-checkout", "init"], repo)
93 cfg = load_json_file(_sparse_config(repo))
94 assert cfg["mode"] == "cone"
95
96 def test_init_no_cone_sets_pattern_mode(self, tmp_path: pathlib.Path) -> None:
97 repo = _init_repo(tmp_path / "repo")
98 rc, out, err = _invoke(["sparse-checkout", "init", "--no-cone"], repo)
99 assert rc == 0
100 cfg = load_json_file(_sparse_config(repo))
101 assert cfg["mode"] == "pattern"
102
103 def test_init_idempotent(self, tmp_path: pathlib.Path) -> None:
104 repo = _init_repo(tmp_path / "repo")
105 _invoke(["sparse-checkout", "init"], repo)
106 _invoke(["sparse-checkout", "set", "src/"], repo)
107 rc, out, err = _invoke(["sparse-checkout", "init"], repo)
108 assert rc == 0
109 assert _sparse_config(repo).exists()
110
111
112 # ---------------------------------------------------------------------------
113 # Unit — set
114 # ---------------------------------------------------------------------------
115
116
117 class TestSet:
118 def test_set_writes_patterns(self, tmp_path: pathlib.Path) -> None:
119 repo = _init_repo(tmp_path / "repo")
120 _invoke(["sparse-checkout", "init"], repo)
121 rc, out, err = _invoke(["sparse-checkout", "set", "src/", "tests/"], repo)
122 assert rc == 0
123 cfg = load_json_file(_sparse_config(repo))
124 assert cfg["patterns"] == ["src/", "tests/"]
125
126 def test_set_replaces_existing(self, tmp_path: pathlib.Path) -> None:
127 repo = _init_repo(tmp_path / "repo")
128 _invoke(["sparse-checkout", "init"], repo)
129 _invoke(["sparse-checkout", "set", "old/"], repo)
130 _invoke(["sparse-checkout", "set", "new/"], repo)
131 cfg = load_json_file(_sparse_config(repo))
132 assert cfg["patterns"] == ["new/"]
133
134 def test_set_without_init_fails(self, tmp_path: pathlib.Path) -> None:
135 repo = _init_repo(tmp_path / "repo")
136 rc, out, err = _invoke(["sparse-checkout", "set", "src/"], repo)
137 assert rc != 0
138
139 def test_set_ansi_injection_rejected(self, tmp_path: pathlib.Path) -> None:
140 repo = _init_repo(tmp_path / "repo")
141 _invoke(["sparse-checkout", "init"], repo)
142 rc, out, err = _invoke(["sparse-checkout", "set", "\x1b[31mbad/\x1b[0m"], repo)
143 assert rc != 0
144
145
146 # ---------------------------------------------------------------------------
147 # Unit — add
148 # ---------------------------------------------------------------------------
149
150
151 class TestAdd:
152 def test_add_appends_patterns(self, tmp_path: pathlib.Path) -> None:
153 repo = _init_repo(tmp_path / "repo")
154 _invoke(["sparse-checkout", "init"], repo)
155 _invoke(["sparse-checkout", "set", "src/"], repo)
156 rc, out, err = _invoke(["sparse-checkout", "add", "tests/"], repo)
157 assert rc == 0
158 cfg = load_json_file(_sparse_config(repo))
159 assert "src/" in cfg["patterns"]
160 assert "tests/" in cfg["patterns"]
161
162 def test_add_deduplicates(self, tmp_path: pathlib.Path) -> None:
163 repo = _init_repo(tmp_path / "repo")
164 _invoke(["sparse-checkout", "init"], repo)
165 _invoke(["sparse-checkout", "set", "src/"], repo)
166 _invoke(["sparse-checkout", "add", "src/"], repo)
167 cfg = load_json_file(_sparse_config(repo))
168 assert cfg["patterns"].count("src/") == 1
169
170 def test_add_without_init_fails(self, tmp_path: pathlib.Path) -> None:
171 repo = _init_repo(tmp_path / "repo")
172 rc, out, err = _invoke(["sparse-checkout", "add", "src/"], repo)
173 assert rc != 0
174
175
176 # ---------------------------------------------------------------------------
177 # Unit — list
178 # ---------------------------------------------------------------------------
179
180
181 class TestList:
182 def test_list_shows_patterns_text(self, tmp_path: pathlib.Path) -> None:
183 repo = _init_repo(tmp_path / "repo")
184 _invoke(["sparse-checkout", "init"], repo)
185 _invoke(["sparse-checkout", "set", "src/", "docs/"], repo)
186 rc, out, err = _invoke(["sparse-checkout", "list"], repo)
187 assert rc == 0
188 assert "src/" in out
189 assert "docs/" in out
190
191 def test_list_json(self, tmp_path: pathlib.Path) -> None:
192 repo = _init_repo(tmp_path / "repo")
193 _invoke(["sparse-checkout", "init"], repo)
194 _invoke(["sparse-checkout", "set", "src/"], repo)
195 rc, out, err = _invoke(["sparse-checkout", "list", "--json"], repo)
196 assert rc == 0
197 data = json.loads(out)
198 assert data["mode"] == "cone"
199 assert "src/" in data["patterns"]
200
201 def test_list_when_disabled(self, tmp_path: pathlib.Path) -> None:
202 repo = _init_repo(tmp_path / "repo")
203 rc, out, err = _invoke(["sparse-checkout", "list"], repo)
204 assert rc == 0
205 assert "disabled" in out.lower()
206
207
208 # ---------------------------------------------------------------------------
209 # Unit — disable
210 # ---------------------------------------------------------------------------
211
212
213 class TestDisable:
214 def test_disable_removes_config(self, tmp_path: pathlib.Path) -> None:
215 repo = _init_repo(tmp_path / "repo")
216 _invoke(["sparse-checkout", "init"], repo)
217 _invoke(["sparse-checkout", "set", "src/"], repo)
218 rc, out, err = _invoke(["sparse-checkout", "disable"], repo)
219 assert rc == 0
220 assert not _sparse_config(repo).exists()
221
222 def test_disable_when_not_active_is_noop(self, tmp_path: pathlib.Path) -> None:
223 repo = _init_repo(tmp_path / "repo")
224 rc, out, err = _invoke(["sparse-checkout", "disable"], repo)
225 assert rc == 0
226
227
228 # ---------------------------------------------------------------------------
229 # Unit — core filter logic
230 # ---------------------------------------------------------------------------
231
232
233 class TestFilterLogic:
234 def test_cone_includes_root_files(self) -> None:
235 from muse.core.sparse import matches_sparse
236 assert matches_sparse("README.md", ["src/"], mode="cone")
237 assert matches_sparse("Makefile", ["src/"], mode="cone")
238
239 def test_cone_includes_files_in_pattern_dir(self) -> None:
240 from muse.core.sparse import matches_sparse
241 assert matches_sparse("src/foo.py", ["src/"], mode="cone")
242 assert matches_sparse("src/bar/baz.py", ["src/"], mode="cone")
243
244 def test_cone_excludes_other_dirs(self) -> None:
245 from muse.core.sparse import matches_sparse
246 assert not matches_sparse("tests/test_foo.py", ["src/"], mode="cone")
247 assert not matches_sparse("docs/readme.md", ["src/"], mode="cone")
248
249 def test_cone_multiple_dirs(self) -> None:
250 from muse.core.sparse import matches_sparse
251 assert matches_sparse("src/foo.py", ["src/", "tests/"], mode="cone")
252 assert matches_sparse("tests/test_foo.py", ["src/", "tests/"], mode="cone")
253 assert not matches_sparse("docs/guide.md", ["src/", "tests/"], mode="cone")
254
255 def test_pattern_mode_glob(self) -> None:
256 from muse.core.sparse import matches_sparse
257 assert matches_sparse("src/foo.py", ["src/**"], mode="pattern")
258 assert not matches_sparse("tests/foo.py", ["src/**"], mode="pattern")
259
260 def test_pattern_mode_extension(self) -> None:
261 from muse.core.sparse import matches_sparse
262 assert matches_sparse("foo.py", ["*.py"], mode="pattern")
263 assert not matches_sparse("foo.txt", ["*.py"], mode="pattern")
264
265 def test_filter_manifest_sparse_cone(self) -> None:
266 from muse.core.sparse import filter_manifest_sparse
267 manifest = {
268 "README.md": "aaa",
269 "src/foo.py": "bbb",
270 "tests/test_foo.py": "ccc",
271 "docs/guide.md": "ddd",
272 }
273 result = filter_manifest_sparse(manifest, ["src/"], mode="cone")
274 assert "README.md" in result
275 assert "src/foo.py" in result
276 assert "tests/test_foo.py" not in result
277 assert "docs/guide.md" not in result
278
279 def test_filter_manifest_sparse_pattern(self) -> None:
280 from muse.core.sparse import filter_manifest_sparse
281 manifest = {
282 "src/foo.py": "aaa",
283 "src/bar.txt": "bbb",
284 "tests/test_foo.py": "ccc",
285 }
286 result = filter_manifest_sparse(manifest, ["**/*.py"], mode="pattern")
287 assert "src/foo.py" in result
288 assert "tests/test_foo.py" in result
289 assert "src/bar.txt" not in result
290
291
292 # ---------------------------------------------------------------------------
293 # Integration — apply_manifest auto-reads sparse config
294 # ---------------------------------------------------------------------------
295
296
297 class TestApplyManifestSparse:
298 def test_apply_manifest_respects_sparse_config(self, tmp_path: pathlib.Path) -> None:
299 """apply_manifest should only materialize files matching sparse patterns."""
300 from muse.core.workdir import apply_manifest
301 repo = _init_repo(tmp_path / "repo")
302 _sparse_config(repo).write_text(
303 json.dumps({"mode": "cone", "patterns": ["src/"]}), encoding="utf-8"
304 )
305 manifest = {
306 "README.md": blob_id(b"readme"),
307 "src/foo.py": blob_id(b"foo"),
308 "tests/test_foo.py": blob_id(b"test"),
309 }
310 write_object(repo, blob_id(b"readme"), b"readme")
311 write_object(repo, blob_id(b"foo"), b"foo")
312 write_object(repo, blob_id(b"test"), b"test")
313
314 apply_manifest(repo, {}, manifest)
315
316 assert (repo / "README.md").exists()
317 assert (repo / "src" / "foo.py").exists()
318 assert not (repo / "tests" / "test_foo.py").exists()
319
320 def test_apply_manifest_no_sparse_config_writes_all(self, tmp_path: pathlib.Path) -> None:
321 """Without sparse config, apply_manifest writes everything."""
322 from muse.core.workdir import apply_manifest
323 repo = _init_repo(tmp_path / "repo")
324 manifest = {
325 "README.md": blob_id(b"readme"),
326 "src/foo.py": blob_id(b"foo"),
327 "tests/test_foo.py": blob_id(b"test"),
328 }
329 write_object(repo, blob_id(b"readme"), b"readme")
330 write_object(repo, blob_id(b"foo"), b"foo")
331 write_object(repo, blob_id(b"test"), b"test")
332
333 apply_manifest(repo, {}, manifest)
334
335 assert (repo / "README.md").exists()
336 assert (repo / "src" / "foo.py").exists()
337 assert (repo / "tests" / "test_foo.py").exists()
338
339
340 # ---------------------------------------------------------------------------
341 # Stress — 200-file manifest filtered to cone
342 # ---------------------------------------------------------------------------
343
344
345 class TestStress:
346 def test_200_file_manifest_cone_filter(self) -> None:
347 from muse.core.sparse import filter_manifest_sparse
348 manifest: Manifest = {}
349 for i in range(100):
350 manifest[f"src/file_{i:03d}.py"] = blob_id(f"src-{i}".encode())
351 for i in range(100):
352 manifest[f"other/file_{i:03d}.py"] = blob_id(f"other-{i}".encode())
353 manifest["README.md"] = blob_id(b"readme")
354
355 result = filter_manifest_sparse(manifest, ["src/"], mode="cone")
356 # src/ files + root files
357 assert len(result) == 101 # 100 src + README
358 assert all(
359 k.startswith("src/") or "/" not in k for k in result
360 )
361
362
363 # ---------------------------------------------------------------------------
364 # Flag registration tests
365 # ---------------------------------------------------------------------------
366
367
368 class TestRegisterFlags:
369 def _parser(self) -> "argparse.ArgumentParser":
370 import argparse
371 from muse.cli.commands.sparse_checkout import register
372
373 p = argparse.ArgumentParser()
374 subs = p.add_subparsers()
375 register(subs)
376 return p
377
378 def test_default_json_out_is_false(self) -> None:
379 args = self._parser().parse_args(["sparse-checkout", "init"])
380 assert args.json_out is False
381
382 def test_json_flag_sets_json_out(self) -> None:
383 args = self._parser().parse_args(["sparse-checkout", "init", "--json"])
384 assert args.json_out is True
385
386 def test_j_shorthand_sets_json_out(self) -> None:
387 args = self._parser().parse_args(["sparse-checkout", "init", "-j"])
388 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 124 days ago