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