gabriel / muse public
test_cmd_reset_hardening.py python
647 lines 28.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Hardening tests for ``muse reset`` — security, schema, error routing, ordering.
2
3 These tests cover the issues fixed in the security/correctness/agent-UX audit.
4 They are intentionally distinct from the existing test_cmd_reset_revert.py and
5 test_cli_reset_revert.py suites, which cover the core reset algorithm.
6
7 Coverage tiers
8 --------------
9 Unit — parser flags, dead-code removal.
10 Integration — error routing to stderr, JSON schema, --dry-run, ordering safety.
11 End-to-end — full CLI: security, branch-name sanitization.
12 Security — ANSI injection, ref sanitization, exc sanitization.
13 Stress — large repos, concurrent repos, reset-and-verify cycles.
14 """
15
16 from __future__ import annotations
17
18 import json
19 import os
20 import pathlib
21 import subprocess
22 import threading
23 import time
24
25 import pytest
26
27 from tests.cli_test_helper import CliRunner, InvokeResult
28 from muse.core.store import get_head_commit_id, read_current_branch
29
30 runner = CliRunner()
31
32 # ──────────────────────────────────────────────────────────────────────────────
33 # Helpers
34 # ──────────────────────────────────────────────────────────────────────────────
35
36 JSON_REQUIRED_KEYS = {
37 "branch", "ref", "old_commit_id", "new_commit_id", "snapshot_id", "mode", "dry_run",
38 }
39
40
41 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
42 saved = os.getcwd()
43 try:
44 os.chdir(repo)
45 return runner.invoke(None, args)
46 finally:
47 os.chdir(saved)
48
49
50 def _reset(repo: pathlib.Path, *extra: str) -> InvokeResult:
51 return _invoke(repo, ["reset", *extra])
52
53
54 def _commit(repo: pathlib.Path, message: str) -> str:
55 """Commit current working tree and return the (prefix of) commit ID."""
56 import re
57
58 result = _invoke(repo, ["commit", "-m", message])
59 m = re.search(r'\[(?:main|[^ ]+) ([0-9a-f]{8,})', result.output)
60 return m.group(1) if m else ""
61
62
63 @pytest.fixture()
64 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
65 """Initialised repo with two commits on ``main``."""
66 saved = os.getcwd()
67 try:
68 os.chdir(tmp_path)
69 runner.invoke(None, ["init"])
70 finally:
71 os.chdir(saved)
72 (tmp_path / "a.py").write_text("x = 1\n")
73 _commit(tmp_path, "initial")
74 (tmp_path / "b.py").write_text("y = 2\n")
75 _commit(tmp_path, "add b")
76 return tmp_path
77
78
79 @pytest.fixture()
80 def c1_id(repo: pathlib.Path) -> str:
81 """Full commit ID of the first commit (HEAD~1)."""
82 from muse.core.store import read_commit
83
84 head_id = get_head_commit_id(repo, "main") or ""
85 head = read_commit(repo, head_id)
86 return (head.parent_commit_id or "") if head else ""
87
88
89 # ──────────────────────────────────────────────────────────────────────────────
90 # Unit — parser flags
91 # ──────────────────────────────────────────────────────────────────────────────
92
93
94 class TestRegisterFlags:
95 def _parse(self, *args: str) -> "object":
96 import argparse
97 from muse.cli.commands.reset import register
98
99 p = argparse.ArgumentParser()
100 sub = p.add_subparsers()
101 register(sub)
102 return p.parse_args(["reset", *args])
103
104 def test_default_fmt_is_text(self) -> None:
105 import argparse
106 from muse.cli.commands.reset import register
107
108 p = argparse.ArgumentParser()
109 sub = p.add_subparsers()
110 register(sub)
111 ns = p.parse_args(["reset", "HEAD~1"])
112 assert ns.fmt == "text"
113
114 def test_json_flag_sets_fmt(self) -> None:
115 import argparse
116 from muse.cli.commands.reset import register
117
118 p = argparse.ArgumentParser()
119 sub = p.add_subparsers()
120 register(sub)
121 ns = p.parse_args(["reset", "HEAD~1", "--json"])
122 assert ns.fmt == "json"
123
124 def test_format_json_flag(self) -> None:
125 import argparse
126 from muse.cli.commands.reset import register
127
128 p = argparse.ArgumentParser()
129 sub = p.add_subparsers()
130 register(sub)
131 ns = p.parse_args(["reset", "HEAD~1", "--format", "json"])
132 assert ns.fmt == "json"
133
134 def test_dry_run_default_false(self) -> None:
135 import argparse
136 from muse.cli.commands.reset import register
137
138 p = argparse.ArgumentParser()
139 sub = p.add_subparsers()
140 register(sub)
141 ns = p.parse_args(["reset", "HEAD~1"])
142 assert ns.dry_run is False
143
144 def test_dry_run_flag(self) -> None:
145 import argparse
146 from muse.cli.commands.reset import register
147
148 p = argparse.ArgumentParser()
149 sub = p.add_subparsers()
150 register(sub)
151 ns = p.parse_args(["reset", "HEAD~1", "--dry-run"])
152 assert ns.dry_run is True
153
154 def test_hard_default_false(self) -> None:
155 import argparse
156 from muse.cli.commands.reset import register
157
158 p = argparse.ArgumentParser()
159 sub = p.add_subparsers()
160 register(sub)
161 ns = p.parse_args(["reset", "HEAD~1"])
162 assert ns.hard is False
163
164 def test_hard_flag(self) -> None:
165 import argparse
166 from muse.cli.commands.reset import register
167
168 p = argparse.ArgumentParser()
169 sub = p.add_subparsers()
170 register(sub)
171 ns = p.parse_args(["reset", "HEAD~1", "--hard"])
172 assert ns.hard is True
173
174 def test_force_default_false(self) -> None:
175 import argparse
176 from muse.cli.commands.reset import register
177
178 p = argparse.ArgumentParser()
179 sub = p.add_subparsers()
180 register(sub)
181 ns = p.parse_args(["reset", "HEAD~1"])
182 assert ns.force is False
183
184
185 # ──────────────────────────────────────────────────────────────────────────────
186 # Unit — dead-code removal
187 # ──────────────────────────────────────────────────────────────────────────────
188
189
190 class TestDeadCodeRemoved:
191 def test_read_branch_wrapper_removed(self) -> None:
192 import muse.cli.commands.reset as m
193
194 assert not hasattr(m, "_read_branch"), (
195 "_read_branch was a dead one-liner wrapper and must be deleted"
196 )
197
198
199 # ──────────────────────────────────────────────────────────────────────────────
200 # Integration — error routing to stderr
201 # ──────────────────────────────────────────────────────────────────────────────
202
203
204 class TestErrorRouting:
205 def test_unknown_ref_error_to_stderr(self, repo: pathlib.Path) -> None:
206 result = _reset(repo, "bogus-ref")
207 assert result.exit_code == 1
208 assert "not found" in (result.stderr or "").lower()
209 assert "not found" not in result.output.replace(result.stderr or "", "")
210
211 def test_unknown_format_error_to_stderr(self, repo: pathlib.Path) -> None:
212 result = _reset(repo, "HEAD~1", "--format", "xml")
213 assert result.exit_code == 1
214 assert "Unknown" in (result.stderr or "")
215
216 def test_missing_snapshot_error_to_stderr(self, repo: pathlib.Path, c1_id: str) -> None:
217 """When --hard reset target snapshot is missing, error goes to stderr."""
218 from muse.core.store import read_commit
219
220 commit = read_commit(repo, c1_id)
221 if commit is None:
222 pytest.skip("Could not read c1 commit")
223 snap_id = commit.snapshot_id
224 # _snapshot_path strips the sha256: prefix — match that naming here.
225 snap_hex = snap_id.removeprefix("sha256:")
226 snap_path = repo / ".muse" / "snapshots" / f"{snap_hex}.msgpack"
227 snap_path.unlink(missing_ok=True)
228
229 result = _reset(repo, c1_id, "--hard")
230 assert result.exit_code != 0
231 assert "not found" in (result.stderr or "").lower() or "snapshot" in (result.stderr or "").lower()
232
233 def test_snapshot_pre_validated_before_branch_ref_written(
234 self, repo: pathlib.Path, c1_id: str
235 ) -> None:
236 """Critical ordering fix: branch ref must NOT advance when snapshot is missing.
237
238 Before the fix, write_branch_ref() was called BEFORE read_snapshot(),
239 so a missing snapshot would leave the branch pointer at the new commit
240 with an unrestored working tree — an inconsistent, unrecoverable state.
241 """
242 from muse.core.store import read_commit
243
244 commit = read_commit(repo, c1_id)
245 if commit is None:
246 pytest.skip("Could not read c1 commit")
247 snap_id = commit.snapshot_id
248 snap_hex = snap_id.removeprefix("sha256:")
249 snap_path = repo / ".muse" / "snapshots" / f"{snap_hex}.msgpack"
250 snap_path.unlink(missing_ok=True)
251
252 before_head = get_head_commit_id(repo, "main")
253 _reset(repo, c1_id, "--hard")
254 after_head = get_head_commit_id(repo, "main")
255
256 # Branch ref must remain at the original commit — not advanced to c1.
257 assert before_head == after_head, (
258 "Branch ref was advanced even though snapshot was missing — "
259 "this is the pre-fix ordering bug"
260 )
261
262 def test_snapshot_source_in_run_before_write_branch_ref(self) -> None:
263 """Source inspection: read_snapshot must appear before write_branch_ref.
264
265 We skip comment lines (those starting with #) to avoid false matches
266 from documentation comments that reference function names.
267 """
268 import inspect
269 from muse.cli.commands.reset import run
270
271 src = inspect.getsource(run)
272 # Only consider non-comment executable lines.
273 code_lines = [
274 (i, l) for i, l in enumerate(src.split("\n"))
275 if not l.lstrip().startswith("#")
276 ]
277 snap_lineno = next((i for i, l in code_lines if "read_snapshot(" in l), -1)
278 write_lineno = next((i for i, l in code_lines if "write_branch_ref(" in l), -1)
279 assert snap_lineno != -1, "read_snapshot not found in run()"
280 assert write_lineno != -1, "write_branch_ref not found in run()"
281 assert snap_lineno < write_lineno, (
282 f"read_snapshot (line {snap_lineno}) must appear before "
283 f"write_branch_ref (line {write_lineno}) in run() — "
284 "this is the critical ordering fix that prevents orphaned branch refs"
285 )
286
287
288 # ──────────────────────────────────────────────────────────────────────────────
289 # Integration — JSON schema stability
290 # ──────────────────────────────────────────────────────────────────────────────
291
292
293 class TestJsonSchema:
294 def test_soft_reset_has_all_keys(self, repo: pathlib.Path, c1_id: str) -> None:
295 result = _reset(repo, c1_id, "--json")
296 assert result.exit_code == 0
297 data = json.loads(result.output)
298 missing = JSON_REQUIRED_KEYS - set(data)
299 assert not missing, f"Missing keys in soft reset JSON: {missing}"
300
301 def test_hard_reset_has_all_keys(self, repo: pathlib.Path, c1_id: str) -> None:
302 result = _reset(repo, c1_id, "--hard", "--json")
303 assert result.exit_code == 0
304 data = json.loads(result.output)
305 missing = JSON_REQUIRED_KEYS - set(data)
306 assert not missing, f"Missing keys in hard reset JSON: {missing}"
307
308 def test_dry_run_has_all_keys(self, repo: pathlib.Path, c1_id: str) -> None:
309 result = _reset(repo, c1_id, "--dry-run", "--json")
310 assert result.exit_code == 0
311 data = json.loads(result.output)
312 missing = JSON_REQUIRED_KEYS - set(data)
313 assert not missing, f"Missing keys in dry-run JSON: {missing}"
314
315 def test_soft_mode_is_soft(self, repo: pathlib.Path, c1_id: str) -> None:
316 result = _reset(repo, c1_id, "--json")
317 data = json.loads(result.output)
318 assert data["mode"] == "soft"
319
320 def test_hard_mode_is_hard(self, repo: pathlib.Path, c1_id: str) -> None:
321 result = _reset(repo, c1_id, "--hard", "--json")
322 data = json.loads(result.output)
323 assert data["mode"] == "hard"
324
325 def test_dry_run_flag_is_true(self, repo: pathlib.Path, c1_id: str) -> None:
326 result = _reset(repo, c1_id, "--dry-run", "--json")
327 data = json.loads(result.output)
328 assert data["dry_run"] is True
329
330 def test_live_reset_dry_run_flag_is_false(self, repo: pathlib.Path, c1_id: str) -> None:
331 result = _reset(repo, c1_id, "--json")
332 data = json.loads(result.output)
333 assert data["dry_run"] is False
334
335 def test_ref_field_matches_input(self, repo: pathlib.Path, c1_id: str) -> None:
336 result = _reset(repo, c1_id, "--json")
337 data = json.loads(result.output)
338 assert data["ref"] == c1_id
339
340 def test_snapshot_id_is_sha256(self, repo: pathlib.Path, c1_id: str) -> None:
341 result = _reset(repo, c1_id, "--json")
342 data = json.loads(result.output)
343 sid = data["snapshot_id"]
344 # IDs are sha256:-prefixed; strip prefix then verify 64-char hex.
345 hex_part = sid.removeprefix("sha256:")
346 assert len(hex_part) == 64, f"Expected 64-char hex after prefix, got {len(hex_part)}: {sid!r}"
347 assert all(c in "0123456789abcdef" for c in hex_part)
348
349 def test_new_commit_id_is_sha256(self, repo: pathlib.Path, c1_id: str) -> None:
350 result = _reset(repo, c1_id, "--json")
351 data = json.loads(result.output)
352 nid = data["new_commit_id"]
353 hex_part = nid.removeprefix("sha256:")
354 assert len(hex_part) == 64, f"Expected 64-char hex after prefix, got {len(hex_part)}: {nid!r}"
355
356 def test_old_commit_id_was_head(self, repo: pathlib.Path, c1_id: str) -> None:
357 head_before = get_head_commit_id(repo, "main")
358 result = _reset(repo, c1_id, "--json")
359 data = json.loads(result.output)
360 assert data["old_commit_id"] == head_before
361
362 def test_branch_field_is_current_branch(self, repo: pathlib.Path, c1_id: str) -> None:
363 result = _reset(repo, c1_id, "--json")
364 data = json.loads(result.output)
365 assert data["branch"] == "main"
366
367
368 # ──────────────────────────────────────────────────────────────────────────────
369 # Integration — --dry-run
370 # ──────────────────────────────────────────────────────────────────────────────
371
372
373 class TestDryRun:
374 def test_dry_run_does_not_advance_branch(self, repo: pathlib.Path, c1_id: str) -> None:
375 before = get_head_commit_id(repo, "main")
376 _reset(repo, c1_id, "--dry-run")
377 after = get_head_commit_id(repo, "main")
378 assert before == after
379
380 def test_dry_run_does_not_modify_workdir(self, repo: pathlib.Path, c1_id: str) -> None:
381 b_content = (repo / "b.py").read_text()
382 _reset(repo, c1_id, "--dry-run", "--hard")
383 assert (repo / "b.py").read_text() == b_content
384
385 def test_dry_run_exit_code_zero(self, repo: pathlib.Path, c1_id: str) -> None:
386 result = _reset(repo, c1_id, "--dry-run")
387 assert result.exit_code == 0
388
389 def test_dry_run_invalid_ref_exits_1(self, repo: pathlib.Path) -> None:
390 result = _reset(repo, "nonexistent-ref", "--dry-run")
391 assert result.exit_code == 1
392
393 def test_dry_run_json_shows_would_be_commit(self, repo: pathlib.Path, c1_id: str) -> None:
394 result = _reset(repo, c1_id, "--dry-run", "--json")
395 data = json.loads(result.output)
396 assert data["new_commit_id"] == c1_id or data["new_commit_id"].startswith(c1_id)
397
398 def test_dry_run_text_mentions_would(self, repo: pathlib.Path, c1_id: str) -> None:
399 result = _reset(repo, c1_id, "--dry-run")
400 assert "dry-run" in result.output.lower() or "would" in result.output.lower()
401
402 def test_dry_run_does_not_write_reflog(self, repo: pathlib.Path, c1_id: str) -> None:
403 from muse.core.reflog import read_reflog
404
405 before_count = len(read_reflog(repo, "main"))
406 _reset(repo, c1_id, "--dry-run")
407 after_count = len(read_reflog(repo, "main"))
408 assert before_count == after_count
409
410
411 # ──────────────────────────────────────────────────────────────────────────────
412 # Integration — soft reset
413 # ──────────────────────────────────────────────────────────────────────────────
414
415
416 class TestSoftReset:
417 def test_soft_advances_branch_to_target(self, repo: pathlib.Path, c1_id: str) -> None:
418 _reset(repo, c1_id)
419 head = get_head_commit_id(repo, "main")
420 assert head is not None and head.startswith(c1_id)
421
422 def test_soft_preserves_working_tree(self, repo: pathlib.Path, c1_id: str) -> None:
423 before = (repo / "b.py").read_text()
424 _reset(repo, c1_id)
425 assert (repo / "b.py").read_text() == before
426
427 def test_soft_reflog_entry_written(self, repo: pathlib.Path, c1_id: str) -> None:
428 from muse.core.reflog import read_reflog
429
430 before_count = len(read_reflog(repo, "main"))
431 _reset(repo, c1_id)
432 after_count = len(read_reflog(repo, "main"))
433 assert after_count > before_count
434
435 def test_soft_text_output_has_commit_id(self, repo: pathlib.Path, c1_id: str) -> None:
436 result = _reset(repo, c1_id)
437 assert c1_id[:8] in result.output
438
439
440 # ──────────────────────────────────────────────────────────────────────────────
441 # Integration — hard reset
442 # ──────────────────────────────────────────────────────────────────────────────
443
444
445 class TestHardReset:
446 def test_hard_advances_branch_to_target(self, repo: pathlib.Path, c1_id: str) -> None:
447 result = _reset(repo, c1_id, "--hard")
448 assert result.exit_code == 0
449 head = get_head_commit_id(repo, "main")
450 assert head is not None and head.startswith(c1_id)
451
452 def test_hard_restores_workdir(self, repo: pathlib.Path, c1_id: str) -> None:
453 assert (repo / "b.py").exists()
454 result = _reset(repo, c1_id, "--hard")
455 assert result.exit_code == 0
456 # b.py was added in the second commit; resetting to c1 removes it
457 assert not (repo / "b.py").exists()
458
459 def test_hard_text_output_shows_head_is_now(self, repo: pathlib.Path, c1_id: str) -> None:
460 result = _reset(repo, c1_id, "--hard")
461 assert "HEAD is now at" in result.output or c1_id[:8] in result.output
462
463 def test_hard_uses_message_first_line(self, repo: pathlib.Path, c1_id: str) -> None:
464 """Text output shows only the first line of a multiline commit message."""
465 (repo / "x.py").write_text("x=1\n")
466 multiline_id = _commit(repo, "first line\n\nmore detail here")
467 # Go back to c1 so we can reset to the multiline commit
468 _reset(repo, c1_id)
469 result = _reset(repo, multiline_id, "--hard")
470 assert "more detail here" not in result.output
471
472
473 # ──────────────────────────────────────────────────────────────────────────────
474 # Security — ANSI injection
475 # ──────────────────────────────────────────────────────────────────────────────
476
477
478 class TestSecurityAnsi:
479 ESC = "\x1b["
480
481 def test_unknown_ref_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
482 evil_ref = f"{self.ESC}31mevil{self.ESC}0m"
483 result = _reset(repo, evil_ref)
484 assert self.ESC not in (result.stderr or "")
485
486 def test_unknown_format_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
487 evil_fmt = f"{self.ESC}31mxml{self.ESC}0m"
488 result = _reset(repo, "HEAD~1", "--format", evil_fmt)
489 assert self.ESC not in (result.stderr or "")
490
491 def test_no_ansi_in_stdout_on_error(self, repo: pathlib.Path) -> None:
492 evil_ref = f"{self.ESC}31mevil{self.ESC}0m"
493 result = _reset(repo, evil_ref)
494 # stdout must be clean — errors go to stderr
495 stdout_only = result.output.replace(result.stderr or "", "")
496 assert self.ESC not in stdout_only
497
498 def test_exc_sanitized_in_branch_validation(self, repo: pathlib.Path) -> None:
499 """sanitize_display(str(exc)) must be used, not bare f'{exc}'."""
500 import inspect
501 from muse.cli.commands.reset import run
502
503 src = inspect.getsource(run)
504 # Confirm the pattern sanitize_display(str(exc)) is used, not bare {exc}
505 assert "sanitize_display(str(exc))" in src
506
507 def test_ref_sanitized_in_not_found_message(self) -> None:
508 """sanitize_display(ref) must be used in the not-found error message."""
509 import inspect
510 from muse.cli.commands.reset import run
511
512 src = inspect.getsource(run)
513 assert "sanitize_display(ref)" in src
514
515 def test_soft_text_output_sanitizes_branch(self, repo: pathlib.Path, c1_id: str) -> None:
516 result = _reset(repo, c1_id)
517 assert self.ESC not in result.output
518
519 def test_hard_text_output_sanitizes_message(self, repo: pathlib.Path, c1_id: str) -> None:
520 result = _reset(repo, c1_id, "--hard")
521 assert self.ESC not in result.output
522
523
524 # ──────────────────────────────────────────────────────────────────────────────
525 # Integration — get_head_commit_id replaces ref_file.read_text()
526 # ──────────────────────────────────────────────────────────────────────────────
527
528
529 class TestRefAbstraction:
530 def test_no_direct_ref_file_read(self) -> None:
531 """run() must use get_head_commit_id(), not read ref_file directly."""
532 import inspect
533 from muse.cli.commands.reset import run
534
535 src = inspect.getsource(run)
536 assert "ref_file.read_text" not in src, (
537 "Direct ref_file.read_text() bypasses the get_head_commit_id "
538 "abstraction layer and is a TOCTOU vulnerability"
539 )
540 assert "get_head_commit_id" in src
541
542 def test_old_commit_id_correct_on_first_commit(self, repo: pathlib.Path) -> None:
543 """old_commit_id in JSON must match HEAD before the reset."""
544 head = get_head_commit_id(repo, "main")
545 result = _reset(repo, "HEAD~1", "--json")
546 data = json.loads(result.output)
547 assert data["old_commit_id"] == head
548
549
550 # ──────────────────────────────────────────────────────────────────────────────
551 # Stress
552 # ──────────────────────────────────────────────────────────────────────────────
553
554
555 @pytest.mark.slow
556 class TestStress:
557 def test_soft_reset_across_50_commits(self, repo: pathlib.Path) -> None:
558 """Soft-reset across 50 commits must complete under 5s."""
559 # Add 48 more commits (we already have 2)
560 for i in range(48):
561 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
562 _commit(repo, f"commit {i}")
563
564 from muse.core.store import read_commit
565
566 # Walk to the 10th commit from the end
567 current_id = get_head_commit_id(repo, "main") or ""
568 target_id = current_id
569 for _ in range(10):
570 c = read_commit(repo, target_id)
571 if c and c.parent_commit_id:
572 target_id = c.parent_commit_id
573
574 t0 = time.perf_counter()
575 result = _reset(repo, target_id)
576 elapsed = (time.perf_counter() - t0) * 1000
577 assert result.exit_code == 0
578 assert elapsed < 5000, f"50-commit soft reset took {elapsed:.0f}ms (limit 5s)"
579
580 def test_hard_reset_with_100_files(self, repo: pathlib.Path, c1_id: str) -> None:
581 """Hard-reset restoring 100 files must complete under 5s."""
582 for i in range(100):
583 (repo / f"g{i:03d}.py").write_text(f"y={i}\n")
584 _commit(repo, "add 100 files")
585 head_id = get_head_commit_id(repo, "main") or ""
586
587 # Reset back to c1 (removes 101 files) then back to head (restores them)
588 t0 = time.perf_counter()
589 r1 = _reset(repo, c1_id, "--hard")
590 r2 = _reset(repo, head_id, "--hard")
591 elapsed = (time.perf_counter() - t0) * 1000
592 assert r1.exit_code == 0
593 assert r2.exit_code == 0
594 assert elapsed < 5000, f"100-file hard reset cycle took {elapsed:.0f}ms"
595
596 def test_dry_run_50_commits_fast(self, repo: pathlib.Path) -> None:
597 """Dry-run across 50 commits must complete under 2s."""
598 for i in range(48):
599 (repo / f"h{i:03d}.py").write_text(f"z={i}\n")
600 _commit(repo, f"commit {i}")
601
602 t0 = time.perf_counter()
603 result = _reset(repo, "HEAD~1", "--dry-run")
604 elapsed = (time.perf_counter() - t0) * 1000
605 assert result.exit_code == 0
606 assert elapsed < 2000, f"dry-run took {elapsed:.0f}ms (limit 2s)"
607
608 def test_concurrent_resets_separate_repos(self, tmp_path: pathlib.Path) -> None:
609 """Multiple repos resetting concurrently must not interfere."""
610 errors: list[str] = []
611
612 def do_reset(idx: int) -> None:
613 repo_dir = tmp_path / f"repo_{idx}"
614 repo_dir.mkdir()
615 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
616 (repo_dir / "a.py").write_text(f"x={idx}\n")
617 subprocess.run(
618 ["muse", "commit", "-m", f"c1_{idx}"],
619 cwd=str(repo_dir), capture_output=True,
620 )
621 (repo_dir / "b.py").write_text(f"y={idx}\n")
622 subprocess.run(
623 ["muse", "commit", "-m", f"c2_{idx}"],
624 cwd=str(repo_dir), capture_output=True,
625 )
626 r = subprocess.run(
627 ["muse", "reset", "HEAD~1", "--json"],
628 cwd=str(repo_dir), capture_output=True, text=True,
629 )
630 if r.returncode != 0:
631 errors.append(f"repo_{idx}: exit={r.returncode}, err={r.stderr[:60]}")
632 return
633 try:
634 data = json.loads(r.stdout)
635 if data["mode"] != "soft":
636 errors.append(f"repo_{idx}: unexpected mode {data['mode']}")
637 if data["dry_run"] is not False:
638 errors.append(f"repo_{idx}: dry_run not False")
639 except Exception as e:
640 errors.append(f"repo_{idx}: parse error {e}")
641
642 threads = [threading.Thread(target=do_reset, args=(i,)) for i in range(6)]
643 for t in threads:
644 t.start()
645 for t in threads:
646 t.join()
647 assert not errors, "Concurrent reset errors:\n" + "\n".join(errors)
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago