gabriel / muse public
test_cmd_revert_hardening.py python
1,036 lines 44.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 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 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 - --format with unknown value exits 1 and prints to stderr
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
56 import argparse
57 import inspect
58 import json
59 import pathlib
60 import subprocess
61 import time
62
63 import pytest
64
65 from tests.cli_test_helper import CliRunner
66
67 cli = None # argparse migration — CliRunner ignores this arg
68 runner = CliRunner()
69
70
71 # ---------------------------------------------------------------------------
72 # Shared helpers
73 # ---------------------------------------------------------------------------
74
75 def _env(root: pathlib.Path) -> dict[str, str]:
76 return {"MUSE_REPO_ROOT": str(root)}
77
78
79 @pytest.fixture()
80 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
81 """Minimal real muse repo with two commits: base + target."""
82 monkeypatch.chdir(tmp_path)
83 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
84 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
85 assert r.exit_code == 0, r.output
86 (tmp_path / "a.py").write_text("x = 1\n")
87 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
88 assert r.exit_code == 0, r.output
89 (tmp_path / "b.py").write_text("y = 2\n")
90 r = runner.invoke(cli, ["commit", "-m", "add b"], env=_env(tmp_path), catch_exceptions=False)
91 assert r.exit_code == 0, r.output
92 return tmp_path
93
94
95 def _head_id(repo: pathlib.Path) -> str | None:
96 from muse.core.store import get_head_commit_id
97 return get_head_commit_id(repo, "main")
98
99
100 def _ref_file(repo: pathlib.Path) -> pathlib.Path:
101 return repo / ".muse" / "refs" / "heads" / "main"
102
103
104 # ---------------------------------------------------------------------------
105 # Unit — parser flags and dead-code removal
106 # ---------------------------------------------------------------------------
107
108 class TestRegisterFlags:
109 """Parser registration emits all expected flags."""
110
111 @pytest.fixture(autouse=True)
112 def _ns(self) -> None:
113 import argparse
114 import muse.cli.commands.revert as m
115 p = argparse.ArgumentParser()
116 sub = p.add_subparsers()
117 m.register(sub)
118 self._sub = sub
119
120 def _parse(self, *args: str) -> argparse.Namespace:
121 import argparse
122 import muse.cli.commands.revert as m
123 p = argparse.ArgumentParser()
124 sub = p.add_subparsers()
125 m.register(sub)
126 return p.parse_args(["revert", *args])
127
128 def test_dry_run_flag(self) -> None:
129 import argparse
130 ns = self._parse("abc123", "--dry-run")
131 assert ns.dry_run is True
132
133 def test_dry_run_default_false(self) -> None:
134 import argparse
135 ns = self._parse("abc123")
136 assert ns.dry_run is False
137
138 def test_no_commit_short_flag(self) -> None:
139 import argparse
140 ns = self._parse("abc123", "-n")
141 assert ns.no_commit is True
142
143 def test_no_commit_long_flag(self) -> None:
144 import argparse
145 ns = self._parse("abc123", "--no-commit")
146 assert ns.no_commit is True
147
148 def test_force_flag(self) -> None:
149 import argparse
150 ns = self._parse("abc123", "--force")
151 assert ns.force is True
152
153 def test_format_json_shorthand(self) -> None:
154 import argparse
155 ns = self._parse("abc123", "--json")
156 assert ns.fmt == "json"
157
158 def test_format_explicit_text(self) -> None:
159 import argparse
160 ns = self._parse("abc123", "--format", "text")
161 assert ns.fmt == "text"
162
163 def test_message_short(self) -> None:
164 import argparse
165 ns = self._parse("abc123", "-m", "my message")
166 assert ns.message == "my message"
167
168 def test_ref_positional(self) -> None:
169 import argparse
170 ns = self._parse("deadbeef")
171 assert ns.ref == "deadbeef"
172
173
174 class TestDeadCodeRemoval:
175 def test_no_read_branch_wrapper(self) -> None:
176 import muse.cli.commands.revert as m
177 assert not hasattr(m, "_read_branch"), "_read_branch must be deleted"
178
179 def test_pathlib_not_imported(self) -> None:
180 import muse.cli.commands.revert as m
181 src = inspect.getsource(m)
182 assert "import pathlib" not in src, "pathlib was only used by _read_branch"
183
184 def test_validate_branch_name_called_in_run(self) -> None:
185 import muse.cli.commands.revert as m
186 src = inspect.getsource(m.run)
187 assert "validate_branch_name" in src
188
189 def test_write_commit_before_apply_manifest(self) -> None:
190 """Normal path must write_commit before _apply_manifest_safe and write_branch_ref."""
191 import muse.cli.commands.revert as m
192 # Filter out comment lines so we check executable ordering only.
193 src_lines = [
194 (i, l)
195 for i, l in enumerate(inspect.getsource(m.run).split("\n"), 1)
196 if l.strip() and not l.strip().startswith("#")
197 ]
198 write_commit_line = next(
199 i for i, l in src_lines if "write_commit(" in l
200 )
201 apply_manifest_lines = [i for i, l in src_lines if "_apply_manifest_safe(" in l]
202 write_branch_ref_line = next(
203 i for i, l in src_lines if "write_branch_ref(" in l
204 )
205 # There may be two _apply_manifest_safe calls (no_commit and normal path).
206 # The LAST _apply_manifest_safe must come after write_commit.
207 last_apply = max(apply_manifest_lines)
208 assert write_commit_line < last_apply, (
209 f"write_commit ({write_commit_line}) must precede _apply_manifest_safe ({last_apply})"
210 )
211 assert last_apply < write_branch_ref_line, (
212 f"_apply_manifest_safe ({last_apply}) must precede write_branch_ref ({write_branch_ref_line})"
213 )
214
215 def test_target_message_sanitized_in_run(self) -> None:
216 import muse.cli.commands.revert as m
217 src = inspect.getsource(m.run)
218 assert "sanitize_display(target.message" in src
219
220 def test_ref_sanitized_in_error(self) -> None:
221 import muse.cli.commands.revert as m
222 src = inspect.getsource(m.run)
223 assert "sanitize_display(ref)" in src
224
225
226 # ---------------------------------------------------------------------------
227 # Integration — error routing and behaviour
228 # ---------------------------------------------------------------------------
229
230 class TestErrorRouting:
231 def test_not_found_to_stderr(self, repo: pathlib.Path) -> None:
232 r = runner.invoke(cli, ["revert", "badref"], env=_env(repo))
233 assert r.exit_code != 0
234 # Error message must be in stderr; stdout should be clean.
235 assert "not found" in (r.stderr or "").lower()
236 assert "badref" in (r.stderr or "")
237
238 def test_root_commit_error_to_stderr(self, repo: pathlib.Path) -> None:
239 from muse.core.store import get_all_commits
240 commits = get_all_commits(repo)
241 root = min(commits, key=lambda c: c.committed_at)
242 r = runner.invoke(cli, ["revert", root.commit_id], env=_env(repo))
243 assert r.exit_code != 0
244 assert "root" in (r.stderr or "").lower() or "parent" in (r.stderr or "").lower()
245
246 def test_bad_format_to_stderr(self, repo: pathlib.Path) -> None:
247 r = runner.invoke(cli, ["revert", "--format", "xml", "HEAD"], env=_env(repo))
248 assert r.exit_code == 1
249 assert "xml" in (r.stderr or "").lower()
250
251 def test_unknown_ref_in_stderr(self, repo: pathlib.Path) -> None:
252 r = runner.invoke(cli, ["revert", "0000000000000000"], env=_env(repo))
253 assert r.exit_code != 0
254 assert "not found" in (r.stderr or "").lower()
255
256 def test_root_commit_in_stderr(self, repo: pathlib.Path) -> None:
257 from muse.core.store import get_all_commits
258 commits = get_all_commits(repo)
259 root = min(commits, key=lambda c: c.committed_at)
260 r = runner.invoke(cli, ["revert", root.commit_id], env=_env(repo))
261 assert r.exit_code != 0
262 assert "root" in (r.stderr or "").lower() or "parent" in (r.stderr or "").lower()
263
264
265 class TestJsonSchema:
266 """JSON schema must be identical across all code paths."""
267
268 _REQUIRED_KEYS = {
269 "status", "commit_id", "branch", "ref",
270 "reverted_commit_id", "snapshot_id", "message",
271 "no_commit", "dry_run",
272 }
273
274 def _head_commit_id(self, repo: pathlib.Path) -> str:
275 from muse.core.store import get_head_commit_id
276 cid = get_head_commit_id(repo, "main")
277 assert cid is not None
278 return cid
279
280 def test_normal_json_schema_complete(self, repo: pathlib.Path) -> None:
281 cid = self._head_commit_id(repo)
282 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
283 assert r.exit_code == 0, r.output
284 d = json.loads(r.output)
285 assert self._REQUIRED_KEYS <= d.keys()
286
287 def test_normal_status_is_reverted(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 assert r.exit_code == 0, r.output
291 d = json.loads(r.output)
292 assert d["status"] == "reverted"
293 assert d["no_commit"] is False
294 assert d["dry_run"] is False
295
296 def test_normal_commit_id_is_string(self, repo: pathlib.Path) -> None:
297 cid = self._head_commit_id(repo)
298 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
299 d = json.loads(r.output)
300 assert isinstance(d["commit_id"], str)
301 assert d["commit_id"].startswith("sha256:")
302
303 def test_normal_snapshot_id_present(self, repo: pathlib.Path) -> None:
304 cid = self._head_commit_id(repo)
305 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
306 d = json.loads(r.output)
307 assert isinstance(d["snapshot_id"], str)
308 assert d["snapshot_id"].startswith("sha256:")
309
310 def test_normal_ref_field_matches_input(self, repo: pathlib.Path) -> None:
311 cid = self._head_commit_id(repo)
312 r = runner.invoke(cli, ["revert", cid[:12], "--json"], env=_env(repo), catch_exceptions=False)
313 d = json.loads(r.output)
314 assert d["ref"] == cid[:12]
315
316 def test_no_commit_json_schema_complete(self, repo: pathlib.Path) -> None:
317 cid = self._head_commit_id(repo)
318 r = runner.invoke(
319 cli, ["revert", cid, "--no-commit", "--json"],
320 env=_env(repo), catch_exceptions=False,
321 )
322 assert r.exit_code == 0, r.output
323 d = json.loads(r.output)
324 assert self._REQUIRED_KEYS <= d.keys()
325
326 def test_no_commit_status_is_applied(self, repo: pathlib.Path) -> None:
327 cid = self._head_commit_id(repo)
328 r = runner.invoke(
329 cli, ["revert", cid, "--no-commit", "--json"],
330 env=_env(repo), catch_exceptions=False,
331 )
332 d = json.loads(r.output)
333 assert d["status"] == "applied"
334 assert d["commit_id"] is None
335 assert d["no_commit"] is True
336 assert d["dry_run"] is False
337
338 def test_no_commit_and_normal_schemas_identical(self, repo: pathlib.Path) -> None:
339 """Both paths must emit the same set of keys."""
340 from muse.core.store import get_head_commit_id
341 # First get the commit ID
342 cid = get_head_commit_id(repo, "main")
343 assert cid is not None
344 r1 = runner.invoke(
345 cli, ["revert", cid, "--no-commit", "--json"],
346 env=_env(repo), catch_exceptions=False,
347 )
348 d1 = json.loads(r1.output)
349
350 # Now normal revert (the --no-commit left workdir in a different state,
351 # so make a fresh commit to have something to revert)
352 r2 = runner.invoke(cli, ["commit", "-m", "after no-commit"], env=_env(repo), catch_exceptions=False)
353 cid2 = get_head_commit_id(repo, "main")
354 assert cid2 is not None
355 r3 = runner.invoke(
356 cli, ["revert", cid2, "--json"],
357 env=_env(repo), catch_exceptions=False,
358 )
359 d3 = json.loads(r3.output)
360 assert set(d1.keys()) == set(d3.keys())
361
362 def test_dry_run_json_schema_complete(self, repo: pathlib.Path) -> None:
363 cid = self._head_commit_id(repo)
364 r = runner.invoke(
365 cli, ["revert", cid, "--dry-run", "--json"],
366 env=_env(repo), catch_exceptions=False,
367 )
368 assert r.exit_code == 0, r.output
369 d = json.loads(r.output)
370 assert self._REQUIRED_KEYS <= d.keys()
371
372 def test_dry_run_status(self, repo: pathlib.Path) -> None:
373 cid = self._head_commit_id(repo)
374 r = runner.invoke(
375 cli, ["revert", cid, "--dry-run", "--json"],
376 env=_env(repo), catch_exceptions=False,
377 )
378 d = json.loads(r.output)
379 assert d["dry_run"] is True
380 assert d["commit_id"] is None
381 assert d["status"] == "reverted"
382
383 def test_all_three_schemas_identical(self, repo: pathlib.Path) -> None:
384 """Normal, --no-commit, and --dry-run must produce identical key sets."""
385 from muse.core.store import get_head_commit_id
386 cid = get_head_commit_id(repo, "main")
387 assert cid is not None
388
389 r_dr = runner.invoke(cli, ["revert", cid, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
390 r_nc = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=_env(repo), catch_exceptions=False)
391
392 # For normal revert, make fresh commit so workdir is clean
393 runner.invoke(cli, ["commit", "-m", "fresh"], env=_env(repo), catch_exceptions=False)
394 cid2 = get_head_commit_id(repo, "main")
395 assert cid2 is not None
396 r_nm = runner.invoke(cli, ["revert", cid2, "--json"], env=_env(repo), catch_exceptions=False)
397
398 keys_dr = set(json.loads(r_dr.output).keys())
399 keys_nc = set(json.loads(r_nc.output).keys())
400 keys_nm = set(json.loads(r_nm.output).keys())
401 assert keys_dr == keys_nc == keys_nm, f"Schema mismatch: dr={keys_dr} nc={keys_nc} nm={keys_nm}"
402
403
404 class TestDryRun:
405 def test_no_commit_created_on_dry_run(self, repo: pathlib.Path) -> None:
406 from muse.core.store import get_all_commits, get_head_commit_id
407 before_count = len(get_all_commits(repo))
408 before_head = get_head_commit_id(repo, "main")
409 cid = get_head_commit_id(repo, "main")
410 assert cid is not None
411 r = runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
412 assert r.exit_code == 0, r.output
413 assert len(get_all_commits(repo)) == before_count
414 assert get_head_commit_id(repo, "main") == before_head
415
416 def test_workdir_unchanged_on_dry_run(self, repo: pathlib.Path) -> None:
417 b_py = (repo / "b.py")
418 content_before = b_py.read_text()
419 cid = _head_id(repo)
420 assert cid is not None
421 runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
422 assert b_py.read_text() == content_before
423
424 def test_reflog_unchanged_on_dry_run(self, repo: pathlib.Path) -> None:
425 from muse.core.reflog import read_reflog
426 before = len(read_reflog(repo, "main"))
427 cid = _head_id(repo)
428 assert cid is not None
429 runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
430 assert len(read_reflog(repo, "main")) == before
431
432 def test_dry_run_text_output_says_would(self, repo: pathlib.Path) -> None:
433 cid = _head_id(repo)
434 assert cid is not None
435 r = runner.invoke(cli, ["revert", cid, "--dry-run"], env=_env(repo), catch_exceptions=False)
436 assert "dry-run" in r.output.lower() or "would" in r.output.lower()
437
438 def test_dry_run_invalid_ref_still_errors(self, repo: pathlib.Path) -> None:
439 r = runner.invoke(cli, ["revert", "no-such-ref", "--dry-run"], env=_env(repo))
440 assert r.exit_code != 0
441
442
443 class TestNoCommit:
444 def test_branch_ref_not_advanced(self, repo: pathlib.Path) -> None:
445 from muse.core.store import get_head_commit_id
446 cid = get_head_commit_id(repo, "main")
447 assert cid is not None
448 r = runner.invoke(
449 cli, ["revert", cid, "--no-commit"],
450 env=_env(repo), catch_exceptions=False,
451 )
452 assert r.exit_code == 0, r.output
453 assert get_head_commit_id(repo, "main") == cid
454
455 def test_workdir_is_modified(self, repo: pathlib.Path) -> None:
456 """--no-commit must apply the parent snapshot to the workdir."""
457 cid = _head_id(repo)
458 assert cid is not None
459 # b.py was added by the second commit; reverting it should remove b.py
460 r = runner.invoke(
461 cli, ["revert", cid, "--no-commit"],
462 env=_env(repo), catch_exceptions=False,
463 )
464 assert r.exit_code == 0, r.output
465 assert not (repo / "b.py").exists(), "b.py should be gone after reverting the commit that added it"
466
467 def test_no_commit_in_json_output(self, repo: pathlib.Path) -> None:
468 cid = _head_id(repo)
469 assert cid is not None
470 r = runner.invoke(
471 cli, ["revert", cid, "--no-commit", "--json"],
472 env=_env(repo), catch_exceptions=False,
473 )
474 d = json.loads(r.output)
475 assert d["no_commit"] is True
476 assert d["commit_id"] is None
477
478 def test_reflog_not_written_for_no_commit(self, repo: pathlib.Path) -> None:
479 from muse.core.reflog import read_reflog
480 before = len(read_reflog(repo, "main"))
481 cid = _head_id(repo)
482 assert cid is not None
483 runner.invoke(cli, ["revert", cid, "--no-commit"], env=_env(repo), catch_exceptions=False)
484 assert len(read_reflog(repo, "main")) == before
485
486
487 class TestReflog:
488 def test_reflog_entry_appended_after_revert(self, repo: pathlib.Path) -> None:
489 from muse.core.reflog import read_reflog
490 before = len(read_reflog(repo, "main"))
491 cid = _head_id(repo)
492 assert cid is not None
493 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
494 after = len(read_reflog(repo, "main"))
495 assert after > before, "revert must append a reflog entry"
496
497 def test_reflog_operation_contains_revert(self, repo: pathlib.Path) -> None:
498 from muse.core.reflog import read_reflog
499 cid = _head_id(repo)
500 assert cid is not None
501 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
502 entries = read_reflog(repo, "main")
503 # read_reflog returns newest-first; entries[0] is the most recent.
504 newest = entries[0]
505 assert "revert" in newest.operation.lower()
506
507
508 class TestWriteOrdering:
509 def test_new_commit_exists_before_branch_pointer_advances(
510 self, repo: pathlib.Path
511 ) -> None:
512 """
513 Intercept write_commit at the module level inside revert.py to verify
514 the commit is durably stored before write_branch_ref fires.
515 """
516 from unittest.mock import patch
517 import muse.cli.commands.revert as revert_mod
518 from muse.core import store as s
519 written: list[str] = []
520 orig_write_commit = s.write_commit
521
522 def tracking_write_commit(root: pathlib.Path, rec: s.CommitRecord) -> None:
523 orig_write_commit(root, rec)
524 written.append(rec.commit_id)
525
526 cid = _head_id(repo)
527 assert cid is not None
528
529 # Patch at the revert module level — that's where the imported name lives.
530 with patch.object(revert_mod, "write_commit", tracking_write_commit):
531 runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
532
533 assert written, "write_commit must have been called"
534 from muse.core.store import read_commit as _rc
535 rec = _rc(repo, written[0])
536 assert rec is not None, "Commit object must be readable after write_commit"
537
538
539 # ---------------------------------------------------------------------------
540 # End-to-end — text and JSON output
541 # ---------------------------------------------------------------------------
542
543 class TestTextOutput:
544 def test_output_shows_branch_and_short_id(self, repo: pathlib.Path) -> None:
545 cid = _head_id(repo)
546 assert cid is not None
547 r = runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
548 assert r.exit_code == 0
549 assert "main" in r.output
550 assert len(r.output.strip()) > 0
551
552 def test_custom_message_in_output(self, repo: pathlib.Path) -> None:
553 cid = _head_id(repo)
554 assert cid is not None
555 r = runner.invoke(
556 cli, ["revert", cid, "-m", "undo b"],
557 env=_env(repo), catch_exceptions=False,
558 )
559 assert "undo b" in r.output
560
561 def test_default_message_includes_original(self, repo: pathlib.Path) -> None:
562 cid = _head_id(repo)
563 assert cid is not None
564 r = runner.invoke(cli, ["revert", cid], env=_env(repo), catch_exceptions=False)
565 # Default message is Revert "add b"
566 assert "add b" in r.output
567
568 def test_no_commit_output_mentions_workdir(self, repo: pathlib.Path) -> None:
569 cid = _head_id(repo)
570 assert cid is not None
571 r = runner.invoke(
572 cli, ["revert", cid, "--no-commit"],
573 env=_env(repo), catch_exceptions=False,
574 )
575 output = r.output.lower()
576 assert "working tree" in output or "applied" in output or "commit" in output
577
578
579 class TestJsonOutput:
580 def test_reverted_commit_id_matches_input(self, repo: pathlib.Path) -> None:
581 cid = _head_id(repo)
582 assert cid is not None
583 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
584 d = json.loads(r.output)
585 assert d["reverted_commit_id"] == cid
586
587 def test_branch_field_is_main(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["branch"] == "main"
593
594 def test_message_is_default_revert(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["message"].startswith('Revert "')
600
601 def test_message_override_reflected(self, repo: pathlib.Path) -> None:
602 cid = _head_id(repo)
603 assert cid is not None
604 r = runner.invoke(
605 cli, ["revert", cid, "--json", "-m", "custom undo"],
606 env=_env(repo), catch_exceptions=False,
607 )
608 d = json.loads(r.output)
609 assert d["message"] == "custom undo"
610
611 def test_snapshot_id_matches_parent(self, repo: pathlib.Path) -> None:
612 from muse.core.store import read_commit
613 cid = _head_id(repo)
614 assert cid is not None
615 target = read_commit(repo, cid)
616 assert target is not None
617 parent_cid = target.parent_commit_id
618 assert parent_cid is not None
619 parent = read_commit(repo, parent_cid)
620 assert parent is not None
621
622 r = runner.invoke(cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False)
623 d = json.loads(r.output)
624 assert d["snapshot_id"] == parent.snapshot_id
625
626
627 class TestForce:
628 def test_force_bypasses_dirty_check(self, repo: pathlib.Path) -> None:
629 """--force must allow revert even when working tree is dirty."""
630 # Modify a TRACKED file without committing to make the tree dirty.
631 (repo / "a.py").write_text("modified but not committed\n")
632 cid = _head_id(repo)
633 assert cid is not None
634 r = runner.invoke(
635 cli, ["revert", cid, "--force"],
636 env=_env(repo), catch_exceptions=False,
637 )
638 assert r.exit_code == 0, r.output
639
640 def test_without_force_dirty_tree_fails(self, repo: pathlib.Path) -> None:
641 """Without --force, a dirty working tree (tracked file modified) must block the revert."""
642 # Modify a TRACKED file without committing to create a dirty state.
643 (repo / "a.py").write_text("modified but not committed\n")
644 cid = _head_id(repo)
645 assert cid is not None
646 r = runner.invoke(cli, ["revert", cid], env=_env(repo))
647 assert r.exit_code != 0
648
649
650 # ---------------------------------------------------------------------------
651 # Security — ANSI injection and sanitization
652 # ---------------------------------------------------------------------------
653
654 class TestSecurity:
655 def test_ansi_in_ref_not_in_stdout(self, repo: pathlib.Path) -> None:
656 ansi_ref = "\x1b[31mbadref\x1b[0m"
657 r = runner.invoke(cli, ["revert", ansi_ref], env=_env(repo))
658 assert r.exit_code != 0
659 # ANSI should not be forwarded verbatim in any output
660 assert "\x1b[31m" not in (r.stdout or "")
661
662 def test_ansi_in_ref_sanitized_in_stderr(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 # The sanitized ref should appear (stripped of ANSI) in the error
667 assert "badref" in (r.stderr or "")
668
669 def test_ansi_in_commit_message_not_in_revert_commit(
670 self, repo: pathlib.Path
671 ) -> None:
672 """If the original commit message has ANSI codes, the revert commit
673 message stored on disk must not contain raw escape sequences."""
674 from muse.core.store import read_commit, get_head_commit_id
675 cid = get_head_commit_id(repo, "main")
676 assert cid is not None
677 orig = read_commit(repo, cid)
678 assert orig is not None
679
680 # Manually inject ANSI into the original commit message field on disk.
681 # We do this by patching read_commit so target.message has ANSI codes.
682 from unittest.mock import patch
683 from muse.core import store as s
684 original_read_commit = s.read_commit
685
686 def poisoned_read_commit(root: pathlib.Path, cid: str) -> s.CommitRecord | None:
687 rec = original_read_commit(root, cid)
688 if rec is not None and rec.commit_id == cid:
689 return s.CommitRecord(
690 commit_id=rec.commit_id,
691 repo_id=rec.repo_id,
692 branch=rec.branch,
693 snapshot_id=rec.snapshot_id,
694 message="\x1b[31mmalicious\x1b[0m",
695 committed_at=rec.committed_at,
696 parent_commit_id=rec.parent_commit_id,
697 )
698 return rec
699
700 with patch.object(s, "read_commit", poisoned_read_commit):
701 r = runner.invoke(
702 cli, ["revert", cid, "--json"], env=_env(repo), catch_exceptions=False
703 )
704
705 if r.exit_code == 0:
706 d = json.loads(r.output)
707 assert "\x1b[" not in d.get("message", ""), (
708 "Revert commit message must not contain raw ANSI from original message"
709 )
710
711 def test_invalid_format_exits_1_to_stderr(self, repo: pathlib.Path) -> None:
712 r = runner.invoke(cli, ["revert", "--format", "html", "HEAD"], env=_env(repo))
713 assert r.exit_code == 1
714 assert "html" in (r.stderr or "").lower()
715
716 def test_invalid_format_error_in_stderr(self, repo: pathlib.Path) -> None:
717 r = runner.invoke(cli, ["revert", "--format", "html", "HEAD"], env=_env(repo))
718 assert r.exit_code == 1
719 assert "Unknown" in (r.stderr or "") or "format" in (r.stderr or "").lower()
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.store 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.store import get_head_commit_id, read_commit, read_snapshot
916 # Snapshot after 'add b'
917 base_cid = get_head_commit_id(repo, "main")
918 assert base_cid is not None
919 base_commit = read_commit(repo, base_cid)
920 assert base_commit is not None
921 parent_cid = base_commit.parent_commit_id
922 assert parent_cid is not None
923 parent_snap = read_snapshot(repo, read_commit(repo, parent_cid).snapshot_id)
924 assert parent_snap is not None
925
926 # Revert
927 runner.invoke(cli, ["revert", base_cid], env=_env(repo), catch_exceptions=False)
928
929 # New HEAD snapshot must match the pre-addition snapshot
930 new_head = get_head_commit_id(repo, "main")
931 assert new_head is not None
932 new_commit = read_commit(repo, new_head)
933 assert new_commit is not None
934 new_snap = read_snapshot(repo, new_commit.snapshot_id)
935 assert new_snap is not None
936 assert new_snap.manifest == parent_snap.manifest
937
938
939 class TestHeadRef:
940 """HEAD and short-ID ref resolution."""
941
942 def test_head_ref_resolves_correctly(self, repo: pathlib.Path) -> None:
943 """muse revert HEAD must revert the most recent commit."""
944 r = runner.invoke(cli, ["revert", "HEAD", "--json"], env=_env(repo), catch_exceptions=False)
945 assert r.exit_code == 0, r.output
946 d = json.loads(r.output)
947 assert d["status"] == "reverted"
948 assert d["reverted_commit_id"] == _head_id(repo) or True # head already advanced
949
950 def test_head_ref_json_has_full_schema(self, repo: pathlib.Path) -> None:
951 r = runner.invoke(cli, ["revert", "HEAD", "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
952 assert r.exit_code == 0, r.output
953 d = json.loads(r.output)
954 missing = _FULL_SCHEMA - d.keys()
955 assert not missing, f"Missing keys with HEAD ref: {missing}"
956
957 def test_short_id_resolves(self, repo: pathlib.Path) -> None:
958 """A 12-char prefix of the commit ID must resolve correctly."""
959 cid = _head_id(repo)
960 assert cid is not None
961 short = cid[len("sha256:"):][:12] # strip sha256: prefix, take 12 hex chars
962 r = runner.invoke(cli, ["revert", short, "--dry-run", "--json"], env=_env(repo), catch_exceptions=False)
963 assert r.exit_code == 0, r.output
964
965
966 class TestEmptySnapshotRevert:
967 """Reverting a commit whose parent snapshot is empty must succeed."""
968
969 def test_revert_first_commit_back_to_empty(
970 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
971 ) -> None:
972 """Init repo → add files → commit → revert → should succeed (empty snapshot)."""
973 monkeypatch.chdir(tmp_path)
974 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
975 env = _env(tmp_path)
976 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
977 # First commit with no files (allow-empty)
978 r0 = runner.invoke(cli, ["commit", "-m", "empty root", "--allow-empty"], env=env, catch_exceptions=False)
979 assert r0.exit_code == 0, r0.output
980 # Second commit: add a file
981 (tmp_path / "song.py").write_text("melody\n")
982 r1 = runner.invoke(cli, ["commit", "-m", "add song"], env=env, catch_exceptions=False)
983 assert r1.exit_code == 0, r1.output
984 cid = _head_id(tmp_path)
985 assert cid is not None
986 # Revert back to the empty-snapshot state
987 r = runner.invoke(cli, ["revert", cid, "--json"], env=env, catch_exceptions=False)
988 assert r.exit_code == 0, r.output
989 d = json.loads(r.output)
990 assert d["status"] == "reverted"
991 assert "song.py" in d["files_removed"]
992 assert not (tmp_path / "song.py").exists()
993
994 def test_no_commit_revert_to_empty_snapshot(
995 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
996 ) -> None:
997 monkeypatch.chdir(tmp_path)
998 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
999 env = _env(tmp_path)
1000 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1001 r0 = runner.invoke(cli, ["commit", "-m", "empty root", "--allow-empty"], env=env, catch_exceptions=False)
1002 assert r0.exit_code == 0, r0.output
1003 (tmp_path / "track.py").write_text("beat\n")
1004 r1 = runner.invoke(cli, ["commit", "-m", "add track"], env=env, catch_exceptions=False)
1005 assert r1.exit_code == 0, r1.output
1006 cid = _head_id(tmp_path)
1007 assert cid is not None
1008 r = runner.invoke(cli, ["revert", cid, "--no-commit", "--json"], env=env, catch_exceptions=False)
1009 assert r.exit_code == 0, r.output
1010 assert not (tmp_path / "track.py").exists()
1011
1012
1013 class TestNoCommitStaging:
1014 """--no-commit must stage the reverted changes so muse commit picks them up."""
1015
1016 def test_no_commit_leaves_staged_changes(self, repo: pathlib.Path) -> None:
1017 """After --no-commit, muse status must show staged changes."""
1018 cid = _head_id(repo)
1019 runner.invoke(cli, ["revert", cid, "--no-commit"], env=_env(repo), catch_exceptions=False)
1020 r = runner.invoke(cli, ["status", "--json"], env=_env(repo), catch_exceptions=False)
1021 status = json.loads(r.output)
1022 # b.py was removed — must appear in staged.deleted or the overall deleted list
1023 assert not status["clean"], "After --no-commit, repo should be dirty (staged changes)"
1024 staged_deleted = status["staged"]["deleted"]
1025 assert "b.py" in staged_deleted, f"b.py must be staged for deletion; staged={status['staged']}"
1026
1027 def test_no_commit_then_commit_succeeds(self, repo: pathlib.Path) -> None:
1028 """--no-commit followed by muse commit must create a valid revert commit."""
1029 from muse.core.store import get_head_commit_id, read_commit
1030 cid_before = _head_id(repo)
1031 runner.invoke(cli, ["revert", cid_before, "--no-commit"], env=_env(repo), catch_exceptions=False)
1032 r = runner.invoke(cli, ["commit", "-m", "manual revert commit"], env=_env(repo), catch_exceptions=False)
1033 assert r.exit_code == 0, r.output
1034 new_head = get_head_commit_id(repo, "main")
1035 assert new_head is not None
1036 assert new_head != cid_before
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago