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