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