gabriel / muse public
test_sparse_checkout_supercharge.py python
704 lines 27.4 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Supercharge tests for ``muse sparse-checkout``.
2
3 Covers gaps from the baseline test suite:
4
5 - JSON output with ``duration_ms`` / ``exit_code`` on ALL subcommands
6 - Exact JSON schema for every subcommand
7 - ``stats`` subcommand (matching_files, excluded_files, efficiency, total_files)
8 - Security: path traversal patterns (``../``), null bytes
9 - Mode switching via ``init --no-cone`` on an already-initialised cone repo
10 - Config corruption graceful recovery
11 - Pattern edge cases: whitespace, very long patterns, unicode
12 - Integration: stats against a real HEAD snapshot
13 - Data integrity: set replaces cleanly; add deduplicates exactly
14 - Stress: 500-pattern list, 1 000-file manifest stats
15 """
16
17 from __future__ import annotations
18 from collections.abc import Mapping
19
20 import datetime
21 import json
22 import pathlib
23
24 import pytest
25
26 from muse.core._types import Manifest, blob_id
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 tests.cli_test_helper import CliRunner
31
32 runner = CliRunner()
33 cli = None
34
35 _REPO_ID = "sparse-supercharge-test"
36
37
38 # ---------------------------------------------------------------------------
39 # Helpers
40 # ---------------------------------------------------------------------------
41
42
43 def _init_repo(path: pathlib.Path) -> pathlib.Path:
44 muse = path / ".muse"
45 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
46 (muse / d).mkdir(parents=True, exist_ok=True)
47 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
48 (muse / "repo.json").write_text(
49 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
50 )
51 return path
52
53
54 def _env(repo: pathlib.Path) -> Mapping[str, str]:
55 return {"MUSE_REPO_ROOT": str(repo)}
56
57
58 def _invoke(args: list[str], repo: pathlib.Path):
59 result = runner.invoke(cli, args, env=_env(repo))
60 return result.exit_code, result.stdout, result.stderr
61
62
63 def _sparse_config(repo: pathlib.Path) -> pathlib.Path:
64 return repo / ".muse" / "sparse-checkout"
65
66
67 def _obj(repo: pathlib.Path, content: bytes) -> str:
68 oid = blob_id(content)
69 write_object(repo, oid, content)
70 return oid
71
72
73 def _snap(repo: pathlib.Path, manifest: Manifest) -> str:
74 sid = compute_snapshot_id(manifest)
75 write_snapshot(
76 repo,
77 SnapshotRecord(
78 snapshot_id=sid,
79 manifest=manifest,
80 created_at=datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc),
81 ),
82 )
83 return sid
84
85
86 def _commit(repo: pathlib.Path, sid: str, branch: str = "main") -> str:
87 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
88 cid = compute_commit_id(
89 repo_id=_REPO_ID,
90 parent_ids=[],
91 snapshot_id=sid,
92 message="test",
93 committed_at_iso=committed_at.isoformat(),
94 author="gabriel",)
95 write_commit(
96 repo,
97 CommitRecord(
98 commit_id=cid,
99 repo_id=_REPO_ID,
100 created_on_branch=branch,
101 snapshot_id=sid,
102 message="test",
103 committed_at=committed_at,
104 author="gabriel",
105 parent_commit_id=None,
106 ),
107 )
108 ref = repo / ".muse" / "refs" / "heads" / branch
109 ref.write_text(cid, encoding="utf-8")
110 return cid
111
112
113 def _make_repo_with_snapshot(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
114 """Return (repo, snapshot_id) with 10 files: 5 under src/, 5 under tests/."""
115 repo = _init_repo(tmp_path)
116 manifest: Manifest = {}
117 for i in range(5):
118 oid = _obj(repo, f"src content {i}".encode())
119 manifest[f"src/module_{i}.py"] = oid
120 for i in range(5):
121 oid = _obj(repo, f"test content {i}".encode())
122 manifest[f"tests/test_{i}.py"] = oid
123 oid_root = _obj(repo, b"readme")
124 manifest["README.md"] = oid_root
125 sid = _snap(repo, manifest)
126 _commit(repo, sid)
127 return repo, sid
128
129
130 # ---------------------------------------------------------------------------
131 # TestJsonEnvelopeAllSubcommands
132 # ---------------------------------------------------------------------------
133
134
135 class TestJsonEnvelopeAllSubcommands:
136 """Every subcommand must emit duration_ms and exit_code when --json is passed."""
137
138 def test_init_json_has_duration_ms(self, tmp_path):
139 repo = _init_repo(tmp_path)
140 rc, out, _ = _invoke(["sparse-checkout", "init", "--json"], repo)
141 assert rc == 0
142 data = json.loads(out)
143 assert "duration_ms" in data, "init --json must include duration_ms"
144 assert isinstance(data["duration_ms"], (int, float))
145
146 def test_init_json_has_exit_code(self, tmp_path):
147 repo = _init_repo(tmp_path)
148 rc, out, _ = _invoke(["sparse-checkout", "init", "--json"], repo)
149 assert rc == 0
150 data = json.loads(out)
151 assert data["exit_code"] == 0
152
153 def test_set_json_has_duration_ms(self, tmp_path):
154 repo = _init_repo(tmp_path)
155 _invoke(["sparse-checkout", "init"], repo)
156 rc, out, _ = _invoke(["sparse-checkout", "set", "src/", "--json"], repo)
157 assert rc == 0
158 data = json.loads(out)
159 assert "duration_ms" in data
160
161 def test_set_json_has_exit_code(self, tmp_path):
162 repo = _init_repo(tmp_path)
163 _invoke(["sparse-checkout", "init"], repo)
164 rc, out, _ = _invoke(["sparse-checkout", "set", "src/", "--json"], repo)
165 assert rc == 0
166 data = json.loads(out)
167 assert data["exit_code"] == 0
168
169 def test_add_json_has_duration_ms(self, tmp_path):
170 repo = _init_repo(tmp_path)
171 _invoke(["sparse-checkout", "init"], repo)
172 rc, out, _ = _invoke(["sparse-checkout", "add", "src/", "--json"], repo)
173 assert rc == 0
174 data = json.loads(out)
175 assert "duration_ms" in data
176
177 def test_add_json_has_exit_code(self, tmp_path):
178 repo = _init_repo(tmp_path)
179 _invoke(["sparse-checkout", "init"], repo)
180 rc, out, _ = _invoke(["sparse-checkout", "add", "src/", "--json"], repo)
181 assert rc == 0
182 data = json.loads(out)
183 assert data["exit_code"] == 0
184
185 def test_disable_json_has_duration_ms(self, tmp_path):
186 repo = _init_repo(tmp_path)
187 _invoke(["sparse-checkout", "init"], repo)
188 rc, out, _ = _invoke(["sparse-checkout", "disable", "--json"], repo)
189 assert rc == 0
190 data = json.loads(out)
191 assert "duration_ms" in data
192
193 def test_disable_json_has_exit_code(self, tmp_path):
194 repo = _init_repo(tmp_path)
195 _invoke(["sparse-checkout", "init"], repo)
196 rc, out, _ = _invoke(["sparse-checkout", "disable", "--json"], repo)
197 assert rc == 0
198 data = json.loads(out)
199 assert data["exit_code"] == 0
200
201 def test_list_json_has_duration_ms(self, tmp_path):
202 repo = _init_repo(tmp_path)
203 _invoke(["sparse-checkout", "init"], repo)
204 rc, out, _ = _invoke(["sparse-checkout", "list", "--json"], repo)
205 assert rc == 0
206 data = json.loads(out)
207 assert "duration_ms" in data
208
209 def test_list_json_has_exit_code(self, tmp_path):
210 repo = _init_repo(tmp_path)
211 _invoke(["sparse-checkout", "init"], repo)
212 rc, out, _ = _invoke(["sparse-checkout", "list", "--json"], repo)
213 assert rc == 0
214 data = json.loads(out)
215 assert data["exit_code"] == 0
216
217
218 # ---------------------------------------------------------------------------
219 # TestInitJsonSchema
220 # ---------------------------------------------------------------------------
221
222
223 class TestInitJsonSchema:
224 """init --json must emit the correct schema in every scenario."""
225
226 def test_fresh_init_cone_mode(self, tmp_path):
227 repo = _init_repo(tmp_path)
228 rc, out, _ = _invoke(["sparse-checkout", "init", "--json"], repo)
229 assert rc == 0
230 data = json.loads(out)
231 assert data["mode"] == "cone"
232 assert data["switched"] is False
233 assert data["exit_code"] == 0
234
235 def test_fresh_init_pattern_mode(self, tmp_path):
236 repo = _init_repo(tmp_path)
237 rc, out, _ = _invoke(["sparse-checkout", "init", "--no-cone", "--json"], repo)
238 assert rc == 0
239 data = json.loads(out)
240 assert data["mode"] == "pattern"
241 assert data["switched"] is False
242 assert data["exit_code"] == 0
243
244 def test_idempotent_init_no_switch(self, tmp_path):
245 repo = _init_repo(tmp_path)
246 _invoke(["sparse-checkout", "init"], repo)
247 rc, out, _ = _invoke(["sparse-checkout", "init", "--json"], repo)
248 assert rc == 0
249 data = json.loads(out)
250 assert data["switched"] is False
251 assert data["mode"] == "cone"
252
253 def test_mode_switch_cone_to_pattern(self, tmp_path):
254 """init --no-cone on existing cone repo must switch mode and report it."""
255 repo = _init_repo(tmp_path)
256 _invoke(["sparse-checkout", "init"], repo)
257 rc, out, _ = _invoke(["sparse-checkout", "init", "--no-cone", "--json"], repo)
258 assert rc == 0
259 data = json.loads(out)
260 assert data["switched"] is True
261 assert data["mode"] == "pattern"
262 assert data["previous_mode"] == "cone"
263
264 def test_mode_switch_pattern_to_cone(self, tmp_path):
265 """init (cone default) on existing pattern repo must switch and report."""
266 repo = _init_repo(tmp_path)
267 _invoke(["sparse-checkout", "init", "--no-cone"], repo)
268 rc, out, _ = _invoke(["sparse-checkout", "init", "--json"], repo)
269 assert rc == 0
270 data = json.loads(out)
271 assert data["switched"] is True
272 assert data["mode"] == "cone"
273 assert data["previous_mode"] == "pattern"
274
275 def test_mode_switch_preserves_patterns(self, tmp_path):
276 """Mode switch must keep existing patterns."""
277 repo = _init_repo(tmp_path)
278 _invoke(["sparse-checkout", "init"], repo)
279 _invoke(["sparse-checkout", "set", "src/", "tests/"], repo)
280 _invoke(["sparse-checkout", "init", "--no-cone"], repo)
281 cfg = json.loads(_sparse_config(repo).read_text())
282 assert "src/" in cfg["patterns"]
283 assert "tests/" in cfg["patterns"]
284
285
286 # ---------------------------------------------------------------------------
287 # TestSetJsonSchema
288 # ---------------------------------------------------------------------------
289
290
291 class TestSetJsonSchema:
292 """set --json must emit patterns, total, duration_ms, exit_code."""
293
294 def test_set_json_patterns_array(self, tmp_path):
295 repo = _init_repo(tmp_path)
296 _invoke(["sparse-checkout", "init"], repo)
297 rc, out, _ = _invoke(["sparse-checkout", "set", "src/", "tests/", "--json"], repo)
298 assert rc == 0
299 data = json.loads(out)
300 assert data["patterns"] == ["src/", "tests/"]
301
302 def test_set_json_total_count(self, tmp_path):
303 repo = _init_repo(tmp_path)
304 _invoke(["sparse-checkout", "init"], repo)
305 rc, out, _ = _invoke(["sparse-checkout", "set", "src/", "tests/", "--json"], repo)
306 assert rc == 0
307 data = json.loads(out)
308 assert data["total"] == 2
309
310 def test_set_json_replaces_previous(self, tmp_path):
311 repo = _init_repo(tmp_path)
312 _invoke(["sparse-checkout", "init"], repo)
313 _invoke(["sparse-checkout", "set", "old/", "--json"], repo)
314 rc, out, _ = _invoke(["sparse-checkout", "set", "new/", "--json"], repo)
315 assert rc == 0
316 data = json.loads(out)
317 assert data["patterns"] == ["new/"]
318 assert data["total"] == 1
319
320 def test_set_without_init_fails(self, tmp_path):
321 repo = _init_repo(tmp_path)
322 rc, _, _ = _invoke(["sparse-checkout", "set", "src/", "--json"], repo)
323 assert rc != 0
324
325
326 # ---------------------------------------------------------------------------
327 # TestAddJsonSchema
328 # ---------------------------------------------------------------------------
329
330
331 class TestAddJsonSchema:
332 """add --json must emit added, skipped, patterns, total, duration_ms, exit_code."""
333
334 def test_add_json_added_count(self, tmp_path):
335 repo = _init_repo(tmp_path)
336 _invoke(["sparse-checkout", "init"], repo)
337 rc, out, _ = _invoke(["sparse-checkout", "add", "src/", "tests/", "--json"], repo)
338 assert rc == 0
339 data = json.loads(out)
340 assert data["added"] == 2
341 assert data["skipped"] == 0
342
343 def test_add_json_skipped_count(self, tmp_path):
344 repo = _init_repo(tmp_path)
345 _invoke(["sparse-checkout", "init"], repo)
346 _invoke(["sparse-checkout", "add", "src/", "--json"], repo)
347 rc, out, _ = _invoke(["sparse-checkout", "add", "src/", "docs/", "--json"], repo)
348 assert rc == 0
349 data = json.loads(out)
350 assert data["added"] == 1
351 assert data["skipped"] == 1
352
353 def test_add_json_patterns_array(self, tmp_path):
354 repo = _init_repo(tmp_path)
355 _invoke(["sparse-checkout", "init"], repo)
356 _invoke(["sparse-checkout", "add", "src/", "--json"], repo)
357 rc, out, _ = _invoke(["sparse-checkout", "add", "tests/", "--json"], repo)
358 assert rc == 0
359 data = json.loads(out)
360 assert "src/" in data["patterns"]
361 assert "tests/" in data["patterns"]
362
363 def test_add_json_total_is_cumulative(self, tmp_path):
364 repo = _init_repo(tmp_path)
365 _invoke(["sparse-checkout", "init"], repo)
366 _invoke(["sparse-checkout", "add", "src/", "tests/", "--json"], repo)
367 rc, out, _ = _invoke(["sparse-checkout", "add", "docs/", "--json"], repo)
368 assert rc == 0
369 data = json.loads(out)
370 assert data["total"] == 3
371
372
373 # ---------------------------------------------------------------------------
374 # TestDisableJsonSchema
375 # ---------------------------------------------------------------------------
376
377
378 class TestDisableJsonSchema:
379 """disable --json must emit was_enabled, duration_ms, exit_code."""
380
381 def test_disable_json_was_enabled_true(self, tmp_path):
382 repo = _init_repo(tmp_path)
383 _invoke(["sparse-checkout", "init"], repo)
384 rc, out, _ = _invoke(["sparse-checkout", "disable", "--json"], repo)
385 assert rc == 0
386 data = json.loads(out)
387 assert data["was_enabled"] is True
388 assert data["exit_code"] == 0
389
390 def test_disable_json_was_enabled_false_when_already_disabled(self, tmp_path):
391 repo = _init_repo(tmp_path)
392 rc, out, _ = _invoke(["sparse-checkout", "disable", "--json"], repo)
393 assert rc == 0
394 data = json.loads(out)
395 assert data["was_enabled"] is False
396 assert data["exit_code"] == 0
397
398 def test_disable_removes_config(self, tmp_path):
399 repo = _init_repo(tmp_path)
400 _invoke(["sparse-checkout", "init"], repo)
401 _invoke(["sparse-checkout", "disable", "--json"], repo)
402 assert not _sparse_config(repo).exists()
403
404
405 # ---------------------------------------------------------------------------
406 # TestStatsSubcommand
407 # ---------------------------------------------------------------------------
408
409
410 class TestStatsSubcommand:
411 """stats subcommand reports matching/excluded file counts from HEAD snapshot."""
412
413 def test_stats_with_cone_filter(self, tmp_path):
414 repo, _ = _make_repo_with_snapshot(tmp_path)
415 _invoke(["sparse-checkout", "init"], repo)
416 _invoke(["sparse-checkout", "set", "src/"], repo)
417 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
418 assert rc == 0, out
419 data = json.loads(out)
420 # 5 src/ files + 1 README.md (root file in cone) = 6 matching
421 assert data["matching_files"] == 6
422 assert data["excluded_files"] == 5 # tests/ excluded
423 assert data["total_files"] == 11
424
425 def test_stats_total_files(self, tmp_path):
426 repo, _ = _make_repo_with_snapshot(tmp_path)
427 _invoke(["sparse-checkout", "init"], repo)
428 _invoke(["sparse-checkout", "set", "src/"], repo)
429 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
430 assert rc == 0
431 data = json.loads(out)
432 assert data["total_files"] == data["matching_files"] + data["excluded_files"]
433
434 def test_stats_efficiency_ratio(self, tmp_path):
435 repo, _ = _make_repo_with_snapshot(tmp_path)
436 _invoke(["sparse-checkout", "init"], repo)
437 _invoke(["sparse-checkout", "set", "src/"], repo)
438 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
439 assert rc == 0
440 data = json.loads(out)
441 expected = data["matching_files"] / data["total_files"]
442 assert abs(data["efficiency"] - expected) < 0.001
443
444 def test_stats_disabled_means_all_match(self, tmp_path):
445 """When sparse-checkout is disabled, all files match."""
446 repo, _ = _make_repo_with_snapshot(tmp_path)
447 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
448 assert rc == 0
449 data = json.loads(out)
450 assert data["enabled"] is False
451 assert data["matching_files"] == data["total_files"]
452 assert data["excluded_files"] == 0
453 assert data["efficiency"] == 1.0
454
455 def test_stats_has_duration_ms(self, tmp_path):
456 repo, _ = _make_repo_with_snapshot(tmp_path)
457 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
458 assert rc == 0
459 data = json.loads(out)
460 assert "duration_ms" in data
461 assert isinstance(data["duration_ms"], (int, float))
462
463 def test_stats_has_exit_code(self, tmp_path):
464 repo, _ = _make_repo_with_snapshot(tmp_path)
465 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
466 assert rc == 0
467 data = json.loads(out)
468 assert data["exit_code"] == 0
469
470 def test_stats_no_commits_exits_cleanly(self, tmp_path):
471 """Stats on a repo with no commits must exit 0 with zeros."""
472 repo = _init_repo(tmp_path)
473 _invoke(["sparse-checkout", "init"], repo)
474 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
475 assert rc == 0
476 data = json.loads(out)
477 assert data["total_files"] == 0
478 assert data["matching_files"] == 0
479
480 def test_stats_pattern_mode(self, tmp_path):
481 repo, _ = _make_repo_with_snapshot(tmp_path)
482 _invoke(["sparse-checkout", "init", "--no-cone"], repo)
483 _invoke(["sparse-checkout", "set", "src/**"], repo)
484 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
485 assert rc == 0
486 data = json.loads(out)
487 # pattern mode: only src/** matches; README.md excluded
488 assert data["matching_files"] == 5
489 assert data["excluded_files"] == 6
490
491
492 # ---------------------------------------------------------------------------
493 # TestSecurityValidation
494 # ---------------------------------------------------------------------------
495
496
497 class TestSecurityValidation:
498 """Dangerous patterns must be rejected before being written to disk."""
499
500 def test_path_traversal_rejected_set(self, tmp_path):
501 repo = _init_repo(tmp_path)
502 _invoke(["sparse-checkout", "init"], repo)
503 rc, _, err = _invoke(["sparse-checkout", "set", "../etc/passwd"], repo)
504 assert rc != 0, "path traversal pattern must be rejected"
505
506 def test_path_traversal_rejected_add(self, tmp_path):
507 repo = _init_repo(tmp_path)
508 _invoke(["sparse-checkout", "init"], repo)
509 rc, _, err = _invoke(["sparse-checkout", "add", "../secret"], repo)
510 assert rc != 0, "path traversal in add must be rejected"
511
512 def test_path_traversal_nested_rejected(self, tmp_path):
513 repo = _init_repo(tmp_path)
514 _invoke(["sparse-checkout", "init"], repo)
515 rc, _, _ = _invoke(["sparse-checkout", "set", "src/../../etc"], repo)
516 assert rc != 0, "nested path traversal must be rejected"
517
518 def test_null_byte_pattern_rejected_set(self, tmp_path):
519 repo = _init_repo(tmp_path)
520 _invoke(["sparse-checkout", "init"], repo)
521 rc, _, _ = _invoke(["sparse-checkout", "set", "src/\x00evil"], repo)
522 assert rc != 0, "null byte in pattern must be rejected"
523
524 def test_null_byte_pattern_rejected_add(self, tmp_path):
525 repo = _init_repo(tmp_path)
526 _invoke(["sparse-checkout", "init"], repo)
527 rc, _, _ = _invoke(["sparse-checkout", "add", "foo\x00bar"], repo)
528 assert rc != 0, "null byte in add pattern must be rejected"
529
530 def test_ansi_still_rejected(self, tmp_path):
531 repo = _init_repo(tmp_path)
532 _invoke(["sparse-checkout", "init"], repo)
533 rc, _, _ = _invoke(["sparse-checkout", "set", "src/\x1b[31mevil"], repo)
534 assert rc != 0, "ANSI escape in pattern must still be rejected"
535
536 def test_safe_pattern_not_rejected(self, tmp_path):
537 repo = _init_repo(tmp_path)
538 _invoke(["sparse-checkout", "init"], repo)
539 rc, _, _ = _invoke(["sparse-checkout", "set", "src/"], repo)
540 assert rc == 0, "normal safe pattern must not be rejected"
541
542 def test_error_message_mentions_traversal(self, tmp_path):
543 repo = _init_repo(tmp_path)
544 _invoke(["sparse-checkout", "init"], repo)
545 rc, out, err = _invoke(["sparse-checkout", "set", "../bad"], repo)
546 assert rc != 0
547 combined = (out + err).lower()
548 assert "traversal" in combined or ".." in combined, (
549 "error message must describe the path traversal issue"
550 )
551
552
553 # ---------------------------------------------------------------------------
554 # TestConfigCorruption
555 # ---------------------------------------------------------------------------
556
557
558 class TestConfigCorruption:
559 """Malformed sparse-checkout config must produce a clear error, not a traceback."""
560
561 def test_malformed_json_exits_nonzero(self, tmp_path):
562 repo = _init_repo(tmp_path)
563 _sparse_config(repo).write_text("{invalid json", encoding="utf-8")
564 rc, _, _ = _invoke(["sparse-checkout", "list", "--json"], repo)
565 assert rc != 0, "malformed config must exit non-zero"
566
567 def test_malformed_json_error_message(self, tmp_path):
568 repo = _init_repo(tmp_path)
569 _sparse_config(repo).write_text("{invalid json", encoding="utf-8")
570 rc, out, err = _invoke(["sparse-checkout", "list", "--json"], repo)
571 assert rc != 0
572 combined = out + err
573 assert len(combined.strip()) > 0, "must emit an error message"
574
575 def test_missing_mode_key(self, tmp_path):
576 repo = _init_repo(tmp_path)
577 _sparse_config(repo).write_text(json.dumps({"patterns": []}), encoding="utf-8")
578 rc, _, _ = _invoke(["sparse-checkout", "list"], repo)
579 assert rc != 0, "config missing 'mode' key must exit non-zero"
580
581 def test_missing_patterns_key(self, tmp_path):
582 repo = _init_repo(tmp_path)
583 _sparse_config(repo).write_text(json.dumps({"mode": "cone"}), encoding="utf-8")
584 rc, _, _ = _invoke(["sparse-checkout", "list"], repo)
585 assert rc != 0, "config missing 'patterns' key must exit non-zero"
586
587
588 # ---------------------------------------------------------------------------
589 # TestModeSwitch
590 # ---------------------------------------------------------------------------
591
592
593 class TestModeSwitch:
594 """Mode switching via re-init must work cleanly."""
595
596 def test_switch_updates_config_file(self, tmp_path):
597 repo = _init_repo(tmp_path)
598 _invoke(["sparse-checkout", "init"], repo)
599 _invoke(["sparse-checkout", "init", "--no-cone"], repo)
600 cfg = json.loads(_sparse_config(repo).read_text())
601 assert cfg["mode"] == "pattern"
602
603 def test_same_mode_reinit_is_not_switch(self, tmp_path):
604 repo = _init_repo(tmp_path)
605 _invoke(["sparse-checkout", "init"], repo)
606 rc, out, _ = _invoke(["sparse-checkout", "init", "--json"], repo)
607 assert rc == 0
608 data = json.loads(out)
609 assert data["switched"] is False
610
611 def test_switch_preserves_patterns_count(self, tmp_path):
612 repo = _init_repo(tmp_path)
613 _invoke(["sparse-checkout", "init"], repo)
614 _invoke(["sparse-checkout", "set", "a/", "b/", "c/"], repo)
615 _invoke(["sparse-checkout", "init", "--no-cone"], repo)
616 cfg = json.loads(_sparse_config(repo).read_text())
617 assert len(cfg["patterns"]) == 3
618
619
620 # ---------------------------------------------------------------------------
621 # TestPatternEdgeCases
622 # ---------------------------------------------------------------------------
623
624
625 class TestPatternEdgeCases:
626 """Edge case patterns that might slip through validation."""
627
628 def test_whitespace_only_pattern_rejected(self, tmp_path):
629 repo = _init_repo(tmp_path)
630 _invoke(["sparse-checkout", "init"], repo)
631 rc, _, _ = _invoke(["sparse-checkout", "set", " "], repo)
632 assert rc != 0, "whitespace-only pattern must be rejected"
633
634 def test_empty_string_pattern_rejected(self, tmp_path):
635 repo = _init_repo(tmp_path)
636 _invoke(["sparse-checkout", "init"], repo)
637 # argparse nargs="+" won't pass empty string, so simulate via direct config write
638 _sparse_config(repo).write_text(
639 json.dumps({"mode": "cone", "patterns": [""]}), encoding="utf-8"
640 )
641 rc, out, _ = _invoke(["sparse-checkout", "list", "--json"], repo)
642 # Reading an empty pattern is fine; it won't match anything useful
643 assert rc == 0 # list itself doesn't re-validate stored patterns
644
645 def test_very_long_pattern_accepted(self, tmp_path):
646 """Patterns up to 1024 chars must be accepted (no arbitrary length limit)."""
647 repo = _init_repo(tmp_path)
648 _invoke(["sparse-checkout", "init"], repo)
649 long_pat = "a/" * 200 # 400 chars
650 rc, _, _ = _invoke(["sparse-checkout", "set", long_pat], repo)
651 assert rc == 0, "long but valid pattern must be accepted"
652
653 def test_unicode_pattern_accepted(self, tmp_path):
654 """Unicode patterns are valid (e.g. internationalised path names)."""
655 repo = _init_repo(tmp_path)
656 _invoke(["sparse-checkout", "init"], repo)
657 rc, _, _ = _invoke(["sparse-checkout", "set", "música/", "--json"], repo)
658 assert rc == 0, "unicode path pattern must be accepted"
659
660
661 # ---------------------------------------------------------------------------
662 # TestStressLargeManifest
663 # ---------------------------------------------------------------------------
664
665
666 class TestStressLargeManifest:
667 """stats must handle large manifests without degrading."""
668
669 def test_stats_1000_file_manifest(self, tmp_path):
670 repo = _init_repo(tmp_path)
671 manifest: Manifest = {}
672 for i in range(500):
673 oid = _obj(repo, f"src {i}".encode())
674 manifest[f"src/file_{i}.py"] = oid
675 for i in range(500):
676 oid = _obj(repo, f"other {i}".encode())
677 manifest[f"other/file_{i}.py"] = oid
678 sid = _snap(repo, manifest)
679 _commit(repo, sid)
680 _invoke(["sparse-checkout", "init"], repo)
681 _invoke(["sparse-checkout", "set", "src/"], repo)
682 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
683 assert rc == 0
684 data = json.loads(out)
685 assert data["total_files"] == 1000
686 assert data["matching_files"] == 500
687 assert data["excluded_files"] == 500
688
689 def test_stats_500_patterns(self, tmp_path):
690 """500-pattern list must not degrade stats computation."""
691 repo = _init_repo(tmp_path)
692 manifest: Manifest = {}
693 for i in range(10):
694 oid = _obj(repo, f"content {i}".encode())
695 manifest[f"dir_{i}/file.py"] = oid
696 sid = _snap(repo, manifest)
697 _commit(repo, sid)
698 _invoke(["sparse-checkout", "init", "--no-cone"], repo)
699 patterns = [f"dir_{i}/**" for i in range(500)]
700 _invoke(["sparse-checkout", "set"] + patterns, repo)
701 rc, out, _ = _invoke(["sparse-checkout", "stats", "--json"], repo)
702 assert rc == 0
703 data = json.loads(out)
704 assert data["matching_files"] == 10
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago