gabriel / muse public
test_cmd_revert_hardening.py python
816 lines 32.7 KB
Raw
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 42 days ago
1 """Comprehensive hardening tests for ``muse revert``.
2
3 Covers all changes introduced in the revert command review:
4
5 Unit
6 ----
7 - Parser flags: --dry-run, --force, --no-commit, --format, --json shorthand
8 - Dead-code removal: _read_branch absent, pathlib not imported
9 - All flags present and correctly typed in register()
10
11 Integration
12 -----------
13 - Error messages routed to stderr, stdout clean
14 - JSON schema identical and complete for all code paths
15 (normal, --no-commit, --dry-run)
16 - --dry-run performs no writes (branch ref, workdir, reflog unchanged)
17 - --no-commit applies workdir changes without advancing the branch ref
18 - Reflog entry appended after normal revert
19 - Write ordering: write_commit fires before apply_manifest in source
20 - validate_branch_name called in run()
21 - target.message sanitized before embedding in revert commit message
22 - ref sanitized in "not found" error
23
24 End-to-end
25 ----------
26 - Text output format
27 - JSON output format with full schema verification
28 - --force bypasses dirty-workdir guard
29
30 Security
31 --------
32 - ANSI escape codes in ref rejected / sanitized in error
33 - ANSI in original commit message not propagated to revert commit message
34 - --format with unknown value exits 1 and prints to stderr
35
36 Stress
37 ------
38 - Revert across a chain of 200 commits
39 - 50 sequential reverts in the same repo
40 - Concurrent reverts to isolated repos
41 """
42
43 from __future__ import annotations
44
45 import argparse
46 import inspect
47 import json
48 import pathlib
49 import subprocess
50 import threading
51 import time
52 import uuid
53
54 import pytest
55
56 from tests.cli_test_helper import CliRunner
57
58 cli = None # argparse migration — CliRunner ignores this arg
59 runner = CliRunner()
60
61
62 # ---------------------------------------------------------------------------
63 # Shared helpers
64 # ---------------------------------------------------------------------------
65
66 def _env(root: pathlib.Path) -> Manifest:
67 return {"MUSE_REPO_ROOT": str(root)}
68
69
70 @pytest.fixture()
71 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
72 """Minimal real muse repo with two commits: base + target."""
73 monkeypatch.chdir(tmp_path)
74 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
75 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
76 assert r.exit_code == 0, r.output
77 (tmp_path / "a.py").write_text("x = 1\n")
78 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
79 assert r.exit_code == 0, r.output
80 (tmp_path / "b.py").write_text("y = 2\n")
81 r = runner.invoke(cli, ["commit", "-m", "add b"], env=_env(tmp_path), catch_exceptions=False)
82 assert r.exit_code == 0, r.output
83 return tmp_path
84
85
86 def _head_id(repo: pathlib.Path) -> str | None:
87 from muse.core.store import get_head_commit_id
88 return get_head_commit_id(repo, "main")
89
90
91 def _ref_file(repo: pathlib.Path) -> pathlib.Path:
92 return repo / ".muse" / "refs" / "heads" / "main"
93
94
95 # ---------------------------------------------------------------------------
96 # Unit — parser flags and dead-code removal
97 # ---------------------------------------------------------------------------
98
99 class TestRegisterFlags:
100 """Parser registration emits all expected flags."""
101
102 @pytest.fixture(autouse=True)
103 def _ns(self) -> None:
104 import argparse
105 import muse.cli.commands.revert as m
106 p = argparse.ArgumentParser()
107 sub = p.add_subparsers()
108 m.register(sub)
109 self._sub = sub
110
111 def _parse(self, *args: str) -> argparse.Namespace:
112 import argparse
113 import muse.cli.commands.revert as m
114 p = argparse.ArgumentParser()
115 sub = p.add_subparsers()
116 m.register(sub)
117 return p.parse_args(["revert", *args])
118
119 def test_dry_run_flag(self) -> None:
120 import argparse
121 ns = self._parse("abc123", "--dry-run")
122 assert ns.dry_run is True
123
124 def test_dry_run_default_false(self) -> None:
125 import argparse
126 ns = self._parse("abc123")
127 assert ns.dry_run is False
128
129 def test_no_commit_short_flag(self) -> None:
130 import argparse
131 ns = self._parse("abc123", "-n")
132 assert ns.no_commit is True
133
134 def test_no_commit_long_flag(self) -> None:
135 import argparse
136 ns = self._parse("abc123", "--no-commit")
137 assert ns.no_commit is True
138
139 def test_force_flag(self) -> None:
140 import argparse
141 ns = self._parse("abc123", "--force")
142 assert ns.force is True
143
144 def test_format_json_shorthand(self) -> None:
145 import argparse
146 ns = self._parse("abc123", "--json")
147 assert ns.fmt == "json"
148
149 def test_format_explicit_text(self) -> None:
150 import argparse
151 ns = self._parse("abc123", "--format", "text")
152 assert ns.fmt == "text"
153
154 def test_message_short(self) -> None:
155 import argparse
156 ns = self._parse("abc123", "-m", "my message")
157 assert ns.message == "my message"
158
159 def test_ref_positional(self) -> None:
160 import argparse
161 ns = self._parse("deadbeef")
162 assert ns.ref == "deadbeef"
163
164
165 class TestDeadCodeRemoval:
166 def test_no_read_branch_wrapper(self) -> None:
167 import muse.cli.commands.revert as m
168 assert not hasattr(m, "_read_branch"), "_read_branch must be deleted"
169
170 def test_pathlib_not_imported(self) -> None:
171 import muse.cli.commands.revert as m
172 src = inspect.getsource(m)
173 assert "import pathlib" not in src, "pathlib was only used by _read_branch"
174
175 def test_validate_branch_name_called_in_run(self) -> None:
176 import muse.cli.commands.revert as m
177 src = inspect.getsource(m.run)
178 assert "validate_branch_name" in src
179
180 def test_write_commit_before_apply_manifest(self) -> None:
181 """Normal path must write_commit before apply_manifest and write_branch_ref."""
182 import muse.cli.commands.revert as m
183 # Filter out comment lines so we check executable ordering only.
184 src_lines = [
185 (i, l)
186 for i, l in enumerate(inspect.getsource(m.run).split("\n"), 1)
187 if l.strip() and not l.strip().startswith("#")
188 ]
189 write_commit_line = next(
190 i for i, l in src_lines if "write_commit(" in l
191 )
192 apply_manifest_lines = [i for i, l in src_lines if "apply_manifest(" in l]
193 write_branch_ref_line = next(
194 i for i, l in src_lines if "write_branch_ref(" in l
195 )
196 # There may be two apply_manifest calls (no_commit and normal path).
197 # The LAST apply_manifest must come after write_commit.
198 last_apply = max(apply_manifest_lines)
199 assert write_commit_line < last_apply, (
200 f"write_commit ({write_commit_line}) must precede apply_manifest ({last_apply})"
201 )
202 assert last_apply < write_branch_ref_line, (
203 f"apply_manifest ({last_apply}) must precede write_branch_ref ({write_branch_ref_line})"
204 )
205
206 def test_target_message_sanitized_in_run(self) -> None:
207 import muse.cli.commands.revert as m
208 src = inspect.getsource(m.run)
209 assert "sanitize_display(target.message" in src
210
211 def test_ref_sanitized_in_error(self) -> None:
212 import muse.cli.commands.revert as m
213 src = inspect.getsource(m.run)
214 assert "sanitize_display(ref)" in src
215
216
217 # ---------------------------------------------------------------------------
218 # Integration — error routing and behaviour
219 # ---------------------------------------------------------------------------
220
221 class TestErrorRouting:
222 def test_not_found_to_stderr(self, repo: pathlib.Path) -> None:
223 r = runner.invoke(cli, ["revert", "badref"], env=_env(repo))
224 assert r.exit_code != 0
225 # Error message must be in stderr; stdout should be clean.
226 assert "not found" in (r.stderr or "").lower()
227 assert "badref" in (r.stderr or "")
228
229 def test_root_commit_error_to_stderr(self, repo: pathlib.Path) -> None:
230 from muse.core.store import get_all_commits
231 commits = get_all_commits(repo)
232 root = min(commits, key=lambda c: c.committed_at)
233 r = runner.invoke(cli, ["revert", root.commit_id], env=_env(repo))
234 assert r.exit_code != 0
235 assert "root" in (r.stderr or "").lower() or "parent" in (r.stderr or "").lower()
236
237 def test_bad_format_to_stderr(self, repo: pathlib.Path) -> None:
238 r = runner.invoke(cli, ["revert", "--format", "xml", "HEAD"], env=_env(repo))
239 assert r.exit_code == 1
240 assert "xml" in (r.stderr or "").lower()
241
242 def test_unknown_ref_in_stderr(self, repo: pathlib.Path) -> None:
243 r = runner.invoke(cli, ["revert", "0000000000000000"], env=_env(repo))
244 assert r.exit_code != 0
245 assert "not found" in (r.stderr or "").lower()
246
247 def test_root_commit_in_stderr(self, repo: pathlib.Path) -> None:
248 from muse.core.store import get_all_commits
249 commits = get_all_commits(repo)
250 root = min(commits, key=lambda c: c.committed_at)
251 r = runner.invoke(cli, ["revert", root.commit_id], env=_env(repo))
252 assert r.exit_code != 0
253 assert "root" in (r.stderr or "").lower() or "parent" in (r.stderr or "").lower()
254
255
256 class TestJsonSchema:
257 """JSON schema must be identical across all code paths."""
258
259 _REQUIRED_KEYS = {
260 "status", "commit_id", "branch", "ref",
261 "reverted_commit_id", "snapshot_id", "message",
262 "no_commit", "dry_run",
263 }
264
265 def _head_commit_id(self, repo: pathlib.Path) -> str:
266 from muse.core.store import get_head_commit_id
267 cid = get_head_commit_id(repo, "main")
268 assert cid is not None
269 return cid
270
271 def test_normal_json_schema_complete(self, repo: pathlib.Path) -> None:
272 cid = self._head_commit_id(repo)
273 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
274 assert r.exit_code == 0, r.output
275 d = json.loads(r.output)
276 assert self._REQUIRED_KEYS <= d.keys()
277
278 def test_normal_status_is_reverted(self, repo: pathlib.Path) -> None:
279 cid = self._head_commit_id(repo)
280 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
281 assert r.exit_code == 0, r.output
282 d = json.loads(r.output)
283 assert d["status"] == "reverted"
284 assert d["no_commit"] is False
285 assert d["dry_run"] is False
286
287 def test_normal_commit_id_is_string(self, repo: pathlib.Path) -> None:
288 cid = self._head_commit_id(repo)
289 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
290 d = json.loads(r.output)
291 assert isinstance(d["commit_id"], str) and len(d["commit_id"]) == 64
292
293 def test_normal_snapshot_id_present(self, repo: pathlib.Path) -> None:
294 cid = self._head_commit_id(repo)
295 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
296 d = json.loads(r.output)
297 assert isinstance(d["snapshot_id"], str) and len(d["snapshot_id"]) == 64
298
299 def test_normal_ref_field_matches_input(self, repo: pathlib.Path) -> None:
300 cid = self._head_commit_id(repo)
301 r = runner.invoke(cli, ["revert", cid[:12], "--json"], env=_env(repo), catch_exceptions=False)
302 d = json.loads(r.output)
303 assert d["ref"] == cid[:12]
304
305 def test_no_commit_json_schema_complete(self, repo: pathlib.Path) -> None:
306 cid = self._head_commit_id(repo)
307 r = runner.invoke(
308 cli, ["revert", cid, "--no-commit", "--json"],
309 env=_env(repo), catch_exceptions=False,
310 )
311 assert r.exit_code == 0, r.output
312 d = json.loads(r.output)
313 assert self._REQUIRED_KEYS <= d.keys()
314
315 def test_no_commit_status_is_applied(self, repo: pathlib.Path) -> None:
316 cid = self._head_commit_id(repo)
317 r = runner.invoke(
318 cli, ["revert", cid, "--no-commit", "--json"],
319 env=_env(repo), catch_exceptions=False,
320 )
321 d = json.loads(r.output)
322 assert d["status"] == "applied"
323 assert d["commit_id"] is None
324 assert d["no_commit"] is True
325 assert d["dry_run"] is False
326
327 def test_no_commit_and_normal_schemas_identical(self, repo: pathlib.Path) -> None:
328 """Both paths must emit the same set of keys."""
329 from muse.core.store import get_head_commit_id
330 # First get the commit ID
331 cid = get_head_commit_id(repo, "main")
332 assert cid is not None
333 r1 = runner.invoke(
334 cli, ["revert", cid, "--no-commit", "--json"],
335 env=_env(repo), catch_exceptions=False,
336 )
337 d1 = json.loads(r1.output)
338
339 # Now normal revert (the --no-commit left workdir in a different state,
340 # so make a fresh commit to have something to revert)
341 r2 = runner.invoke(cli, ["commit", "-m", "after no-commit"], env=_env(repo), catch_exceptions=False)
342 cid2 = get_head_commit_id(repo, "main")
343 assert cid2 is not None
344 r3 = runner.invoke(
345 cli, ["revert", cid2, "--json"],
346 env=_env(repo), catch_exceptions=False,
347 )
348 d3 = json.loads(r3.output)
349 assert set(d1.keys()) == set(d3.keys())
350
351 def test_dry_run_json_schema_complete(self, repo: pathlib.Path) -> None:
352 cid = self._head_commit_id(repo)
353 r = runner.invoke(
354 cli, ["revert", cid, "--dry-run", "--json"],
355 env=_env(repo), catch_exceptions=False,
356 )
357 assert r.exit_code == 0, r.output
358 d = json.loads(r.output)
359 assert self._REQUIRED_KEYS <= d.keys()
360
361 def test_dry_run_status(self, repo: pathlib.Path) -> None:
362 cid = self._head_commit_id(repo)
363 r = runner.invoke(
364 cli, ["revert", cid, "--dry-run", "--json"],
365 env=_env(repo), catch_exceptions=False,
366 )
367 d = json.loads(r.output)
368 assert d["dry_run"] is True
369 assert d["commit_id"] is None
370 assert d["status"] == "reverted"
371
372 def test_all_three_schemas_identical(self, repo: pathlib.Path) -> None:
373 """Normal, --no-commit, and --dry-run must produce identical key sets."""
374 from muse.core.store import get_head_commit_id
375 cid = get_head_commit_id(repo, "main")
376 assert cid is not None
377
378 r_dr = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
379 r_nc = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
380
381 # For normal revert, make fresh commit so workdir is clean
382 runner.invoke(cli, ["commit", "-m", "fresh"], env=_env(repo), catch_exceptions=False)
383 cid2 = get_head_commit_id(repo, "main")
384 assert cid2 is not None
385 r_nm = runner.invoke(cli, ["revert", cid2, "--json"], env=_env(repo), catch_exceptions=False)
386
387 keys_dr = set(json.loads(r_dr.output).keys())
388 keys_nc = set(json.loads(r_nc.output).keys())
389 keys_nm = set(json.loads(r_nm.output).keys())
390 assert keys_dr == keys_nc == keys_nm, f"Schema mismatch: dr={keys_dr} nc={keys_nc} nm={keys_nm}"
391
392
393 class TestDryRun:
394 def test_no_commit_created_on_dry_run(self, repo: pathlib.Path) -> None:
395 from muse.core.store import get_all_commits, get_head_commit_id
396 before_count = len(get_all_commits(repo))
397 before_head = get_head_commit_id(repo, "main")
398 cid = get_head_commit_id(repo, "main")
399 assert cid is not None
400 r = runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
401 assert r.exit_code == 0, r.output
402 assert len(get_all_commits(repo)) == before_count
403 assert get_head_commit_id(repo, "main") == before_head
404
405 def test_workdir_unchanged_on_dry_run(self, repo: pathlib.Path) -> None:
406 b_py = (repo / "b.py")
407 content_before = b_py.read_text()
408 cid = _head_id(repo)
409 assert cid is not None
410 runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
411 assert b_py.read_text() == content_before
412
413 def test_reflog_unchanged_on_dry_run(self, repo: pathlib.Path) -> None:
414 from muse.core.reflog import read_reflog
415 before = len(read_reflog(repo, "main"))
416 cid = _head_id(repo)
417 assert cid is not None
418 runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
419 assert len(read_reflog(repo, "main")) == before
420
421 def test_dry_run_text_output_says_would(self, repo: pathlib.Path) -> None:
422 cid = _head_id(repo)
423 assert cid is not None
424 r = runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
425 assert "dry-run" in r.output.lower() or "would" in r.output.lower()
426
427 def test_dry_run_invalid_ref_still_errors(self, repo: pathlib.Path) -> None:
428 r = runner.invoke(cli, ["revert", "no-such-ref", "--dry-run"], env=_env(repo))
429 assert r.exit_code != 0
430
431
432 class TestNoCommit:
433 def test_branch_ref_not_advanced(self, repo: pathlib.Path) -> None:
434 from muse.core.store import get_head_commit_id
435 cid = get_head_commit_id(repo, "main")
436 assert cid is not None
437 r = runner.invoke(
438 cli, ["revert", cid, "--no-commit"],
439 env=_env(repo), catch_exceptions=False,
440 )
441 assert r.exit_code == 0, r.output
442 assert get_head_commit_id(repo, "main") == cid
443
444 def test_workdir_is_modified(self, repo: pathlib.Path) -> None:
445 """--no-commit must apply the parent snapshot to the workdir."""
446 cid = _head_id(repo)
447 assert cid is not None
448 # b.py was added by the second commit; reverting it should remove b.py
449 r = runner.invoke(
450 cli, ["revert", cid, "--no-commit"],
451 env=_env(repo), catch_exceptions=False,
452 )
453 assert r.exit_code == 0, r.output
454 assert not (repo / "b.py").exists(), "b.py should be gone after reverting the commit that added it"
455
456 def test_no_commit_in_json_output(self, repo: pathlib.Path) -> None:
457 cid = _head_id(repo)
458 assert cid is not None
459 r = runner.invoke(
460 cli, ["revert", cid, "--no-commit", "--json"],
461 env=_env(repo), catch_exceptions=False,
462 )
463 d = json.loads(r.output)
464 assert d["no_commit"] is True
465 assert d["commit_id"] is None
466
467 def test_reflog_not_written_for_no_commit(self, repo: pathlib.Path) -> None:
468 from muse.core.reflog import read_reflog
469 before = len(read_reflog(repo, "main"))
470 cid = _head_id(repo)
471 assert cid is not None
472 runner.invoke(cli, ["revert", cid, "--no-commit"], env=_env(repo), catch_exceptions=False)
473 assert len(read_reflog(repo, "main")) == before
474
475
476 class TestReflog:
477 def test_reflog_entry_appended_after_revert(self, repo: pathlib.Path) -> None:
478 from muse.core.reflog import read_reflog
479 before = len(read_reflog(repo, "main"))
480 cid = _head_id(repo)
481 assert cid is not None
482 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
483 after = len(read_reflog(repo, "main"))
484 assert after > before, "revert must append a reflog entry"
485
486 def test_reflog_operation_contains_revert(self, repo: pathlib.Path) -> None:
487 from muse.core.reflog import read_reflog
488 cid = _head_id(repo)
489 assert cid is not None
490 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
491 entries = read_reflog(repo, "main")
492 # read_reflog returns newest-first; entries[0] is the most recent.
493 newest = entries[0]
494 assert "revert" in newest.operation.lower()
495
496
497 class TestWriteOrdering:
498 def test_new_commit_exists_before_branch_pointer_advances(
499 self, repo: pathlib.Path
500 ) -> None:
501 """
502 Intercept write_commit at the module level inside revert.py to verify
503 the commit is durably stored before write_branch_ref fires.
504 """
505 from unittest.mock import patch
506 import muse.cli.commands.revert as revert_mod
507 from muse.core import store as s
508 written: list[str] = []
509 orig_write_commit = s.write_commit
510
511 def tracking_write_commit(root: pathlib.Path, rec: s.CommitRecord) -> None:
512 orig_write_commit(root, rec)
513 written.append(rec.commit_id)
514
515 cid = _head_id(repo)
516 assert cid is not None
517
518 # Patch at the revert module level — that's where the imported name lives.
519 with patch.object(revert_mod, "write_commit", tracking_write_commit):
520 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
521
522 assert written, "write_commit must have been called"
523 from muse.core.store import read_commit as _rc
524 rec = _rc(repo, written[0])
525 assert rec is not None, "Commit object must be readable after write_commit"
526
527
528 # ---------------------------------------------------------------------------
529 # End-to-end — text and JSON output
530 # ---------------------------------------------------------------------------
531
532 class TestTextOutput:
533 def test_output_shows_branch_and_short_id(self, repo: pathlib.Path) -> None:
534 cid = _head_id(repo)
535 assert cid is not None
536 r = runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
537 assert r.exit_code == 0
538 assert "main" in r.output
539 assert len(r.output.strip()) > 0
540
541 def test_custom_message_in_output(self, repo: pathlib.Path) -> None:
542 cid = _head_id(repo)
543 assert cid is not None
544 r = runner.invoke(
545 cli, ["revert", cid, "-m", "undo b"],
546 env=_env(repo), catch_exceptions=False,
547 )
548 assert "undo b" in r.output
549
550 def test_default_message_includes_original(self, repo: pathlib.Path) -> None:
551 cid = _head_id(repo)
552 assert cid is not None
553 r = runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
554 # Default message is Revert "add b"
555 assert "add b" in r.output
556
557 def test_no_commit_output_mentions_workdir(self, repo: pathlib.Path) -> None:
558 cid = _head_id(repo)
559 assert cid is not None
560 r = runner.invoke(
561 cli, ["revert", cid, "--no-commit"],
562 env=_env(repo), catch_exceptions=False,
563 )
564 output = r.output.lower()
565 assert "working tree" in output or "applied" in output or "commit" in output
566
567
568 class TestJsonOutput:
569 def test_reverted_commit_id_matches_input(self, repo: pathlib.Path) -> None:
570 cid = _head_id(repo)
571 assert cid is not None
572 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
573 d = json.loads(r.output)
574 assert d["reverted_commit_id"] == cid
575
576 def test_branch_field_is_main(self, repo: pathlib.Path) -> None:
577 cid = _head_id(repo)
578 assert cid is not None
579 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
580 d = json.loads(r.output)
581 assert d["branch"] == "main"
582
583 def test_message_is_default_revert(self, repo: pathlib.Path) -> None:
584 cid = _head_id(repo)
585 assert cid is not None
586 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
587 d = json.loads(r.output)
588 assert d["message"].startswith('Revert "')
589
590 def test_message_override_reflected(self, repo: pathlib.Path) -> None:
591 cid = _head_id(repo)
592 assert cid is not None
593 r = runner.invoke(
594 cli, ["revert", cid, "--json", "-m", "custom undo"],
595 env=_env(repo), catch_exceptions=False,
596 )
597 d = json.loads(r.output)
598 assert d["message"] == "custom undo"
599
600 def test_snapshot_id_matches_parent(self, repo: pathlib.Path) -> None:
601 from muse.core.store import read_commit
602 cid = _head_id(repo)
603 assert cid is not None
604 target = read_commit(repo, cid)
605 assert target is not None
606 parent_cid = target.parent_commit_id
607 assert parent_cid is not None
608 parent = read_commit(repo, parent_cid)
609 assert parent is not None
610
611 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
612 d = json.loads(r.output)
613 assert d["snapshot_id"] == parent.snapshot_id
614
615
616 class TestForce:
617 def test_force_bypasses_dirty_check(self, repo: pathlib.Path) -> None:
618 """--force must allow revert even when working tree is dirty."""
619 # Modify a TRACKED file without committing to make the tree dirty.
620 (repo / "a.py").write_text("modified but not committed\n")
621 cid = _head_id(repo)
622 assert cid is not None
623 r = runner.invoke(
624 cli, ["revert", cid, "--force"],
625 env=_env(repo), catch_exceptions=False,
626 )
627 assert r.exit_code == 0, r.output
628
629 def test_without_force_dirty_tree_fails(self, repo: pathlib.Path) -> None:
630 """Without --force, a dirty working tree (tracked file modified) must block the revert."""
631 # Modify a TRACKED file without committing to create a dirty state.
632 (repo / "a.py").write_text("modified but not committed\n")
633 cid = _head_id(repo)
634 assert cid is not None
635 r = runner.invoke(cli, ["revert", cid], env=_env(repo))
636 assert r.exit_code != 0
637
638
639 # ---------------------------------------------------------------------------
640 # Security — ANSI injection and sanitization
641 # ---------------------------------------------------------------------------
642
643 class TestSecurity:
644 def test_ansi_in_ref_not_in_stdout(self, repo: pathlib.Path) -> None:
645 ansi_ref = "\x1b[31mbadref\x1b[0m"
646 r = runner.invoke(cli, ["revert", ansi_ref], env=_env(repo))
647 assert r.exit_code != 0
648 # ANSI should not be forwarded verbatim in any output
649 assert "\x1b[31m" not in (r.stdout or "")
650
651 def test_ansi_in_ref_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
652 ansi_ref = "\x1b[31mbadref\x1b[0m"
653 r = runner.invoke(cli, ["revert", ansi_ref], env=_env(repo))
654 assert r.exit_code != 0
655 # The sanitized ref should appear (stripped of ANSI) in the error
656 assert "badref" in (r.stderr or "")
657
658 def test_ansi_in_commit_message_not_in_revert_commit(
659 self, repo: pathlib.Path
660 ) -> None:
661 """If the original commit message has ANSI codes, the revert commit
662 message stored on disk must not contain raw escape sequences."""
663 from muse.core.store import read_commit, get_head_commit_id
664 cid = get_head_commit_id(repo, "main")
665 assert cid is not None
666 orig = read_commit(repo, cid)
667 assert orig is not None
668
669 # Manually inject ANSI into the original commit message field on disk.
670 # We do this by patching read_commit so target.message has ANSI codes.
671 from unittest.mock import patch
672 from muse.core import store as s
673 original_read_commit = s.read_commit
674
675 def poisoned_read_commit(root: pathlib.Path, cid: str) -> s.CommitRecord | None:
676 rec = original_read_commit(root, cid)
677 if rec is not None and rec.commit_id == cid:
678 return s.CommitRecord(
679 commit_id=rec.commit_id,
680 repo_id=rec.repo_id,
681 branch=rec.branch,
682 snapshot_id=rec.snapshot_id,
683 message="\x1b[31mmalicious\x1b[0m",
684 committed_at=rec.committed_at,
685 parent_commit_id=rec.parent_commit_id,
686 )
687 return rec
688
689 with patch.object(s, "read_commit", poisoned_read_commit):
690 r = runner.invoke(
691 cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False
692 )
693
694 if r.exit_code == 0:
695 d = json.loads(r.output)
696 assert "\x1b[" not in d.get("message", ""), (
697 "Revert commit message must not contain raw ANSI from original message"
698 )
699
700 def test_invalid_format_exits_1_to_stderr(self, repo: pathlib.Path) -> None:
701 r = runner.invoke(cli, ["revert", "--format", "html", "HEAD"], env=_env(repo))
702 assert r.exit_code == 1
703 assert "html" in (r.stderr or "").lower()
704
705 def test_invalid_format_error_in_stderr(self, repo: pathlib.Path) -> None:
706 r = runner.invoke(cli, ["revert", "--format", "html", "HEAD"], env=_env(repo))
707 assert r.exit_code == 1
708 assert "Unknown" in (r.stderr or "") or "format" in (r.stderr or "").lower()
709
710
711 # ---------------------------------------------------------------------------
712 # Stress
713 # ---------------------------------------------------------------------------
714
715 class TestStress:
716 @pytest.mark.slow
717 def test_revert_in_long_chain(self, tmp_path: pathlib.Path) -> None:
718 """Revert a commit deep in a 200-commit chain."""
719 env = _env(tmp_path)
720 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
721 (tmp_path / "seed.py").write_text("seed\n")
722 runner.invoke(cli, ["commit", "-m", "seed"], env=env, catch_exceptions=False)
723
724 # Commit 1 is what we will later revert
725 (tmp_path / "target.py").write_text("target content\n")
726 r1 = runner.invoke(cli, ["commit", "-m", "target commit"], env=env, catch_exceptions=False)
727 assert r1.exit_code == 0
728 target_cid = _head_id(tmp_path)
729 assert target_cid is not None
730
731 # Add 198 more commits
732 for i in range(198):
733 (tmp_path / f"f{i}.py").write_text(f"data {i}\n")
734 runner.invoke(cli, ["commit", "-m", f"c{i}"], env=env, catch_exceptions=False)
735
736 r = runner.invoke(
737 cli, ["revert", target_cid, "--json"], env=env, catch_exceptions=False
738 )
739 assert r.exit_code == 0
740 d = json.loads(r.output)
741 assert d["reverted_commit_id"] == target_cid
742 assert d["status"] == "reverted"
743
744 @pytest.mark.slow
745 def test_sequential_reverts(self, tmp_path: pathlib.Path) -> None:
746 """50 sequential reverts on the same repo must all succeed."""
747 env = _env(tmp_path)
748 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
749 (tmp_path / "base.py").write_text("base\n")
750 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
751
752 failures: list[str] = []
753 for i in range(50):
754 (tmp_path / f"w{i}.py").write_text(f"{i}\n")
755 r_c = runner.invoke(cli, ["commit", "-m", f"add w{i}"], env=env)
756 if r_c.exit_code != 0:
757 failures.append(f"commit {i}: {r_c.output.strip()[:60]}")
758 continue
759 cid = _head_id(tmp_path)
760 assert cid is not None
761 r_r = runner.invoke(cli, ["revert", cid, "--json"], env=env)
762 if r_r.exit_code != 0:
763 failures.append(f"revert {i}: exit={r_r.exit_code} {r_r.output.strip()[:60]}")
764
765 assert not failures, f"Failures: {failures}"
766
767 @pytest.mark.slow
768 def test_concurrent_reverts_isolated_repos(self, tmp_path: pathlib.Path) -> None:
769 """Parallel reverts to distinct repos must not interfere."""
770 errors: list[str] = []
771
772 def do_revert(idx: int) -> None:
773 repo_dir = tmp_path / f"repo_{idx}"
774 repo_dir.mkdir()
775 env = {"MUSE_REPO_ROOT": str(repo_dir)}
776 runner.invoke(cli, ["init"], env=env)
777 (repo_dir / "a.py").write_text("a\n")
778 runner.invoke(cli, ["commit", "-m", "base"], env=env)
779 (repo_dir / "b.py").write_text("b\n")
780 runner.invoke(cli, ["commit", "-m", "add b"], env=env)
781 cid = _head_id(repo_dir)
782 if not cid:
783 errors.append(f"repo {idx}: no HEAD")
784 return
785 r = runner.invoke(cli, ["revert", cid, "--json"], env=env)
786 if r.exit_code != 0:
787 errors.append(f"repo {idx}: exit={r.exit_code} {r.output.strip()[:80]}")
788
789 threads = [threading.Thread(target=do_revert, args=(i,)) for i in range(8)]
790 for t in threads:
791 t.start()
792 for t in threads:
793 t.join()
794
795 assert not errors, f"Concurrent revert errors: {errors}"
796
797 @pytest.mark.slow
798 def test_dry_run_performance(self, tmp_path: pathlib.Path) -> None:
799 """--dry-run on a large repo must complete in < 3 s."""
800 env = _env(tmp_path)
801 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
802 (tmp_path / "base.py").write_text("base\n")
803 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
804 for i in range(100):
805 (tmp_path / f"f{i}.py").write_text(f"data {i}\n")
806 runner.invoke(cli, ["commit", "-m", f"c{i}"], env=env, catch_exceptions=False)
807
808 cid = _head_id(tmp_path)
809 assert cid is not None
810 start = time.perf_counter()
811 r = runner.invoke(
812 cli, ["revert", cid, "--dry-run", "--json"], env=env, catch_exceptions=False
813 )
814 elapsed = time.perf_counter() - start
815 assert r.exit_code == 0, r.output
816 assert elapsed < 3.0, f"--dry-run took {elapsed:.2f}s on 100-commit repo"
File History 1 commit
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 42 days ago