gabriel / muse public
test_cmd_reset_hardening.py python
640 lines 27.9 KB
Raw
sha256:3788deb0ce8d6f235a23185f7f5cd65785ecdb41b0a7f2b0107adebf45c09221 update tests/test_cmd_reset_hardening.py and tests/test_cmd… Human 42 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 # Snapshots live in .muse/snapshots/<snap_id>.msgpack
225 snap_path = repo / ".muse" / "snapshots" / f"{snap_id}.msgpack"
226 snap_path.unlink(missing_ok=True)
227
228 result = _reset(repo, c1_id, "--hard")
229 assert result.exit_code != 0
230 assert "not found" in (result.stderr or "").lower() or "snapshot" in (result.stderr or "").lower()
231
232 def test_snapshot_pre_validated_before_branch_ref_written(
233 self, repo: pathlib.Path, c1_id: str
234 ) -> None:
235 """Critical ordering fix: branch ref must NOT advance when snapshot is missing.
236
237 Before the fix, write_branch_ref() was called BEFORE read_snapshot(),
238 so a missing snapshot would leave the branch pointer at the new commit
239 with an unrestored working tree — an inconsistent, unrecoverable state.
240 """
241 from muse.core.store import read_commit
242
243 commit = read_commit(repo, c1_id)
244 if commit is None:
245 pytest.skip("Could not read c1 commit")
246 snap_id = commit.snapshot_id
247 snap_path = repo / ".muse" / "snapshots" / f"{snap_id}.msgpack"
248 snap_path.unlink(missing_ok=True)
249
250 before_head = get_head_commit_id(repo, "main")
251 _reset(repo, c1_id, "--hard")
252 after_head = get_head_commit_id(repo, "main")
253
254 # Branch ref must remain at the original commit — not advanced to c1.
255 assert before_head == after_head, (
256 "Branch ref was advanced even though snapshot was missing — "
257 "this is the pre-fix ordering bug"
258 )
259
260 def test_snapshot_source_in_run_before_write_branch_ref(self) -> None:
261 """Source inspection: read_snapshot must appear before write_branch_ref.
262
263 We skip comment lines (those starting with #) to avoid false matches
264 from documentation comments that reference function names.
265 """
266 import inspect
267 from muse.cli.commands.reset import run
268
269 src = inspect.getsource(run)
270 # Only consider non-comment executable lines.
271 code_lines = [
272 (i, l) for i, l in enumerate(src.split("\n"))
273 if not l.lstrip().startswith("#")
274 ]
275 snap_lineno = next((i for i, l in code_lines if "read_snapshot(" in l), -1)
276 write_lineno = next((i for i, l in code_lines if "write_branch_ref(" in l), -1)
277 assert snap_lineno != -1, "read_snapshot not found in run()"
278 assert write_lineno != -1, "write_branch_ref not found in run()"
279 assert snap_lineno < write_lineno, (
280 f"read_snapshot (line {snap_lineno}) must appear before "
281 f"write_branch_ref (line {write_lineno}) in run() — "
282 "this is the critical ordering fix that prevents orphaned branch refs"
283 )
284
285
286 # ──────────────────────────────────────────────────────────────────────────────
287 # Integration — JSON schema stability
288 # ──────────────────────────────────────────────────────────────────────────────
289
290
291 class TestJsonSchema:
292 def test_soft_reset_has_all_keys(self, repo: pathlib.Path, c1_id: str) -> None:
293 result = _reset(repo, c1_id, "--json")
294 assert result.exit_code == 0
295 data = json.loads(result.output)
296 missing = JSON_REQUIRED_KEYS - set(data)
297 assert not missing, f"Missing keys in soft reset JSON: {missing}"
298
299 def test_hard_reset_has_all_keys(self, repo: pathlib.Path, c1_id: str) -> None:
300 result = _reset(repo, c1_id, "--hard", "--json")
301 assert result.exit_code == 0
302 data = json.loads(result.output)
303 missing = JSON_REQUIRED_KEYS - set(data)
304 assert not missing, f"Missing keys in hard reset JSON: {missing}"
305
306 def test_dry_run_has_all_keys(self, repo: pathlib.Path, c1_id: str) -> None:
307 result = _reset(repo, c1_id, "--dry-run", "--json")
308 assert result.exit_code == 0
309 data = json.loads(result.output)
310 missing = JSON_REQUIRED_KEYS - set(data)
311 assert not missing, f"Missing keys in dry-run JSON: {missing}"
312
313 def test_soft_mode_is_soft(self, repo: pathlib.Path, c1_id: str) -> None:
314 result = _reset(repo, c1_id, "--json")
315 data = json.loads(result.output)
316 assert data["mode"] == "soft"
317
318 def test_hard_mode_is_hard(self, repo: pathlib.Path, c1_id: str) -> None:
319 result = _reset(repo, c1_id, "--hard", "--json")
320 data = json.loads(result.output)
321 assert data["mode"] == "hard"
322
323 def test_dry_run_flag_is_true(self, repo: pathlib.Path, c1_id: str) -> None:
324 result = _reset(repo, c1_id, "--dry-run", "--json")
325 data = json.loads(result.output)
326 assert data["dry_run"] is True
327
328 def test_live_reset_dry_run_flag_is_false(self, repo: pathlib.Path, c1_id: str) -> None:
329 result = _reset(repo, c1_id, "--json")
330 data = json.loads(result.output)
331 assert data["dry_run"] is False
332
333 def test_ref_field_matches_input(self, repo: pathlib.Path, c1_id: str) -> None:
334 result = _reset(repo, c1_id, "--json")
335 data = json.loads(result.output)
336 assert data["ref"] == c1_id
337
338 def test_snapshot_id_is_sha256(self, repo: pathlib.Path, c1_id: str) -> None:
339 result = _reset(repo, c1_id, "--json")
340 data = json.loads(result.output)
341 assert len(data["snapshot_id"]) == 64
342 assert all(c in "0123456789abcdef" for c in data["snapshot_id"])
343
344 def test_new_commit_id_is_sha256(self, repo: pathlib.Path, c1_id: str) -> None:
345 result = _reset(repo, c1_id, "--json")
346 data = json.loads(result.output)
347 assert len(data["new_commit_id"]) == 64
348
349 def test_old_commit_id_was_head(self, repo: pathlib.Path, c1_id: str) -> None:
350 head_before = get_head_commit_id(repo, "main")
351 result = _reset(repo, c1_id, "--json")
352 data = json.loads(result.output)
353 assert data["old_commit_id"] == head_before
354
355 def test_branch_field_is_current_branch(self, repo: pathlib.Path, c1_id: str) -> None:
356 result = _reset(repo, c1_id, "--json")
357 data = json.loads(result.output)
358 assert data["branch"] == "main"
359
360
361 # ──────────────────────────────────────────────────────────────────────────────
362 # Integration — --dry-run
363 # ──────────────────────────────────────────────────────────────────────────────
364
365
366 class TestDryRun:
367 def test_dry_run_does_not_advance_branch(self, repo: pathlib.Path, c1_id: str) -> None:
368 before = get_head_commit_id(repo, "main")
369 _reset(repo, c1_id, "--dry-run")
370 after = get_head_commit_id(repo, "main")
371 assert before == after
372
373 def test_dry_run_does_not_modify_workdir(self, repo: pathlib.Path, c1_id: str) -> None:
374 b_content = (repo / "b.py").read_text()
375 _reset(repo, c1_id, "--dry-run", "--hard")
376 assert (repo / "b.py").read_text() == b_content
377
378 def test_dry_run_exit_code_zero(self, repo: pathlib.Path, c1_id: str) -> None:
379 result = _reset(repo, c1_id, "--dry-run")
380 assert result.exit_code == 0
381
382 def test_dry_run_invalid_ref_exits_1(self, repo: pathlib.Path) -> None:
383 result = _reset(repo, "nonexistent-ref", "--dry-run")
384 assert result.exit_code == 1
385
386 def test_dry_run_json_shows_would_be_commit(self, repo: pathlib.Path, c1_id: str) -> None:
387 result = _reset(repo, c1_id, "--dry-run", "--json")
388 data = json.loads(result.output)
389 assert data["new_commit_id"] == c1_id or data["new_commit_id"].startswith(c1_id)
390
391 def test_dry_run_text_mentions_would(self, repo: pathlib.Path, c1_id: str) -> None:
392 result = _reset(repo, c1_id, "--dry-run")
393 assert "dry-run" in result.output.lower() or "would" in result.output.lower()
394
395 def test_dry_run_does_not_write_reflog(self, repo: pathlib.Path, c1_id: str) -> None:
396 from muse.core.reflog import read_reflog
397
398 before_count = len(read_reflog(repo, "main"))
399 _reset(repo, c1_id, "--dry-run")
400 after_count = len(read_reflog(repo, "main"))
401 assert before_count == after_count
402
403
404 # ──────────────────────────────────────────────────────────────────────────────
405 # Integration — soft reset
406 # ──────────────────────────────────────────────────────────────────────────────
407
408
409 class TestSoftReset:
410 def test_soft_advances_branch_to_target(self, repo: pathlib.Path, c1_id: str) -> None:
411 _reset(repo, c1_id)
412 head = get_head_commit_id(repo, "main")
413 assert head is not None and head.startswith(c1_id)
414
415 def test_soft_preserves_working_tree(self, repo: pathlib.Path, c1_id: str) -> None:
416 before = (repo / "b.py").read_text()
417 _reset(repo, c1_id)
418 assert (repo / "b.py").read_text() == before
419
420 def test_soft_reflog_entry_written(self, repo: pathlib.Path, c1_id: str) -> None:
421 from muse.core.reflog import read_reflog
422
423 before_count = len(read_reflog(repo, "main"))
424 _reset(repo, c1_id)
425 after_count = len(read_reflog(repo, "main"))
426 assert after_count > before_count
427
428 def test_soft_text_output_has_commit_id(self, repo: pathlib.Path, c1_id: str) -> None:
429 result = _reset(repo, c1_id)
430 assert c1_id[:8] in result.output
431
432
433 # ──────────────────────────────────────────────────────────────────────────────
434 # Integration — hard reset
435 # ──────────────────────────────────────────────────────────────────────────────
436
437
438 class TestHardReset:
439 def test_hard_advances_branch_to_target(self, repo: pathlib.Path, c1_id: str) -> None:
440 result = _reset(repo, c1_id, "--hard")
441 assert result.exit_code == 0
442 head = get_head_commit_id(repo, "main")
443 assert head is not None and head.startswith(c1_id)
444
445 def test_hard_restores_workdir(self, repo: pathlib.Path, c1_id: str) -> None:
446 assert (repo / "b.py").exists()
447 result = _reset(repo, c1_id, "--hard")
448 assert result.exit_code == 0
449 # b.py was added in the second commit; resetting to c1 removes it
450 assert not (repo / "b.py").exists()
451
452 def test_hard_text_output_shows_head_is_now(self, repo: pathlib.Path, c1_id: str) -> None:
453 result = _reset(repo, c1_id, "--hard")
454 assert "HEAD is now at" in result.output or c1_id[:8] in result.output
455
456 def test_hard_uses_message_first_line(self, repo: pathlib.Path, c1_id: str) -> None:
457 """Text output shows only the first line of a multiline commit message."""
458 (repo / "x.py").write_text("x=1\n")
459 multiline_id = _commit(repo, "first line\n\nmore detail here")
460 # Go back to c1 so we can reset to the multiline commit
461 _reset(repo, c1_id)
462 result = _reset(repo, multiline_id, "--hard")
463 assert "more detail here" not in result.output
464
465
466 # ──────────────────────────────────────────────────────────────────────────────
467 # Security — ANSI injection
468 # ──────────────────────────────────────────────────────────────────────────────
469
470
471 class TestSecurityAnsi:
472 ESC = "\x1b["
473
474 def test_unknown_ref_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
475 evil_ref = f"{self.ESC}31mevil{self.ESC}0m"
476 result = _reset(repo, evil_ref)
477 assert self.ESC not in (result.stderr or "")
478
479 def test_unknown_format_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
480 evil_fmt = f"{self.ESC}31mxml{self.ESC}0m"
481 result = _reset(repo, "HEAD~1", "--format", evil_fmt)
482 assert self.ESC not in (result.stderr or "")
483
484 def test_no_ansi_in_stdout_on_error(self, repo: pathlib.Path) -> None:
485 evil_ref = f"{self.ESC}31mevil{self.ESC}0m"
486 result = _reset(repo, evil_ref)
487 # stdout must be clean — errors go to stderr
488 stdout_only = result.output.replace(result.stderr or "", "")
489 assert self.ESC not in stdout_only
490
491 def test_exc_sanitized_in_branch_validation(self, repo: pathlib.Path) -> None:
492 """sanitize_display(str(exc)) must be used, not bare f'{exc}'."""
493 import inspect
494 from muse.cli.commands.reset import run
495
496 src = inspect.getsource(run)
497 # Confirm the pattern sanitize_display(str(exc)) is used, not bare {exc}
498 assert "sanitize_display(str(exc))" in src
499
500 def test_ref_sanitized_in_not_found_message(self) -> None:
501 """sanitize_display(ref) must be used in the not-found error message."""
502 import inspect
503 from muse.cli.commands.reset import run
504
505 src = inspect.getsource(run)
506 assert "sanitize_display(ref)" in src
507
508 def test_soft_text_output_sanitizes_branch(self, repo: pathlib.Path, c1_id: str) -> None:
509 result = _reset(repo, c1_id)
510 assert self.ESC not in result.output
511
512 def test_hard_text_output_sanitizes_message(self, repo: pathlib.Path, c1_id: str) -> None:
513 result = _reset(repo, c1_id, "--hard")
514 assert self.ESC not in result.output
515
516
517 # ──────────────────────────────────────────────────────────────────────────────
518 # Integration — get_head_commit_id replaces ref_file.read_text()
519 # ──────────────────────────────────────────────────────────────────────────────
520
521
522 class TestRefAbstraction:
523 def test_no_direct_ref_file_read(self) -> None:
524 """run() must use get_head_commit_id(), not read ref_file directly."""
525 import inspect
526 from muse.cli.commands.reset import run
527
528 src = inspect.getsource(run)
529 assert "ref_file.read_text" not in src, (
530 "Direct ref_file.read_text() bypasses the get_head_commit_id "
531 "abstraction layer and is a TOCTOU vulnerability"
532 )
533 assert "get_head_commit_id" in src
534
535 def test_old_commit_id_correct_on_first_commit(self, repo: pathlib.Path) -> None:
536 """old_commit_id in JSON must match HEAD before the reset."""
537 head = get_head_commit_id(repo, "main")
538 result = _reset(repo, "HEAD~1", "--json")
539 data = json.loads(result.output)
540 assert data["old_commit_id"] == head
541
542
543 # ──────────────────────────────────────────────────────────────────────────────
544 # Stress
545 # ──────────────────────────────────────────────────────────────────────────────
546
547
548 @pytest.mark.slow
549 class TestStress:
550 def test_soft_reset_across_50_commits(self, repo: pathlib.Path) -> None:
551 """Soft-reset across 50 commits must complete under 5s."""
552 # Add 48 more commits (we already have 2)
553 for i in range(48):
554 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
555 _commit(repo, f"commit {i}")
556
557 from muse.core.store import read_commit
558
559 # Walk to the 10th commit from the end
560 current_id = get_head_commit_id(repo, "main") or ""
561 target_id = current_id
562 for _ in range(10):
563 c = read_commit(repo, target_id)
564 if c and c.parent_commit_id:
565 target_id = c.parent_commit_id
566
567 t0 = time.perf_counter()
568 result = _reset(repo, target_id)
569 elapsed = (time.perf_counter() - t0) * 1000
570 assert result.exit_code == 0
571 assert elapsed < 5000, f"50-commit soft reset took {elapsed:.0f}ms (limit 5s)"
572
573 def test_hard_reset_with_100_files(self, repo: pathlib.Path, c1_id: str) -> None:
574 """Hard-reset restoring 100 files must complete under 5s."""
575 for i in range(100):
576 (repo / f"g{i:03d}.py").write_text(f"y={i}\n")
577 _commit(repo, "add 100 files")
578 head_id = get_head_commit_id(repo, "main") or ""
579
580 # Reset back to c1 (removes 101 files) then back to head (restores them)
581 t0 = time.perf_counter()
582 r1 = _reset(repo, c1_id, "--hard")
583 r2 = _reset(repo, head_id, "--hard")
584 elapsed = (time.perf_counter() - t0) * 1000
585 assert r1.exit_code == 0
586 assert r2.exit_code == 0
587 assert elapsed < 5000, f"100-file hard reset cycle took {elapsed:.0f}ms"
588
589 def test_dry_run_50_commits_fast(self, repo: pathlib.Path) -> None:
590 """Dry-run across 50 commits must complete under 2s."""
591 for i in range(48):
592 (repo / f"h{i:03d}.py").write_text(f"z={i}\n")
593 _commit(repo, f"commit {i}")
594
595 t0 = time.perf_counter()
596 result = _reset(repo, "HEAD~1", "--dry-run")
597 elapsed = (time.perf_counter() - t0) * 1000
598 assert result.exit_code == 0
599 assert elapsed < 2000, f"dry-run took {elapsed:.0f}ms (limit 2s)"
600
601 def test_concurrent_resets_separate_repos(self, tmp_path: pathlib.Path) -> None:
602 """Multiple repos resetting concurrently must not interfere."""
603 errors: list[str] = []
604
605 def do_reset(idx: int) -> None:
606 repo_dir = tmp_path / f"repo_{idx}"
607 repo_dir.mkdir()
608 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
609 (repo_dir / "a.py").write_text(f"x={idx}\n")
610 subprocess.run(
611 ["muse", "commit", "-m", f"c1_{idx}"],
612 cwd=str(repo_dir), capture_output=True,
613 )
614 (repo_dir / "b.py").write_text(f"y={idx}\n")
615 subprocess.run(
616 ["muse", "commit", "-m", f"c2_{idx}"],
617 cwd=str(repo_dir), capture_output=True,
618 )
619 r = subprocess.run(
620 ["muse", "reset", "HEAD~1", "--json"],
621 cwd=str(repo_dir), capture_output=True, text=True,
622 )
623 if r.returncode != 0:
624 errors.append(f"repo_{idx}: exit={r.returncode}, err={r.stderr[:60]}")
625 return
626 try:
627 data = json.loads(r.stdout)
628 if data["mode"] != "soft":
629 errors.append(f"repo_{idx}: unexpected mode {data['mode']}")
630 if data["dry_run"] is not False:
631 errors.append(f"repo_{idx}: dry_run not False")
632 except Exception as e:
633 errors.append(f"repo_{idx}: parse error {e}")
634
635 threads = [threading.Thread(target=do_reset, args=(i,)) for i in range(6)]
636 for t in threads:
637 t.start()
638 for t in threads:
639 t.join()
640 assert not errors, "Concurrent reset errors:\n" + "\n".join(errors)
File History 1 commit
sha256:3788deb0ce8d6f235a23185f7f5cd65785ecdb41b0a7f2b0107adebf45c09221 update tests/test_cmd_reset_hardening.py and tests/test_cmd… Human 42 days ago