gabriel / muse public
test_cmd_code_add.py python
748 lines 28.1 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive tests for ``muse code add`` and ``muse code reset``.
2
3 Review findings addressed
4 --------------------------
5 Security
6 * Path-traversal: staging a file outside the repo root is rejected.
7 * Symlink: symlinks are not followed during tree walks (followlinks=False).
8 * `.museignore`: ignored files are never staged even when explicitly named.
9
10 Performance
11 * Unchanged files (content = committed) are skipped — no object written.
12 * Already-staged files with the same content are skipped (idempotent).
13
14 New capabilities (added this review)
15 * ``--format json`` on ``muse code add`` — machine-readable output.
16 * ``--format json`` on ``muse code reset`` — machine-readable output.
17 * Breakdown summary in text output (N added, M modified, K deleted).
18
19 Stage persistence migration
20 * ``.muse/code/stage.json`` → ``.muse/code/stage.msgpack``.
21 * Legacy JSON is transparently migrated on first read.
22 * Corrupt msgpack clears stage and warns rather than silently returning {}.
23
24 Test categories
25 ---------------
26 I Security — path traversal, symlinks, ignore rules.
27 II JSON output — muse code add --format json.
28 III JSON output — muse code reset --format json.
29 IV Text output breakdown — "N added, M modified, K deleted".
30 V msgpack stage persistence — format, atomicity, migration.
31 VI Dry-run correctness — no writes, accurate preview.
32 VII Edge cases — fresh repo, no commits, multiple flags, cycles.
33 VIII Stress — 500-file staging, repeated cycles, large files.
34 """
35
36 from __future__ import annotations
37
38 import hashlib
39 import json
40 import os
41 import pathlib
42 import uuid
43
44 import msgpack
45 import pytest
46
47 from muse.plugins.code.stage import StagedEntry, read_stage, stage_path, write_stage, StagedFileMap
48 from muse.core._types import Manifest
49 from tests.cli_test_helper import CliRunner
50
51 runner = CliRunner()
52 cli = None
53
54
55 # ---------------------------------------------------------------------------
56 # Helpers and fixtures
57 # ---------------------------------------------------------------------------
58
59
60 def _env(root: pathlib.Path) -> Manifest:
61 return {"MUSE_REPO_ROOT": str(root)}
62
63
64 def _run(root: pathlib.Path, *args: str) -> tuple[int, str]:
65 result = runner.invoke(cli, list(args), env=_env(root), catch_exceptions=False)
66 return result.exit_code, result.output
67
68
69 def _run_unchecked(root: pathlib.Path, *args: str) -> tuple[int, str]:
70 result = runner.invoke(cli, list(args), env=_env(root))
71 return result.exit_code, result.output
72
73
74 @pytest.fixture()
75 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
76 """Fresh code-domain repo with one committed file (main.py = 'x = 1')."""
77 monkeypatch.chdir(tmp_path)
78 r = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
79 assert r.exit_code == 0, r.output
80 (tmp_path / "main.py").write_text("x = 1\n")
81 r2 = runner.invoke(cli, ["commit", "--allow-empty", "-m", "init"], env=_env(tmp_path))
82 assert r2.exit_code == 0, r2.output
83 return tmp_path
84
85
86 # ===========================================================================
87 # I Security
88 # ===========================================================================
89
90
91 class TestSecurityI:
92 """Files outside the repo, symlinks, and ignored paths must never be staged."""
93
94 def test_I1_path_outside_repo_root_is_rejected(
95 self, repo: pathlib.Path
96 ) -> None:
97 """I1: staging a path outside the repo root exits non-zero."""
98 outside = repo.parent / f"secret_{uuid.uuid4().hex}.txt"
99 outside.write_text("secret\n")
100
101 code, _ = _run_unchecked(repo, "code", "add", str(outside))
102 assert code != 0 or str(outside) not in _read_stage(repo)
103
104 def test_I2_symlink_not_followed_during_dot_add(
105 self, repo: pathlib.Path
106 ) -> None:
107 """I2: symlinks to files outside the repo are never staged."""
108 outside = repo.parent / f"outside_{uuid.uuid4().hex}.txt"
109 outside.write_text("outside content\n")
110 link = repo / "link_to_outside.txt"
111 link.symlink_to(outside)
112
113 _run(repo, "code", "add", ".")
114 stage = _read_stage(repo)
115 assert "link_to_outside.txt" not in stage, "Symlink to outside must not be staged"
116
117 def test_I3_museignore_file_not_staged_by_dot(
118 self, repo: pathlib.Path
119 ) -> None:
120 """I3: .museignore exclusions are honoured by 'muse code add .'"""
121 # .museignore is TOML — use the proper section format.
122 (repo / ".museignore").write_text(
123 '[domain.code]\npatterns = ["*.secret"]\n'
124 )
125 (repo / "creds.secret").write_text("password=123\n")
126
127 _run(repo, "code", "add", ".")
128 stage = _read_stage(repo)
129 assert "creds.secret" not in stage, "Ignored file must not be staged"
130
131 def test_I4_museignore_file_not_staged_when_explicit(
132 self, repo: pathlib.Path
133 ) -> None:
134 """I4: even when explicitly named, .museignore exclusions prevent staging."""
135 (repo / ".museignore").write_text(
136 '[domain.code]\npatterns = ["private.py"]\n'
137 )
138 (repo / "private.py").write_text("SECRET = 'x'\n")
139
140 _run(repo, "code", "add", "private.py")
141 stage = _read_stage(repo)
142 assert "private.py" not in stage, "Explicitly named ignored file must not be staged"
143
144 def test_I5_hidden_files_not_staged_by_default(
145 self, repo: pathlib.Path
146 ) -> None:
147 """I5: hidden files (dotfiles) are skipped by _walk_tree."""
148 (repo / ".env").write_text("API_KEY=secret\n")
149
150 _run(repo, "code", "add", ".")
151 stage = _read_stage(repo)
152 assert ".env" not in stage, "Hidden .env must not be staged"
153
154 def test_I6_pycache_not_staged(self, repo: pathlib.Path) -> None:
155 """I6: __pycache__ directories are never walked."""
156 cache = repo / "__pycache__"
157 cache.mkdir()
158 (cache / "main.cpython-311.pyc").write_bytes(b"\x00compiled\x00")
159
160 _run(repo, "code", "add", ".")
161 stage = _read_stage(repo)
162 for key in stage:
163 assert "__pycache__" not in key, f"Compiled cache file staged: {key}"
164
165
166 # ===========================================================================
167 # II JSON output — muse code add --format json
168 # ===========================================================================
169
170
171 class TestJsonOutputAddII:
172 """``muse code add --format json`` must emit valid, complete JSON."""
173
174 def test_II1_json_output_on_single_file_staged(
175 self, repo: pathlib.Path
176 ) -> None:
177 """II1: staging one file emits correct JSON with all required keys."""
178 (repo / "main.py").write_text("x = 2\n")
179
180 code, out = _run(repo, "code", "add", "--format", "json", "main.py")
181 assert code == 0, out
182 data = json.loads(out.strip())
183 assert data["staged"] == 1
184 assert data["modified"] == 1
185 assert data["added"] == 0
186 assert data["deleted"] == 0
187 assert data["dry_run"] is False
188 assert any(f["path"] == "main.py" for f in data["files"])
189
190 def test_II2_json_output_new_file_is_added(
191 self, repo: pathlib.Path
192 ) -> None:
193 """II2: a brand-new file has mode 'new file' in JSON output."""
194 (repo / "brand_new.py").write_text("y = 99\n")
195
196 code, out = _run(repo, "code", "add", "--format", "json", "brand_new.py")
197 assert code == 0, out
198 data = json.loads(out.strip())
199 assert data["added"] == 1
200 assert data["modified"] == 0
201 file_entry = next(f for f in data["files"] if f["path"] == "brand_new.py")
202 assert file_entry["mode"] == "new file"
203
204 def test_II3_json_output_deletion_counted(
205 self, repo: pathlib.Path
206 ) -> None:
207 """II3: staging a deletion records deleted=1 in JSON."""
208 (repo / "main.py").unlink()
209
210 code, out = _run(repo, "code", "add", "-u", "--format", "json")
211 assert code == 0, out
212 data = json.loads(out.strip())
213 assert data["deleted"] == 1
214 assert any(f["mode"] == "deleted" for f in data["files"])
215
216 def test_II4_json_output_nothing_to_stage(
217 self, repo: pathlib.Path
218 ) -> None:
219 """II4: nothing to stage returns staged=0, not an error."""
220 # main.py is already at committed content — nothing to stage.
221 code, out = _run(repo, "code", "add", "--format", "json", ".")
222 assert code == 0, out
223 data = json.loads(out.strip())
224 assert data["staged"] == 0
225
226 def test_II5_json_dry_run_flag_true(self, repo: pathlib.Path) -> None:
227 """II5: --dry-run sets dry_run=true in JSON and writes no stage."""
228 (repo / "main.py").write_text("# dry\n")
229
230 code, out = _run(
231 repo, "code", "add", "--dry-run", "--format", "json", "main.py"
232 )
233 assert code == 0, out
234 data = json.loads(out.strip())
235 assert data["dry_run"] is True
236 assert data["staged"] == 1
237 assert not stage_path(repo).exists()
238
239 def test_II6_json_output_multiple_files(
240 self, repo: pathlib.Path
241 ) -> None:
242 """II6: multiple staged files all appear in the files list."""
243 for i in range(5):
244 (repo / f"f{i}.py").write_text(f"v = {i}\n")
245
246 code, out = _run(repo, "code", "add", "--format", "json", "-A")
247 assert code == 0, out
248 data = json.loads(out.strip())
249 assert data["staged"] >= 5
250 paths = {f["path"] for f in data["files"]}
251 for i in range(5):
252 assert f"f{i}.py" in paths
253
254 def test_II7_json_output_is_valid_json(self, repo: pathlib.Path) -> None:
255 """II7: output is always parseable JSON, never raw text."""
256 (repo / "main.py").write_text("# changed\n")
257 _, out = _run(repo, "code", "add", "--format", "json", "main.py")
258 json.loads(out.strip()) # must not raise
259
260
261 # ===========================================================================
262 # III JSON output — muse code reset --format json
263 # ===========================================================================
264
265
266 class TestJsonOutputResetIII:
267 """``muse code reset --format json`` must emit valid, complete JSON."""
268
269 def test_III1_json_reset_specific_file(self, repo: pathlib.Path) -> None:
270 """III1: resetting a staged file returns unstaged=1 in JSON."""
271 (repo / "main.py").write_text("# staged\n")
272 _run(repo, "code", "add", "main.py")
273
274 code, out = _run(repo, "code", "reset", "--format", "json", "main.py")
275 assert code == 0, out
276 data = json.loads(out.strip())
277 assert data["unstaged"] == 1
278 assert "main.py" in data["files"]
279
280 def test_III2_json_reset_all(self, repo: pathlib.Path) -> None:
281 """III2: reset with no args clears all staged files, reports count in JSON."""
282 for i in range(3):
283 (repo / f"f{i}.py").write_text(f"x = {i}\n")
284 _run(repo, "code", "add", "-A")
285
286 code, out = _run(repo, "code", "reset", "--format", "json")
287 assert code == 0, out
288 data = json.loads(out.strip())
289 assert data["unstaged"] >= 3
290
291 def test_III3_json_reset_nothing_staged(self, repo: pathlib.Path) -> None:
292 """III3: reset with nothing staged returns unstaged=0 in JSON."""
293 code, out = _run(repo, "code", "reset", "--format", "json")
294 assert code == 0, out
295 data = json.loads(out.strip())
296 assert data["unstaged"] == 0
297 assert data["files"] == []
298
299 def test_III4_json_reset_preserves_other_staged_files(
300 self, repo: pathlib.Path
301 ) -> None:
302 """III4: resetting one file leaves others staged."""
303 (repo / "main.py").write_text("# changed\n")
304 (repo / "other.py").write_text("y = 9\n")
305 _run(repo, "code", "add", "-A")
306
307 code, out = _run(repo, "code", "reset", "--format", "json", "other.py")
308 assert code == 0, out
309 data = json.loads(out.strip())
310 assert data["unstaged"] == 1
311 assert "other.py" in data["files"]
312
313 remaining = read_stage(repo)
314 assert "main.py" in remaining, "main.py must still be staged"
315 assert "other.py" not in remaining
316
317
318 # ===========================================================================
319 # IV Text output breakdown
320 # ===========================================================================
321
322
323 class TestTextOutputBreakdownIV:
324 """The text summary must show a breakdown: N added, M modified, K deleted."""
325
326 def test_IV1_text_shows_added_count(self, repo: pathlib.Path) -> None:
327 """IV1: new files appear in 'added' part of the breakdown."""
328 (repo / "new.py").write_text("z = 0\n")
329 _, out = _run(repo, "code", "add", "new.py")
330 assert "added" in out
331
332 def test_IV2_text_shows_modified_count(self, repo: pathlib.Path) -> None:
333 """IV2: modified tracked files appear in 'modified' part."""
334 (repo / "main.py").write_text("x = 999\n")
335 _, out = _run(repo, "code", "add", "main.py")
336 assert "modified" in out
337
338 def test_IV3_text_shows_deleted_count(self, repo: pathlib.Path) -> None:
339 """IV3: staged deletions appear in 'deleted' part."""
340 (repo / "main.py").unlink()
341 _, out = _run(repo, "code", "add", "-u")
342 assert "deleted" in out
343
344 def test_IV4_text_nothing_to_stage_message(
345 self, repo: pathlib.Path
346 ) -> None:
347 """IV4: when nothing changed, output explains nothing to stage."""
348 _, out = _run(repo, "code", "add", ".")
349 assert "Nothing" in out or "already up to date" in out
350
351 def test_IV5_text_breakdown_counts_match_actual(
352 self, repo: pathlib.Path
353 ) -> None:
354 """IV5: text breakdown totals match what was actually staged."""
355 (repo / "main.py").write_text("x = 2\n") # modified
356 (repo / "a.py").write_text("a = 1\n") # new
357 (repo / "b.py").write_text("b = 2\n") # new
358
359 _, out = _run(repo, "code", "add", "-A")
360 assert "1 modified" in out
361 assert "2 added" in out
362
363
364 # ===========================================================================
365 # V msgpack stage persistence
366 # ===========================================================================
367
368
369 class TestMsgpackPersistenceV:
370 """The stage index must be persisted as msgpack and survive round-trips."""
371
372 def test_V1_stage_file_is_msgpack_not_json(
373 self, repo: pathlib.Path
374 ) -> None:
375 """V1: after staging, the file on disk is valid msgpack, not JSON."""
376 (repo / "main.py").write_text("x = 9\n")
377 _run(repo, "code", "add", "main.py")
378
379 path = stage_path(repo)
380 assert path.exists(), "stage.msgpack must exist after staging"
381 raw = path.read_bytes()
382 assert not raw.startswith(b"{"), "Stage file must not be JSON"
383 data = msgpack.unpackb(raw, raw=False)
384 assert "entries" in data
385 assert "main.py" in data["entries"]
386
387 def test_V2_stage_round_trips_all_entry_fields(
388 self, repo: pathlib.Path
389 ) -> None:
390 """V2: object_id, mode, and staged_at survive a write/read cycle."""
391 (repo / "main.py").write_text("x = 42\n")
392 _run(repo, "code", "add", "main.py")
393
394 stage = read_stage(repo)
395 entry = stage["main.py"]
396 assert len(entry["object_id"]) == 64, "object_id must be 64-char SHA-256"
397 assert entry["mode"] in ("A", "M", "D")
398 assert entry["staged_at"]
399
400 def test_V3_stage_atomic_write_no_tmp_file_after_success(
401 self, repo: pathlib.Path
402 ) -> None:
403 """V3: no .stage-tmp-* file lingers after a successful write."""
404 (repo / "main.py").write_text("x = 1\n")
405 _run(repo, "code", "add", "main.py")
406
407 stage_dir = repo / ".muse" / "code"
408 tmps = list(stage_dir.glob(".stage-tmp-*"))
409 assert tmps == [], f"Stale tmp files: {tmps}"
410
411 def test_V4_legacy_json_migrated_on_first_read(
412 self, repo: pathlib.Path
413 ) -> None:
414 """V4: if stage.json exists (legacy), read_stage migrates it to msgpack."""
415 stage_dir = repo / ".muse" / "code"
416 stage_dir.mkdir(parents=True, exist_ok=True)
417 oid = "a" * 64
418 legacy = stage_dir / "stage.json"
419 legacy.write_text(json.dumps({
420 "version": 1,
421 "entries": {"main.py": {"object_id": oid, "mode": "M", "staged_at": "x"}},
422 }))
423
424 entries = read_stage(repo)
425 assert "main.py" in entries
426 assert not legacy.exists(), "Legacy JSON must be removed after migration"
427 assert stage_path(repo).exists(), "Msgpack must be created after migration"
428
429 def test_V5_corrupt_msgpack_clears_and_returns_empty(
430 self, repo: pathlib.Path
431 ) -> None:
432 """V5: corrupt msgpack is deleted and read_stage returns {}."""
433 stage_dir = repo / ".muse" / "code"
434 stage_dir.mkdir(parents=True, exist_ok=True)
435 stage_path(repo).write_bytes(b"\xde\xad\xbe\xef garbage")
436
437 entries = read_stage(repo)
438 assert entries == {}
439 assert not stage_path(repo).exists(), "Corrupt stage file must be removed"
440
441 def test_V6_write_empty_removes_msgpack_file(
442 self, repo: pathlib.Path
443 ) -> None:
444 """V6: write_stage({}) removes stage.msgpack (clear the stage)."""
445 # Change main.py so it's different from the committed content.
446 (repo / "main.py").write_text("x = 999\n")
447 _run(repo, "code", "add", "main.py")
448 assert stage_path(repo).exists(), "Stage must exist after staging a changed file"
449
450 write_stage(repo, {})
451 assert not stage_path(repo).exists()
452
453 def test_V7_stage_version_is_2_in_msgpack(
454 self, repo: pathlib.Path
455 ) -> None:
456 """V7: msgpack file carries version=2."""
457 (repo / "main.py").write_text("x = 999\n")
458 _run(repo, "code", "add", "main.py")
459 assert stage_path(repo).exists(), "Stage must exist after staging"
460
461 raw = msgpack.unpackb(stage_path(repo).read_bytes(), raw=False)
462 assert raw["version"] == 2
463
464
465 # ===========================================================================
466 # VI Dry-run correctness
467 # ===========================================================================
468
469
470 class TestDryRunVI:
471 """--dry-run must preview accurately and never write anything."""
472
473 def test_VI1_dry_run_lists_files_that_would_be_staged(
474 self, repo: pathlib.Path
475 ) -> None:
476 """VI1: output lists every file that would be staged."""
477 (repo / "main.py").write_text("x = 3\n")
478 (repo / "new.py").write_text("y = 0\n")
479
480 _, out = _run(repo, "code", "add", "--dry-run", "-A")
481 assert "main.py" in out
482 assert "new.py" in out
483
484 def test_VI2_dry_run_does_not_write_stage_file(
485 self, repo: pathlib.Path
486 ) -> None:
487 """VI2: after dry-run, stage.msgpack must not exist."""
488 (repo / "main.py").write_text("x = 3\n")
489 _run(repo, "code", "add", "--dry-run", "main.py")
490 assert not stage_path(repo).exists()
491
492 def test_VI3_dry_run_does_not_write_objects(
493 self, repo: pathlib.Path
494 ) -> None:
495 """VI3: dry-run must not write any blobs to the object store."""
496 content = b"brand new content\n"
497 (repo / "brand_new.py").write_bytes(content)
498 oid = hashlib.sha256(content).hexdigest()
499 obj_path = repo / ".muse" / "objects" / oid[:2] / oid[2:]
500
501 _run(repo, "code", "add", "--dry-run", "brand_new.py")
502 assert not obj_path.exists(), "Dry-run must not write objects to the store"
503
504 def test_VI4_dry_run_json_shows_correct_counts(
505 self, repo: pathlib.Path
506 ) -> None:
507 """VI4: --dry-run --format json shows accurate counts."""
508 (repo / "main.py").write_text("x = 5\n") # modified
509 (repo / "extra.py").write_text("z = 0\n") # new
510
511 _, out = _run(
512 repo, "code", "add", "--dry-run", "--format", "json", "-A"
513 )
514 data = json.loads(out.strip())
515 assert data["dry_run"] is True
516 assert data["modified"] >= 1
517 assert data["added"] >= 1
518
519 def test_VI5_dry_run_output_stable_across_runs(
520 self, repo: pathlib.Path
521 ) -> None:
522 """VI5: running dry-run twice on the same tree produces identical output."""
523 (repo / "main.py").write_text("x = 7\n")
524
525 _, out1 = _run(repo, "code", "add", "--dry-run", "--format", "json", ".")
526 _, out2 = _run(repo, "code", "add", "--dry-run", "--format", "json", ".")
527 assert json.loads(out1) == json.loads(out2)
528
529
530 # ===========================================================================
531 # VII Edge cases
532 # ===========================================================================
533
534
535 class TestEdgeCasesVII:
536 """Edge cases: fresh repo, no commits, conflicting flags, etc."""
537
538 def test_VII1_stage_on_fresh_repo_no_commits(
539 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
540 ) -> None:
541 """VII1: staging works on a repo with no prior commits."""
542 monkeypatch.chdir(tmp_path)
543 runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
544 (tmp_path / "first.py").write_text("x = 1\n")
545
546 code, out = _run(tmp_path, "code", "add", "first.py")
547 assert code == 0, out
548 stage = read_stage(tmp_path)
549 assert "first.py" in stage
550 assert stage["first.py"]["mode"] == "A"
551
552 def test_VII2_staging_identical_content_is_idempotent(
553 self, repo: pathlib.Path
554 ) -> None:
555 """VII2: staging the same file twice with identical content is a no-op."""
556 (repo / "main.py").write_text("x = 10\n")
557 _run(repo, "code", "add", "main.py")
558
559 code, out = _run(repo, "code", "add", "main.py")
560 assert code == 0
561 assert "already up to date" in out or "Nothing" in out
562
563 def test_VII3_restaging_after_modification_updates_object_id(
564 self, repo: pathlib.Path
565 ) -> None:
566 """VII3: re-staging a file after modification updates the object_id."""
567 (repo / "main.py").write_text("v1\n")
568 _run(repo, "code", "add", "main.py")
569 oid_v1 = read_stage(repo)["main.py"]["object_id"]
570
571 (repo / "main.py").write_text("v2\n")
572 _run(repo, "code", "add", "main.py")
573 oid_v2 = read_stage(repo)["main.py"]["object_id"]
574
575 assert oid_v1 != oid_v2
576
577 def test_VII4_nonexistent_path_exits_nonzero(
578 self, repo: pathlib.Path
579 ) -> None:
580 """VII4: staging a non-existent, untracked path exits non-zero."""
581 code, _ = _run_unchecked(repo, "code", "add", "ghost.py")
582 assert code != 0
583
584 def test_VII5_directory_scoped_add_leaves_top_level_unstaged(
585 self, repo: pathlib.Path
586 ) -> None:
587 """VII5: 'muse code add subdir' stages only files under that directory."""
588 sub = repo / "sub"
589 sub.mkdir()
590 (sub / "a.py").write_text("a = 1\n")
591 (repo / "top.py").write_text("t = 1\n")
592
593 _run(repo, "code", "add", "sub")
594 stage = read_stage(repo)
595 assert "sub/a.py" in stage
596 assert "top.py" not in stage
597
598 def test_VII6_verbose_shows_per_file_mode(
599 self, repo: pathlib.Path
600 ) -> None:
601 """VII6: --verbose shows one line per staged file."""
602 (repo / "main.py").write_text("x = 2\n")
603 _, out = _run(repo, "code", "add", "-v", "main.py")
604 assert "main.py" in out
605
606 def test_VII7_reset_HEAD_syntax_alias(self, repo: pathlib.Path) -> None:
607 """VII7: 'muse code reset HEAD <file>' is identical to 'muse code reset <file>'."""
608 (repo / "main.py").write_text("x = 3\n")
609 _run(repo, "code", "add", "main.py")
610
611 code, _ = _run(repo, "code", "reset", "HEAD", "main.py")
612 assert code == 0
613 assert not stage_path(repo).exists()
614
615 def test_VII8_stage_then_commit_then_restage_works(
616 self, repo: pathlib.Path
617 ) -> None:
618 """VII8: full stage → commit → re-stage cycle works end-to-end."""
619 (repo / "main.py").write_text("x = 5\n")
620 _run(repo, "code", "add", "main.py")
621 _run(repo, "commit", "-m", "v2")
622
623 assert not stage_path(repo).exists()
624
625 (repo / "main.py").write_text("x = 6\n")
626 code, out = _run(repo, "code", "add", "main.py")
627 assert code == 0
628 assert "main.py" in read_stage(repo)
629
630 def test_VII9_update_flag_includes_modifications_not_new(
631 self, repo: pathlib.Path
632 ) -> None:
633 """VII9: -u stages tracked modifications but not new untracked files."""
634 (repo / "main.py").write_text("x = 99\n") # tracked, modified
635 (repo / "untracked.py").write_text("u = 0\n") # new, untracked
636
637 _run(repo, "code", "add", "-u")
638 stage = read_stage(repo)
639 assert "main.py" in stage
640 assert "untracked.py" not in stage
641
642
643 # ===========================================================================
644 # VIII Stress tests
645 # ===========================================================================
646
647
648 class TestStressVIII:
649 """High-volume and adversarial scenarios."""
650
651 def test_VIII1_stage_500_files_correct_count(
652 self, repo: pathlib.Path
653 ) -> None:
654 """VIII1: staging 500 files produces 500 entries in the stage index."""
655 for i in range(500):
656 (repo / f"module_{i:04d}.py").write_text(f"X = {i}\n")
657
658 code, out = _run(repo, "code", "add", "-A")
659 assert code == 0, out
660 stage = read_stage(repo)
661 assert len(stage) >= 500
662
663 def test_VIII2_500_files_json_output_correct(
664 self, repo: pathlib.Path
665 ) -> None:
666 """VIII2: JSON output for 500 files has correct counts."""
667 for i in range(500):
668 (repo / f"f_{i:04d}.py").write_text(f"X = {i}\n")
669
670 _, out = _run(repo, "code", "add", "-A", "--format", "json")
671 data = json.loads(out.strip())
672 assert data["added"] >= 500
673 assert data["staged"] >= 500
674
675 def test_VIII3_stage_add_reset_cycle_50_times(
676 self, repo: pathlib.Path
677 ) -> None:
678 """VIII3: 50 add/reset cycles leave a clean stage each time."""
679 (repo / "main.py").write_text("x = 0\n")
680
681 for cycle in range(50):
682 (repo / "main.py").write_text(f"x = {cycle}\n")
683 code, _ = _run(repo, "code", "add", "main.py")
684 assert code == 0, f"Cycle {cycle}: add failed"
685
686 code, _ = _run(repo, "code", "reset", "main.py")
687 assert code == 0, f"Cycle {cycle}: reset failed"
688 assert not stage_path(repo).exists(), (
689 f"Cycle {cycle}: stage not cleared after reset"
690 )
691
692 def test_VIII4_large_file_stages_correctly(
693 self, repo: pathlib.Path
694 ) -> None:
695 """VIII4: a 5 MiB file stages and its object_id is correct."""
696 content = os.urandom(5 * 1024 * 1024)
697 (repo / "big.bin").write_bytes(content)
698
699 code, _ = _run(repo, "code", "add", "big.bin")
700 assert code == 0
701
702 stage = read_stage(repo)
703 assert "big.bin" in stage
704 expected_oid = hashlib.sha256(content).hexdigest()
705 assert stage["big.bin"]["object_id"] == expected_oid
706
707 def test_VIII5_all_modes_in_single_add(
708 self, repo: pathlib.Path
709 ) -> None:
710 """VIII5: a single add can capture added, modified, and deleted in one shot."""
711 # Add extra tracked file and commit first.
712 (repo / "to_delete.py").write_text("del = 1\n")
713 _run(repo, "code", "add", "to_delete.py")
714 _run(repo, "commit", "-m", "add to_delete")
715
716 (repo / "main.py").write_text("x = modified\n")
717 (repo / "to_delete.py").unlink()
718 (repo / "brand_new.py").write_text("new = True\n")
719
720 code, out = _run(repo, "code", "add", "--format", "json", "-A")
721 assert code == 0, out
722 data = json.loads(out.strip())
723 assert data["modified"] >= 1
724 assert data["added"] >= 1
725 assert data["deleted"] >= 1
726
727 def test_VIII6_staging_after_many_commits_works(
728 self, repo: pathlib.Path
729 ) -> None:
730 """VIII6: staging still works correctly after many commits."""
731 for i in range(50):
732 (repo / "main.py").write_text(f"x = {i}\n")
733 _run(repo, "commit", "--allow-empty", "-m", f"commit {i}")
734
735 (repo / "main.py").write_text("x = final\n")
736 code, _ = _run(repo, "code", "add", "main.py")
737 assert code == 0
738 stage = read_stage(repo)
739 assert "main.py" in stage
740
741
742 # ---------------------------------------------------------------------------
743 # Helper
744 # ---------------------------------------------------------------------------
745
746
747 def _read_stage(root: pathlib.Path) -> StagedFileMap:
748 return read_stage(root)
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago