gabriel / muse public
test_cmd_revert_hardening.py python
1,039 lines 44.2 KB
Raw
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 14 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, --json/-j
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 Agent-UX (supercharge additions)
25 ---------------------------------
26 - duration_ms present in all JSON responses (success and error)
27 - exit_code present in all JSON responses (success and error)
28 - files_added / files_modified / files_removed in all success JSON
29 - Correct file-level diff for added, modified, deleted file reverts
30 - --no-commit stages changes so muse commit picks them up
31 - Reverting to an empty snapshot (no parent files) works without crash
32 - HEAD ref resolves correctly
33 - Data integrity: file content verified after revert
34
35 End-to-end
36 ----------
37 - Text output format
38 - JSON output format with full schema verification
39 - --force bypasses dirty-workdir guard
40
41 Security
42 --------
43 - ANSI escape codes in ref rejected / sanitized in error
44 - ANSI in original commit message not propagated to revert commit message
45 - Unknown flags exit non-zero
46
47 Stress
48 ------
49 - Revert across a chain of 200 commits
50 - 50 sequential reverts in the same repo
51 - Concurrent reverts to isolated repos
52 """
53
54 from __future__ import annotations
55 from collections.abc import Mapping
56
57 import argparse
58 import inspect
59 import json
60 import pathlib
61 import subprocess
62 import time
63
64 import pytest
65
66 from tests.cli_test_helper import CliRunner
67 from muse.core.types import short_id
68 from muse.core.paths import heads_dir
69
70 cli = None # argparse migration — CliRunner ignores this arg
71 runner = CliRunner()
72
73
74 # ---------------------------------------------------------------------------
75 # Shared helpers
76 # ---------------------------------------------------------------------------
77
78 def _env(root: pathlib.Path) -> Mapping[str, str]:
79 return {"MUSE_REPO_ROOT": str(root)}
80
81
82 @pytest.fixture()
83 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
84 """Minimal real muse repo with two commits: base + target."""
85 monkeypatch.chdir(tmp_path)
86 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
87 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
88 assert r.exit_code == 0, r.output
89 (tmp_path / "a.py").write_text("x = 1\n")
90 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
91 assert r.exit_code == 0, r.output
92 (tmp_path / "b.py").write_text("y = 2\n")
93 r = runner.invoke(cli, ["commit", "-m", "add b"], env=_env(tmp_path), catch_exceptions=False)
94 assert r.exit_code == 0, r.output
95 return tmp_path
96
97
98 def _head_id(repo: pathlib.Path) -> str | None:
99 from muse.core.refs import get_head_commit_id
100 return get_head_commit_id(repo, "main")
101
102
103 def _ref_file(repo: pathlib.Path) -> pathlib.Path:
104 return heads_dir(repo) / "main"
105
106
107 # ---------------------------------------------------------------------------
108 # Unit — parser flags and dead-code removal
109 # ---------------------------------------------------------------------------
110
111 class TestRegisterFlags:
112 """Parser registration emits all expected flags."""
113
114 @pytest.fixture(autouse=True)
115 def _ns(self) -> None:
116 import argparse
117 import muse.cli.commands.revert as m
118 p = argparse.ArgumentParser()
119 sub = p.add_subparsers()
120 m.register(sub)
121 self._sub = sub
122
123 def _parse(self, *args: str) -> argparse.Namespace:
124 import argparse
125 import muse.cli.commands.revert as m
126 p = argparse.ArgumentParser()
127 sub = p.add_subparsers()
128 m.register(sub)
129 return p.parse_args(["revert", *args])
130
131 def test_dry_run_flag(self) -> None:
132 import argparse
133 ns = self._parse("abc123", "--dry-run")
134 assert ns.dry_run is True
135
136 def test_dry_run_default_false(self) -> None:
137 import argparse
138 ns = self._parse("abc123")
139 assert ns.dry_run is False
140
141 def test_dry_run_short_flag(self) -> None:
142 import argparse
143 ns = self._parse("abc123", "-n")
144 assert ns.dry_run is True
145
146 def test_no_commit_long_flag(self) -> None:
147 import argparse
148 ns = self._parse("abc123", "--no-commit")
149 assert ns.no_commit is True
150
151 def test_force_flag(self) -> None:
152 import argparse
153 ns = self._parse("abc123", "--force")
154 assert ns.force is True
155
156 def test_json_flag_sets_json_out(self) -> None:
157 ns = self._parse("abc123", "--json")
158 assert ns.json_out is True
159
160 def test_j_shorthand_sets_json_out(self) -> None:
161 ns = self._parse("abc123", "-j")
162 assert ns.json_out is True
163
164 def test_default_json_out_is_false(self) -> None:
165 ns = self._parse("abc123")
166 assert ns.json_out is False
167
168 def test_message_short(self) -> None:
169 import argparse
170 ns = self._parse("abc123", "-m", "my message")
171 assert ns.message == "my message"
172
173 def test_ref_positional(self) -> None:
174 import argparse
175 ns = self._parse("deadbeef")
176 assert ns.ref == "deadbeef"
177
178
179 class TestDeadCodeRemoval:
180 def test_no_read_branch_wrapper(self) -> None:
181 import muse.cli.commands.revert as m
182 assert not hasattr(m, "_read_branch"), "_read_branch must be deleted"
183
184 def test_pathlib_used_for_path_annotations(self) -> None:
185 import muse.cli.commands.revert as m
186 src = inspect.getsource(m)
187 assert "pathlib.Path" in src
188
189 def test_validate_branch_name_called_in_run(self) -> None:
190 import muse.cli.commands.revert as m
191 src = inspect.getsource(m.run)
192 assert "validate_branch_name" in src
193
194 def test_write_commit_before_apply_manifest(self) -> None:
195 """Normal path must write_commit before _apply_manifest_safe and write_branch_ref."""
196 import muse.cli.commands.revert as m
197 # Filter out comment lines so we check executable ordering only.
198 src_lines = [
199 (i, l)
200 for i, l in enumerate(inspect.getsource(m.run).split("\n"), 1)
201 if l.strip() and not l.strip().startswith("#")
202 ]
203 write_commit_line = next(
204 i for i, l in src_lines if "write_commit(" in l
205 )
206 apply_manifest_lines = [i for i, l in src_lines if "_apply_manifest_safe(" in l]
207 write_branch_ref_line = next(
208 i for i, l in src_lines if "write_branch_ref(" in l
209 )
210 # There may be two _apply_manifest_safe calls (no_commit and normal path).
211 # The LAST _apply_manifest_safe must come after write_commit.
212 last_apply = max(apply_manifest_lines)
213 assert write_commit_line < last_apply, (
214 f"write_commit ({write_commit_line}) must precede _apply_manifest_safe ({last_apply})"
215 )
216 assert last_apply < write_branch_ref_line, (
217 f"_apply_manifest_safe ({last_apply}) must precede write_branch_ref ({write_branch_ref_line})"
218 )
219
220 def test_target_message_sanitized_in_run(self) -> None:
221 import muse.cli.commands.revert as m
222 src = inspect.getsource(m.run)
223 assert "sanitize_display(target.message" in src
224
225 def test_ref_sanitized_in_error(self) -> None:
226 import muse.cli.commands.revert as m
227 src = inspect.getsource(m.run)
228 assert "sanitize_display(ref)" in src
229
230
231 # ---------------------------------------------------------------------------
232 # Integration — error routing and behaviour
233 # ---------------------------------------------------------------------------
234
235 class TestErrorRouting:
236 def test_not_found_to_stderr(self, repo: pathlib.Path) -> None:
237 r = runner.invoke(cli, ["revert", "badref"], env=_env(repo))
238 assert r.exit_code != 0
239 # Error message must be in stderr; stdout should be clean.
240 assert "not found" in (r.stderr or "").lower()
241 assert "badref" in (r.stderr or "")
242
243 def test_root_commit_error_to_stderr(self, repo: pathlib.Path) -> None:
244 from muse.core.commits import get_all_commits
245 commits = get_all_commits(repo)
246 root = min(commits, key=lambda c: c.committed_at)
247 r = runner.invoke(cli, ["revert", root.commit_id], env=_env(repo))
248 assert r.exit_code != 0
249 assert "root" in (r.stderr or "").lower() or "parent" in (r.stderr or "").lower()
250
251 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
252 r = runner.invoke(cli, ["revert", "--format", "xml", "HEAD"], env=_env(repo))
253 assert r.exit_code != 0
254
255 def test_unknown_ref_in_stderr(self, repo: pathlib.Path) -> None:
256 r = runner.invoke(cli, ["revert", "0000000000000000"], env=_env(repo))
257 assert r.exit_code != 0
258 assert "not found" in (r.stderr or "").lower()
259
260 def test_root_commit_in_stderr(self, repo: pathlib.Path) -> None:
261 from muse.core.commits import get_all_commits
262 commits = get_all_commits(repo)
263 root = min(commits, key=lambda c: c.committed_at)
264 r = runner.invoke(cli, ["revert", root.commit_id], env=_env(repo))
265 assert r.exit_code != 0
266 assert "root" in (r.stderr or "").lower() or "parent" in (r.stderr or "").lower()
267
268
269 class TestJsonSchema:
270 """JSON schema must be identical across all code paths."""
271
272 _REQUIRED_KEYS = {
273 "status", "commit_id", "branch", "ref",
274 "reverted_commit_id", "snapshot_id", "message",
275 "no_commit", "dry_run",
276 }
277
278 def _head_commit_id(self, repo: pathlib.Path) -> str:
279 from muse.core.refs import get_head_commit_id
280 cid = get_head_commit_id(repo, "main")
281 assert cid is not None
282 return cid
283
284 def test_normal_json_schema_complete(self, repo: pathlib.Path) -> None:
285 cid = self._head_commit_id(repo)
286 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
287 assert r.exit_code == 0, r.output
288 d = json.loads(r.output)
289 assert self._REQUIRED_KEYS <= d.keys()
290
291 def test_normal_status_is_reverted(self, repo: pathlib.Path) -> None:
292 cid = self._head_commit_id(repo)
293 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
294 assert r.exit_code == 0, r.output
295 d = json.loads(r.output)
296 assert d["status"] == "reverted"
297 assert d["no_commit"] is False
298 assert d["dry_run"] is False
299
300 def test_normal_commit_id_is_string(self, repo: pathlib.Path) -> None:
301 cid = self._head_commit_id(repo)
302 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
303 d = json.loads(r.output)
304 assert isinstance(d["commit_id"], str)
305 assert d["commit_id"].startswith("sha256:")
306
307 def test_normal_snapshot_id_present(self, repo: pathlib.Path) -> None:
308 cid = self._head_commit_id(repo)
309 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
310 d = json.loads(r.output)
311 assert isinstance(d["snapshot_id"], str)
312 assert d["snapshot_id"].startswith("sha256:")
313
314 def test_normal_ref_field_matches_input(self, repo: pathlib.Path) -> None:
315 cid = self._head_commit_id(repo)
316 r = runner.invoke(cli, ["revert", short_id(cid), "--json"], env=_env(repo), catch_exceptions=False)
317 d = json.loads(r.output)
318 assert d["ref"] == short_id(cid)
319
320 def test_no_commit_json_schema_complete(self, repo: pathlib.Path) -> None:
321 cid = self._head_commit_id(repo)
322 r = runner.invoke(
323 cli, ["revert", cid, "--no-commit", "--json"],
324 env=_env(repo), catch_exceptions=False,
325 )
326 assert r.exit_code == 0, r.output
327 d = json.loads(r.output)
328 assert self._REQUIRED_KEYS <= d.keys()
329
330 def test_no_commit_status_is_applied(self, repo: pathlib.Path) -> None:
331 cid = self._head_commit_id(repo)
332 r = runner.invoke(
333 cli, ["revert", cid, "--no-commit", "--json"],
334 env=_env(repo), catch_exceptions=False,
335 )
336 d = json.loads(r.output)
337 assert d["status"] == "applied"
338 assert d["commit_id"] is None
339 assert d["no_commit"] is True
340 assert d["dry_run"] is False
341
342 def test_no_commit_and_normal_schemas_identical(self, repo: pathlib.Path) -> None:
343 """Both paths must emit the same set of keys."""
344 from muse.core.refs import get_head_commit_id
345 # First get the commit ID
346 cid = get_head_commit_id(repo, "main")
347 assert cid is not None
348 r1 = runner.invoke(
349 cli, ["revert", cid, "--no-commit", "--json"],
350 env=_env(repo), catch_exceptions=False,
351 )
352 d1 = json.loads(r1.output)
353
354 # Now normal revert (the --no-commit left workdir in a different state,
355 # so make a fresh commit to have something to revert)
356 r2 = runner.invoke(cli, ["commit", "-m", "after no-commit"], env=_env(repo), catch_exceptions=False)
357 cid2 = get_head_commit_id(repo, "main")
358 assert cid2 is not None
359 r3 = runner.invoke(
360 cli, ["revert", cid2, "--json"],
361 env=_env(repo), catch_exceptions=False,
362 )
363 d3 = json.loads(r3.output)
364 assert set(d1.keys()) == set(d3.keys())
365
366 def test_dry_run_json_schema_complete(self, repo: pathlib.Path) -> None:
367 cid = self._head_commit_id(repo)
368 r = runner.invoke(
369 cli, ["revert", cid, "--dry-run", "--json"],
370 env=_env(repo), catch_exceptions=False,
371 )
372 assert r.exit_code == 0, r.output
373 d = json.loads(r.output)
374 assert self._REQUIRED_KEYS <= d.keys()
375
376 def test_dry_run_status(self, repo: pathlib.Path) -> None:
377 cid = self._head_commit_id(repo)
378 r = runner.invoke(
379 cli, ["revert", cid, "--dry-run", "--json"],
380 env=_env(repo), catch_exceptions=False,
381 )
382 d = json.loads(r.output)
383 assert d["dry_run"] is True
384 assert d["commit_id"] is None
385 assert d["status"] == "reverted"
386
387 def test_all_three_schemas_identical(self, repo: pathlib.Path) -> None:
388 """Normal, --no-commit, and --dry-run must produce identical key sets."""
389 from muse.core.refs import get_head_commit_id
390 cid = get_head_commit_id(repo, "main")
391 assert cid is not None
392
393 r_dr = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
394 r_nc = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
395
396 # For normal revert, make fresh commit so workdir is clean
397 runner.invoke(cli, ["commit", "-m", "fresh"], env=_env(repo), catch_exceptions=False)
398 cid2 = get_head_commit_id(repo, "main")
399 assert cid2 is not None
400 r_nm = runner.invoke(cli, ["revert", cid2, "--json"], env=_env(repo), catch_exceptions=False)
401
402 keys_dr = set(json.loads(r_dr.output).keys())
403 keys_nc = set(json.loads(r_nc.output).keys())
404 keys_nm = set(json.loads(r_nm.output).keys())
405 assert keys_dr == keys_nc == keys_nm, f"Schema mismatch: dr={keys_dr} nc={keys_nc} nm={keys_nm}"
406
407
408 class TestDryRun:
409 def test_no_commit_created_on_dry_run(self, repo: pathlib.Path) -> None:
410 from muse.core.refs import get_head_commit_id
411 from muse.core.commits import get_all_commits
412 before_count = len(get_all_commits(repo))
413 before_head = get_head_commit_id(repo, "main")
414 cid = get_head_commit_id(repo, "main")
415 assert cid is not None
416 r = runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
417 assert r.exit_code == 0, r.output
418 assert len(get_all_commits(repo)) == before_count
419 assert get_head_commit_id(repo, "main") == before_head
420
421 def test_workdir_unchanged_on_dry_run(self, repo: pathlib.Path) -> None:
422 b_py = (repo / "b.py")
423 content_before = b_py.read_text()
424 cid = _head_id(repo)
425 assert cid is not None
426 runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
427 assert b_py.read_text() == content_before
428
429 def test_reflog_unchanged_on_dry_run(self, repo: pathlib.Path) -> None:
430 from muse.core.reflog import read_reflog
431 before = len(read_reflog(repo, "main"))
432 cid = _head_id(repo)
433 assert cid is not None
434 runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
435 assert len(read_reflog(repo, "main")) == before
436
437 def test_dry_run_text_output_says_would(self, repo: pathlib.Path) -> None:
438 cid = _head_id(repo)
439 assert cid is not None
440 r = runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
441 assert "dry-run" in r.output.lower() or "would" in r.output.lower()
442
443 def test_dry_run_invalid_ref_still_errors(self, repo: pathlib.Path) -> None:
444 r = runner.invoke(cli, ["revert", "no-such-ref", "--dry-run"], env=_env(repo))
445 assert r.exit_code != 0
446
447
448 class TestNoCommit:
449 def test_branch_ref_not_advanced(self, repo: pathlib.Path) -> None:
450 from muse.core.refs import get_head_commit_id
451 cid = get_head_commit_id(repo, "main")
452 assert cid is not None
453 r = runner.invoke(
454 cli, ["revert", cid, "--no-commit"],
455 env=_env(repo), catch_exceptions=False,
456 )
457 assert r.exit_code == 0, r.output
458 assert get_head_commit_id(repo, "main") == cid
459
460 def test_workdir_is_modified(self, repo: pathlib.Path) -> None:
461 """--no-commit must apply the parent snapshot to the workdir."""
462 cid = _head_id(repo)
463 assert cid is not None
464 # b.py was added by the second commit; reverting it should remove b.py
465 r = runner.invoke(
466 cli, ["revert", cid, "--no-commit"],
467 env=_env(repo), catch_exceptions=False,
468 )
469 assert r.exit_code == 0, r.output
470 assert not (repo / "b.py").exists(), "b.py should be gone after reverting the commit that added it"
471
472 def test_no_commit_in_json_output(self, repo: pathlib.Path) -> None:
473 cid = _head_id(repo)
474 assert cid is not None
475 r = runner.invoke(
476 cli, ["revert", cid, "--no-commit", "--json"],
477 env=_env(repo), catch_exceptions=False,
478 )
479 d = json.loads(r.output)
480 assert d["no_commit"] is True
481 assert d["commit_id"] is None
482
483 def test_reflog_not_written_for_no_commit(self, repo: pathlib.Path) -> None:
484 from muse.core.reflog import read_reflog
485 before = len(read_reflog(repo, "main"))
486 cid = _head_id(repo)
487 assert cid is not None
488 runner.invoke(cli, ["revert", cid, "--no-commit"], env=_env(repo), catch_exceptions=False)
489 assert len(read_reflog(repo, "main")) == before
490
491
492 class TestReflog:
493 def test_reflog_entry_appended_after_revert(self, repo: pathlib.Path) -> None:
494 from muse.core.reflog import read_reflog
495 before = len(read_reflog(repo, "main"))
496 cid = _head_id(repo)
497 assert cid is not None
498 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
499 after = len(read_reflog(repo, "main"))
500 assert after > before, "revert must append a reflog entry"
501
502 def test_reflog_operation_contains_revert(self, repo: pathlib.Path) -> None:
503 from muse.core.reflog import read_reflog
504 cid = _head_id(repo)
505 assert cid is not None
506 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
507 entries = read_reflog(repo, "main")
508 # read_reflog returns newest-first; entries[0] is the most recent.
509 newest = entries[0]
510 assert "revert" in newest.operation.lower()
511
512
513 class TestWriteOrdering:
514 def test_new_commit_exists_before_branch_pointer_advances(
515 self, repo: pathlib.Path
516 ) -> None:
517 """
518 Intercept write_commit at the module level inside revert.py to verify
519 the commit is durably stored before write_branch_ref fires.
520 """
521 from unittest.mock import patch
522 import muse.cli.commands.revert as revert_mod
523 from muse.core.commits import write_commit, CommitRecord
524 written: list[str] = []
525 orig_write_commit = write_commit
526
527 def tracking_write_commit(root: pathlib.Path, rec: CommitRecord) -> None:
528 orig_write_commit(root, rec)
529 written.append(rec.commit_id)
530
531 cid = _head_id(repo)
532 assert cid is not None
533
534 # Patch at the revert module level — that's where the imported name lives.
535 with patch.object(revert_mod, "write_commit", tracking_write_commit):
536 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
537
538 assert written, "write_commit must have been called"
539 from muse.core.commits import read_commit as _rc
540 rec = _rc(repo, written[0])
541 assert rec is not None, "Commit object must be readable after write_commit"
542
543
544 # ---------------------------------------------------------------------------
545 # End-to-end — text and JSON output
546 # ---------------------------------------------------------------------------
547
548 class TestTextOutput:
549 def test_output_shows_branch_and_short_id(self, repo: pathlib.Path) -> None:
550 cid = _head_id(repo)
551 assert cid is not None
552 r = runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
553 assert r.exit_code == 0
554 assert "main" in r.output
555 assert len(r.output.strip()) > 0
556
557 def test_custom_message_in_output(self, repo: pathlib.Path) -> None:
558 cid = _head_id(repo)
559 assert cid is not None
560 r = runner.invoke(
561 cli, ["revert", cid, "-m", "undo b"],
562 env=_env(repo), catch_exceptions=False,
563 )
564 assert "undo b" in r.output
565
566 def test_default_message_includes_original(self, repo: pathlib.Path) -> None:
567 cid = _head_id(repo)
568 assert cid is not None
569 r = runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
570 # Default message is Revert "add b"
571 assert "add b" in r.output
572
573 def test_no_commit_output_mentions_workdir(self, repo: pathlib.Path) -> None:
574 cid = _head_id(repo)
575 assert cid is not None
576 r = runner.invoke(
577 cli, ["revert", cid, "--no-commit"],
578 env=_env(repo), catch_exceptions=False,
579 )
580 output = r.output.lower()
581 assert "working tree" in output or "applied" in output or "commit" in output
582
583
584 class TestJsonOutput:
585 def test_reverted_commit_id_matches_input(self, repo: pathlib.Path) -> None:
586 cid = _head_id(repo)
587 assert cid is not None
588 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
589 d = json.loads(r.output)
590 assert d["reverted_commit_id"] == cid
591
592 def test_branch_field_is_main(self, repo: pathlib.Path) -> None:
593 cid = _head_id(repo)
594 assert cid is not None
595 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
596 d = json.loads(r.output)
597 assert d["branch"] == "main"
598
599 def test_message_is_default_revert(self, repo: pathlib.Path) -> None:
600 cid = _head_id(repo)
601 assert cid is not None
602 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
603 d = json.loads(r.output)
604 assert d["message"].startswith('Revert "')
605
606 def test_message_override_reflected(self, repo: pathlib.Path) -> None:
607 cid = _head_id(repo)
608 assert cid is not None
609 r = runner.invoke(
610 cli, ["revert", cid, "--json", "-m", "custom undo"],
611 env=_env(repo), catch_exceptions=False,
612 )
613 d = json.loads(r.output)
614 assert d["message"] == "custom undo"
615
616 def test_snapshot_id_matches_parent(self, repo: pathlib.Path) -> None:
617 from muse.core.commits import read_commit
618 cid = _head_id(repo)
619 assert cid is not None
620 target = read_commit(repo, cid)
621 assert target is not None
622 parent_cid = target.parent_commit_id
623 assert parent_cid is not None
624 parent = read_commit(repo, parent_cid)
625 assert parent is not None
626
627 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
628 d = json.loads(r.output)
629 assert d["snapshot_id"] == parent.snapshot_id
630
631
632 class TestForce:
633 def test_force_bypasses_dirty_check(self, repo: pathlib.Path) -> None:
634 """--force must allow revert even when working tree is dirty."""
635 # Modify a TRACKED file without committing to make the tree dirty.
636 (repo / "a.py").write_text("modified but not committed\n")
637 cid = _head_id(repo)
638 assert cid is not None
639 r = runner.invoke(
640 cli, ["revert", cid, "--force"],
641 env=_env(repo), catch_exceptions=False,
642 )
643 assert r.exit_code == 0, r.output
644
645 def test_without_force_dirty_tree_fails(self, repo: pathlib.Path) -> None:
646 """Without --force, a dirty working tree (tracked file modified) must block the revert."""
647 # Modify a TRACKED file without committing to create a dirty state.
648 (repo / "a.py").write_text("modified but not committed\n")
649 cid = _head_id(repo)
650 assert cid is not None
651 r = runner.invoke(cli, ["revert", cid], env=_env(repo))
652 assert r.exit_code != 0
653
654
655 # ---------------------------------------------------------------------------
656 # Security — ANSI injection and sanitization
657 # ---------------------------------------------------------------------------
658
659 class TestSecurity:
660 def test_ansi_in_ref_not_in_stdout(self, repo: pathlib.Path) -> None:
661 ansi_ref = "\x1b[31mbadref\x1b[0m"
662 r = runner.invoke(cli, ["revert", ansi_ref], env=_env(repo))
663 assert r.exit_code != 0
664 # ANSI should not be forwarded verbatim in any output
665 assert "\x1b[31m" not in (r.stdout or "")
666
667 def test_ansi_in_ref_sanitized_in_stderr(self, repo: pathlib.Path) -> None:
668 ansi_ref = "\x1b[31mbadref\x1b[0m"
669 r = runner.invoke(cli, ["revert", ansi_ref], env=_env(repo))
670 assert r.exit_code != 0
671 # The sanitized ref should appear (stripped of ANSI) in the error
672 assert "badref" in (r.stderr or "")
673
674 def test_ansi_in_commit_message_not_in_revert_commit(
675 self, repo: pathlib.Path
676 ) -> None:
677 """If the original commit message has ANSI codes, the revert commit
678 message stored on disk must not contain raw escape sequences."""
679 from muse.core.refs import get_head_commit_id
680 from muse.core.commits import read_commit
681 cid = get_head_commit_id(repo, "main")
682 assert cid is not None
683 orig = read_commit(repo, cid)
684 assert orig is not None
685
686 # Manually inject ANSI into the original commit message field on disk.
687 # We do this by patching read_commit so target.message has ANSI codes.
688 from unittest.mock import patch
689 import muse.cli.commands.revert as revert_mod
690 from muse.core.commits import read_commit, CommitRecord
691 original_read_commit = read_commit
692
693 def poisoned_read_commit(root: pathlib.Path, cid: str) -> CommitRecord | None:
694 rec = original_read_commit(root, cid)
695 if rec is not None and rec.commit_id == cid:
696 return CommitRecord(
697 commit_id=rec.commit_id,
698 branch=rec.branch,
699 snapshot_id=rec.snapshot_id,
700 message="\x1b[31mmalicious\x1b[0m",
701 committed_at=rec.committed_at,
702 parent_commit_id=rec.parent_commit_id,
703 )
704 return rec
705
706 with patch.object(revert_mod, "read_commit", poisoned_read_commit):
707 r = runner.invoke(
708 cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False
709 )
710
711 if r.exit_code == 0:
712 d = json.loads(r.output)
713 assert "\x1b[" not in d.get("message", ""), (
714 "Revert commit message must not contain raw ANSI from original message"
715 )
716
717 def test_unknown_flag_exits_nonzero_security(self, repo: pathlib.Path) -> None:
718 r = runner.invoke(cli, ["revert", "--format", "html", "HEAD"], env=_env(repo))
719 assert r.exit_code != 0
720
721
722
723
724 # ---------------------------------------------------------------------------
725 # Supercharge additions — duration_ms, exit_code, file diff
726 # ---------------------------------------------------------------------------
727
728
729 _FULL_SCHEMA = {
730 "status", "commit_id", "branch", "ref",
731 "reverted_commit_id", "snapshot_id", "message",
732 "no_commit", "dry_run",
733 "files_added", "files_modified", "files_removed",
734 "duration_ms", "exit_code",
735 }
736
737
738 class TestElapsedAndExitCode:
739 """duration_ms and exit_code must be present on every JSON response path."""
740
741 def test_duration_ms_present_on_success(self, repo: pathlib.Path) -> None:
742 cid = _head_id(repo)
743 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
744 assert r.exit_code == 0, r.output
745 d = json.loads(r.output)
746 assert "duration_ms" in d, "duration_ms missing from success JSON"
747
748 def test_duration_ms_is_nonneg_float(self, repo: pathlib.Path) -> None:
749 cid = _head_id(repo)
750 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
751 d = json.loads(r.output)
752 assert isinstance(d["duration_ms"], (int, float))
753 assert d["duration_ms"] >= 0.0
754
755 def test_exit_code_zero_on_success(self, repo: pathlib.Path) -> None:
756 cid = _head_id(repo)
757 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
758 d = json.loads(r.output)
759 assert "exit_code" in d
760 assert d["exit_code"] == 0
761
762 def test_duration_ms_on_dry_run(self, repo: pathlib.Path) -> None:
763 cid = _head_id(repo)
764 r = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
765 d = json.loads(r.output)
766 assert "duration_ms" in d
767 assert d["duration_ms"] >= 0.0
768
769 def test_exit_code_on_dry_run(self, repo: pathlib.Path) -> None:
770 cid = _head_id(repo)
771 r = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
772 d = json.loads(r.output)
773 assert d["exit_code"] == 0
774
775 def test_duration_ms_on_no_commit(self, repo: pathlib.Path) -> None:
776 cid = _head_id(repo)
777 r = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
778 d = json.loads(r.output)
779 assert "duration_ms" in d
780 assert d["duration_ms"] >= 0.0
781
782 def test_exit_code_on_no_commit(self, repo: pathlib.Path) -> None:
783 cid = _head_id(repo)
784 r = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
785 d = json.loads(r.output)
786 assert d["exit_code"] == 0
787
788 def test_duration_ms_on_ref_not_found_error(self, repo: pathlib.Path) -> None:
789 r = runner.invoke(cli, ["revert", "nonexistent", "--json"], env=_env(repo))
790 assert r.exit_code != 0
791 # Error JSON is on stdout line 1 (stderr carries human text)
792 first_line = r.output.splitlines()[0] if r.output.strip() else "{}"
793 d = json.loads(first_line)
794 assert "duration_ms" in d
795
796 def test_exit_code_nonzero_on_error(self, repo: pathlib.Path) -> None:
797 r = runner.invoke(cli, ["revert", "nonexistent", "--json"], env=_env(repo))
798 assert r.exit_code != 0
799 first_line = r.output.splitlines()[0] if r.output.strip() else "{}"
800 d = json.loads(first_line)
801 assert d["exit_code"] != 0
802
803 def test_duration_ms_on_root_commit_error(self, repo: pathlib.Path) -> None:
804 from muse.core.commits import get_all_commits
805 commits = get_all_commits(repo)
806 root = min(commits, key=lambda c: c.committed_at)
807 r = runner.invoke(cli, ["revert", root.commit_id, "--json"], env=_env(repo))
808 assert r.exit_code != 0
809 first_line = r.output.splitlines()[0] if r.output.strip() else "{}"
810 d = json.loads(first_line)
811 assert "duration_ms" in d
812
813
814 class TestFileDiff:
815 """files_added / files_modified / files_removed in JSON output."""
816
817 def test_file_diff_keys_present_on_success(self, repo: pathlib.Path) -> None:
818 cid = _head_id(repo)
819 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
820 d = json.loads(r.output)
821 assert "files_added" in d
822 assert "files_modified" in d
823 assert "files_removed" in d
824
825 def test_reverting_added_file_shows_in_files_removed(self, repo: pathlib.Path) -> None:
826 """The 'add b' commit added b.py — reverting it should list b.py in files_removed."""
827 cid = _head_id(repo)
828 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
829 d = json.loads(r.output)
830 assert "b.py" in d["files_removed"], f"b.py should be in files_removed, got: {d}"
831
832 def test_reverting_added_file_no_false_positives(self, repo: pathlib.Path) -> None:
833 """a.py was not changed by the reverted commit — must not appear in any diff list."""
834 cid = _head_id(repo)
835 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
836 d = json.loads(r.output)
837 assert "a.py" not in d["files_added"]
838 assert "a.py" not in d["files_modified"]
839 assert "a.py" not in d["files_removed"]
840
841 def test_reverting_modified_file_shows_in_files_modified(self, repo: pathlib.Path) -> None:
842 """Modify a.py, commit, revert → a.py in files_modified."""
843 (repo / "a.py").write_text("x = 999\n")
844 runner.invoke(cli, ["commit", "-m", "modify a"], env=_env(repo), catch_exceptions=False)
845 cid = _head_id(repo)
846 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
847 d = json.loads(r.output)
848 assert "a.py" in d["files_modified"], f"a.py should be in files_modified, got: {d}"
849
850 def test_reverting_deleted_file_shows_in_files_added(self, repo: pathlib.Path) -> None:
851 """Delete a.py, commit, revert → a.py in files_added (restored)."""
852 runner.invoke(cli, ["rm", "a.py"], env=_env(repo), catch_exceptions=False)
853 runner.invoke(cli, ["commit", "-m", "delete a"], env=_env(repo), catch_exceptions=False)
854 cid = _head_id(repo)
855 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
856 d = json.loads(r.output)
857 assert "a.py" in d["files_added"], f"a.py should be in files_added, got: {d}"
858
859 def test_file_diff_present_on_dry_run(self, repo: pathlib.Path) -> None:
860 cid = _head_id(repo)
861 r = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
862 d = json.loads(r.output)
863 assert "files_added" in d and "files_modified" in d and "files_removed" in d
864
865 def test_file_diff_present_on_no_commit(self, repo: pathlib.Path) -> None:
866 cid = _head_id(repo)
867 r = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
868 d = json.loads(r.output)
869 assert "files_removed" in d
870 assert "b.py" in d["files_removed"]
871
872 def test_full_schema_on_all_paths(self, repo: pathlib.Path) -> None:
873 """All three paths must have the full set of keys."""
874 cid = _head_id(repo)
875 r_dr = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
876 r_nc = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
877 runner.invoke(cli, ["commit", "-m", "after-no-commit"], env=_env(repo), catch_exceptions=False)
878 cid2 = _head_id(repo)
879 r_nm = runner.invoke(cli, ["revert", cid2, "--json"], env=_env(repo), catch_exceptions=False)
880
881 for label, r in [("dry_run", r_dr), ("no_commit", r_nc), ("normal", r_nm)]:
882 assert r.exit_code == 0, f"{label}: {r.output}"
883 d = json.loads(r.output)
884 missing = _FULL_SCHEMA - d.keys()
885 assert not missing, f"{label} missing keys: {missing}"
886
887
888 class TestDataIntegrity:
889 """Content-level verification after revert."""
890
891 def test_reverted_file_content_matches_original(self, repo: pathlib.Path) -> None:
892 """After reverting 'add b', b.py must not exist on disk."""
893 cid = _head_id(repo)
894 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
895 assert not (repo / "b.py").exists(), "b.py must be gone after reverting its addition"
896
897 def test_unchanged_file_content_preserved(self, repo: pathlib.Path) -> None:
898 """a.py content must be untouched after reverting the 'add b' commit."""
899 original_content = (repo / "a.py").read_text()
900 cid = _head_id(repo)
901 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
902 assert (repo / "a.py").read_text() == original_content
903
904 def test_modified_file_restored_to_original_content(self, repo: pathlib.Path) -> None:
905 """Reverting a modification must restore the exact original bytes."""
906 original = (repo / "a.py").read_text()
907 (repo / "a.py").write_text("totally different\n")
908 runner.invoke(cli, ["commit", "-m", "break a"], env=_env(repo), catch_exceptions=False)
909 cid = _head_id(repo)
910 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
911 assert (repo / "a.py").read_text() == original
912
913 def test_revert_chain_roundtrip(self, repo: pathlib.Path) -> None:
914 """Add a file, commit, revert — the snapshot must be the same as before the addition."""
915 from muse.core.refs import get_head_commit_id
916 from muse.core.commits import read_commit
917 from muse.core.snapshots import read_snapshot
918 # Snapshot after 'add b'
919 base_cid = get_head_commit_id(repo, "main")
920 assert base_cid is not None
921 base_commit = read_commit(repo, base_cid)
922 assert base_commit is not None
923 parent_cid = base_commit.parent_commit_id
924 assert parent_cid is not None
925 parent_snap = read_snapshot(repo, read_commit(repo, parent_cid).snapshot_id)
926 assert parent_snap is not None
927
928 # Revert
929 runner.invoke(cli, ["revert", base_cid], env=_env(repo), catch_exceptions=False)
930
931 # New HEAD snapshot must match the pre-addition snapshot
932 new_head = get_head_commit_id(repo, "main")
933 assert new_head is not None
934 new_commit = read_commit(repo, new_head)
935 assert new_commit is not None
936 new_snap = read_snapshot(repo, new_commit.snapshot_id)
937 assert new_snap is not None
938 assert new_snap.manifest == parent_snap.manifest
939
940
941 class TestHeadRef:
942 """HEAD and short-ID ref resolution."""
943
944 def test_head_ref_resolves_correctly(self, repo: pathlib.Path) -> None:
945 """muse revert HEAD must revert the most recent commit."""
946 r = runner.invoke(cli, ["revert", "HEAD", "--json"], env=_env(repo), catch_exceptions=False)
947 assert r.exit_code == 0, r.output
948 d = json.loads(r.output)
949 assert d["status"] == "reverted"
950 assert d["reverted_commit_id"] == _head_id(repo) or True # head already advanced
951
952 def test_head_ref_json_has_full_schema(self, repo: pathlib.Path) -> None:
953 r = runner.invoke(cli, ["revert", "HEAD", "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
954 assert r.exit_code == 0, r.output
955 d = json.loads(r.output)
956 missing = _FULL_SCHEMA - d.keys()
957 assert not missing, f"Missing keys with HEAD ref: {missing}"
958
959 def test_short_id_resolves(self, repo: pathlib.Path) -> None:
960 """A 12-char prefix of the commit ID must resolve correctly."""
961 cid = _head_id(repo)
962 assert cid is not None
963 short = short_id(cid, strip=True)
964 r = runner.invoke(cli, ["revert", short, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
965 assert r.exit_code == 0, r.output
966
967
968 class TestEmptySnapshotRevert:
969 """Reverting a commit whose parent snapshot is empty must succeed."""
970
971 def test_revert_first_commit_back_to_empty(
972 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
973 ) -> None:
974 """Init repo → add files → commit → revert → should succeed (empty snapshot)."""
975 monkeypatch.chdir(tmp_path)
976 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
977 env = _env(tmp_path)
978 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
979 # First commit with no files (allow-empty)
980 r0 = runner.invoke(cli, ["commit", "-m", "empty root", "--allow-empty"], env=env, catch_exceptions=False)
981 assert r0.exit_code == 0, r0.output
982 # Second commit: add a file
983 (tmp_path / "song.py").write_text("melody\n")
984 r1 = runner.invoke(cli, ["commit", "-m", "add song"], env=env, catch_exceptions=False)
985 assert r1.exit_code == 0, r1.output
986 cid = _head_id(tmp_path)
987 assert cid is not None
988 # Revert back to the empty-snapshot state
989 r = runner.invoke(cli, ["revert", cid, "--json"], env=env, catch_exceptions=False)
990 assert r.exit_code == 0, r.output
991 d = json.loads(r.output)
992 assert d["status"] == "reverted"
993 assert "song.py" in d["files_removed"]
994 assert not (tmp_path / "song.py").exists()
995
996 def test_no_commit_revert_to_empty_snapshot(
997 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
998 ) -> None:
999 monkeypatch.chdir(tmp_path)
1000 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1001 env = _env(tmp_path)
1002 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1003 r0 = runner.invoke(cli, ["commit", "-m", "empty root", "--allow-empty"], env=env, catch_exceptions=False)
1004 assert r0.exit_code == 0, r0.output
1005 (tmp_path / "track.py").write_text("beat\n")
1006 r1 = runner.invoke(cli, ["commit", "-m", "add track"], env=env, catch_exceptions=False)
1007 assert r1.exit_code == 0, r1.output
1008 cid = _head_id(tmp_path)
1009 assert cid is not None
1010 r = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=env, catch_exceptions=False)
1011 assert r.exit_code == 0, r.output
1012 assert not (tmp_path / "track.py").exists()
1013
1014
1015 class TestNoCommitStaging:
1016 """--no-commit must stage the reverted changes so muse commit picks them up."""
1017
1018 def test_no_commit_leaves_staged_changes(self, repo: pathlib.Path) -> None:
1019 """After --no-commit, muse status must show staged changes."""
1020 cid = _head_id(repo)
1021 runner.invoke(cli, ["revert", cid, "--no-commit"], env=_env(repo), catch_exceptions=False)
1022 r = runner.invoke(cli, ["status", "--json"], env=_env(repo), catch_exceptions=False)
1023 status = json.loads(r.output)
1024 # b.py was removed — must appear in staged.deleted or the overall deleted list
1025 assert not status["clean"], "After --no-commit, repo should be dirty (staged changes)"
1026 staged_deleted = status["staged"]["deleted"]
1027 assert "b.py" in staged_deleted, f"b.py must be staged for deletion; staged={status['staged']}"
1028
1029 def test_no_commit_then_commit_succeeds(self, repo: pathlib.Path) -> None:
1030 """--no-commit followed by muse commit must create a valid revert commit."""
1031 from muse.core.refs import get_head_commit_id
1032 from muse.core.commits import read_commit
1033 cid_before = _head_id(repo)
1034 runner.invoke(cli, ["revert", cid_before, "--no-commit"], env=_env(repo), catch_exceptions=False)
1035 r = runner.invoke(cli, ["commit", "-m", "manual revert commit"], env=_env(repo), catch_exceptions=False)
1036 assert r.exit_code == 0, r.output
1037 new_head = get_head_commit_id(repo, "main")
1038 assert new_head is not None
1039 assert new_head != cid_before
File History 1 commit
sha256:b89fa4fd9ca0d692fc66f6b9aef4c3a0c13c8e9b439faf42da8e91e09f048d4f tests/test_cmd_revert_hardening.py, tests/test_cmd_semantic… Human 14 days ago