test_checkout_autostash.py
python
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago
| 1 | """Tests for ``muse checkout --autostash``. |
| 2 | |
| 3 | Coverage matrix |
| 4 | --------------- |
| 5 | Unit |
| 6 | - ``_stash_push_programmatic`` / ``_stash_pop_programmatic`` in isolation |
| 7 | - ``_apply_autostash`` text vs JSON mode output |
| 8 | |
| 9 | Integration (full CLI round-trips via CliRunner) |
| 10 | - clean tree: --autostash is a no-op (no stash entry created) |
| 11 | - dirty tree: stash pushed, branch switched, stash popped |
| 12 | - dirty tree, --json mode: correct JSON on stdout; autostash lines on stderr |
| 13 | - stash pop restores modified file content on the new branch |
| 14 | - stash pop restores newly-added files that don't exist in target branch |
| 15 | - ``-b`` + ``--autostash``: new-branch creation ignores autostash (no push) |
| 16 | - ``--autostash`` + ``--force`` is rejected |
| 17 | - ``--autostash`` + ``--merge`` is rejected |
| 18 | - ``--autostash`` + ``--dry-run``: no stash created; exit 0 if branch exists |
| 19 | - already-on same branch: autostash no-op, no stash entry |
| 20 | - guard.py error message includes all four remedies when dirty without flags |
| 21 | - pop failure (corrupt stash): warns to stderr, does NOT re-raise |
| 22 | |
| 23 | The suite uses only stdlib + pytest; no mocking of internals. Every test |
| 24 | operates against a real, hermetic Muse repo in tmp_path. |
| 25 | """ |
| 26 | |
| 27 | from __future__ import annotations |
| 28 | |
| 29 | import io |
| 30 | import json |
| 31 | import os |
| 32 | import pathlib |
| 33 | import sys |
| 34 | import contextlib |
| 35 | |
| 36 | import pytest |
| 37 | |
| 38 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 39 | from muse.core.store import read_current_branch |
| 40 | from muse.cli.commands.stash import ( |
| 41 | _stash_push_programmatic, |
| 42 | _stash_pop_programmatic, |
| 43 | _load_stash, |
| 44 | ) |
| 45 | from muse.cli.commands.checkout import _apply_autostash |
| 46 | |
| 47 | runner = CliRunner() |
| 48 | |
| 49 | |
| 50 | # ── Helpers ─────────────────────────────────────────────────────────────────── |
| 51 | |
| 52 | |
| 53 | def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: |
| 54 | saved = os.getcwd() |
| 55 | try: |
| 56 | os.chdir(repo) |
| 57 | return runner.invoke(None, args) |
| 58 | finally: |
| 59 | os.chdir(saved) |
| 60 | |
| 61 | |
| 62 | def _commit(repo: pathlib.Path, files: dict[str, str], msg: str = "commit") -> None: |
| 63 | for name, content in files.items(): |
| 64 | (repo / name).write_text(content, encoding="utf-8") |
| 65 | _invoke(repo, ["commit", "-m", msg]) |
| 66 | |
| 67 | |
| 68 | def _stash_size(repo: pathlib.Path) -> int: |
| 69 | return len(_load_stash(repo)) |
| 70 | |
| 71 | |
| 72 | # ── Fixtures ────────────────────────────────────────────────────────────────── |
| 73 | |
| 74 | |
| 75 | @pytest.fixture() |
| 76 | def two_branch_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 77 | """Repo with ``main`` and ``feat`` branches with distinct committed files. |
| 78 | |
| 79 | main: a.py="main\\n" |
| 80 | feat: a.py="main\\n", b.py="feat\\n" |
| 81 | """ |
| 82 | saved = os.getcwd() |
| 83 | try: |
| 84 | os.chdir(tmp_path) |
| 85 | runner.invoke(None, ["init"]) |
| 86 | finally: |
| 87 | os.chdir(saved) |
| 88 | |
| 89 | _commit(tmp_path, {"a.py": "main\n"}, "main: initial") |
| 90 | _invoke(tmp_path, ["checkout", "-b", "feat"]) |
| 91 | _commit(tmp_path, {"b.py": "feat\n"}, "feat: add b.py") |
| 92 | _invoke(tmp_path, ["checkout", "main"]) |
| 93 | return tmp_path |
| 94 | |
| 95 | |
| 96 | # ── Unit: _stash_push_programmatic / _stash_pop_programmatic ───────────────── |
| 97 | |
| 98 | |
| 99 | class TestProgrammaticStashAPI: |
| 100 | """``_stash_push_programmatic`` and ``_stash_pop_programmatic`` are the |
| 101 | building blocks for autostash — they must work correctly in isolation.""" |
| 102 | |
| 103 | def test_push_returns_none_when_clean(self, two_branch_repo: pathlib.Path) -> None: |
| 104 | """Nothing to stash → None returned, stash stack unchanged.""" |
| 105 | result = _stash_push_programmatic(two_branch_repo) |
| 106 | assert result is None |
| 107 | assert _stash_size(two_branch_repo) == 0 |
| 108 | |
| 109 | def test_push_returns_entry_when_dirty(self, two_branch_repo: pathlib.Path) -> None: |
| 110 | """Modified file → entry returned with correct metadata.""" |
| 111 | (two_branch_repo / "a.py").write_text("modified\n", encoding="utf-8") |
| 112 | entry = _stash_push_programmatic(two_branch_repo, message="wip") |
| 113 | assert entry is not None |
| 114 | assert entry["message"] == "wip" |
| 115 | assert entry["branch"] == "main" |
| 116 | assert "a.py" in entry["delta"] |
| 117 | assert _stash_size(two_branch_repo) == 1 |
| 118 | |
| 119 | def test_push_restores_working_tree_to_head(self, two_branch_repo: pathlib.Path) -> None: |
| 120 | """After push, working tree matches HEAD (file reverted to committed state).""" |
| 121 | (two_branch_repo / "a.py").write_text("modified\n", encoding="utf-8") |
| 122 | _stash_push_programmatic(two_branch_repo) |
| 123 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "main\n" |
| 124 | |
| 125 | def test_push_captures_added_files(self, two_branch_repo: pathlib.Path) -> None: |
| 126 | """Newly-added files (not in HEAD) are captured in the stash delta.""" |
| 127 | (two_branch_repo / "new.py").write_text("new\n", encoding="utf-8") |
| 128 | # new.py is untracked — push captures it only if the plugin sees it. |
| 129 | # In muse, snapshot includes all non-ignored files, so it IS in delta. |
| 130 | entry = _stash_push_programmatic(two_branch_repo) |
| 131 | if entry is not None: |
| 132 | # If captured, it must be in delta |
| 133 | assert "new.py" in entry["delta"] |
| 134 | |
| 135 | def test_pop_raises_on_empty_stash(self, two_branch_repo: pathlib.Path) -> None: |
| 136 | with pytest.raises(ValueError, match="No stash entries"): |
| 137 | _stash_pop_programmatic(two_branch_repo) |
| 138 | |
| 139 | def test_pop_raises_on_out_of_range_index(self, two_branch_repo: pathlib.Path) -> None: |
| 140 | (two_branch_repo / "a.py").write_text("v2\n", encoding="utf-8") |
| 141 | _stash_push_programmatic(two_branch_repo) |
| 142 | with pytest.raises(ValueError, match="out of range"): |
| 143 | _stash_pop_programmatic(two_branch_repo, index=5) |
| 144 | |
| 145 | def test_pop_restores_file_content(self, two_branch_repo: pathlib.Path) -> None: |
| 146 | """Pop restores the exact bytes that were stashed.""" |
| 147 | (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") |
| 148 | _stash_push_programmatic(two_branch_repo) |
| 149 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "main\n" |
| 150 | _stash_pop_programmatic(two_branch_repo) |
| 151 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "dirty\n" |
| 152 | |
| 153 | def test_pop_removes_entry_from_stack(self, two_branch_repo: pathlib.Path) -> None: |
| 154 | (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") |
| 155 | _stash_push_programmatic(two_branch_repo) |
| 156 | assert _stash_size(two_branch_repo) == 1 |
| 157 | _stash_pop_programmatic(two_branch_repo) |
| 158 | assert _stash_size(two_branch_repo) == 0 |
| 159 | |
| 160 | def test_push_pop_roundtrip_message_preserved(self, two_branch_repo: pathlib.Path) -> None: |
| 161 | (two_branch_repo / "a.py").write_text("v3\n", encoding="utf-8") |
| 162 | _stash_push_programmatic(two_branch_repo, message="WIP on main: autostash") |
| 163 | entry = _stash_pop_programmatic(two_branch_repo) |
| 164 | assert entry["message"] == "WIP on main: autostash" |
| 165 | |
| 166 | |
| 167 | # ── Unit: _apply_autostash output ──────────────────────────────────────────── |
| 168 | |
| 169 | |
| 170 | class TestApplyAutostash: |
| 171 | """``_apply_autostash`` must emit the right text and use the right streams.""" |
| 172 | |
| 173 | def test_text_mode_prints_to_stdout(self, two_branch_repo: pathlib.Path) -> None: |
| 174 | (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") |
| 175 | _stash_push_programmatic(two_branch_repo) |
| 176 | out = io.StringIO() |
| 177 | with contextlib.redirect_stdout(out): |
| 178 | _apply_autostash(two_branch_repo, "text") |
| 179 | assert "Applied autostash" in out.getvalue() |
| 180 | |
| 181 | def test_json_mode_prints_to_stderr(self, two_branch_repo: pathlib.Path) -> None: |
| 182 | (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") |
| 183 | _stash_push_programmatic(two_branch_repo) |
| 184 | out = io.StringIO() |
| 185 | err = io.StringIO() |
| 186 | with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): |
| 187 | _apply_autostash(two_branch_repo, "json") |
| 188 | assert out.getvalue() == "" |
| 189 | assert "autostash" in err.getvalue() |
| 190 | |
| 191 | def test_corrupt_stash_warns_stderr_does_not_raise(self, two_branch_repo: pathlib.Path) -> None: |
| 192 | """Missing objects → warning on stderr, no exception propagation.""" |
| 193 | (two_branch_repo / "a.py").write_text("dirty\n", encoding="utf-8") |
| 194 | entry = _stash_push_programmatic(two_branch_repo) |
| 195 | assert entry is not None |
| 196 | # Corrupt the object store by zeroing the object file for a.py |
| 197 | obj_id = entry["delta"]["a.py"] |
| 198 | obj_path = two_branch_repo / ".muse" / "objects" / obj_id[:2] / obj_id[2:] |
| 199 | if obj_path.exists(): |
| 200 | obj_path.chmod(0o644) |
| 201 | obj_path.write_bytes(b"") # zero it out → read_object returns None |
| 202 | err = io.StringIO() |
| 203 | with contextlib.redirect_stderr(err): |
| 204 | _apply_autostash(two_branch_repo, "text") # must not raise |
| 205 | assert "stash@{0}" in err.getvalue() or "autostash" in err.getvalue() |
| 206 | |
| 207 | |
| 208 | # ── Integration: --autostash CLI end-to-end ─────────────────────────────────── |
| 209 | |
| 210 | |
| 211 | class TestAutostashCLI: |
| 212 | """Full CLI round-trips through CliRunner.""" |
| 213 | |
| 214 | # ── clean tree ──────────────────────────────────────────────────────────── |
| 215 | |
| 216 | def test_clean_tree_no_stash_entry_created(self, two_branch_repo: pathlib.Path) -> None: |
| 217 | """--autostash on a clean tree → switch succeeds, stash stack stays empty.""" |
| 218 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 219 | assert result.exit_code == 0 |
| 220 | assert read_current_branch(two_branch_repo) == "feat" |
| 221 | assert _stash_size(two_branch_repo) == 0 |
| 222 | |
| 223 | def test_clean_tree_no_autostash_message(self, two_branch_repo: pathlib.Path) -> None: |
| 224 | """No stash push message printed when tree is already clean.""" |
| 225 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 226 | assert "Stashing" not in result.output |
| 227 | assert "Applied autostash" not in result.output |
| 228 | |
| 229 | # ── dirty tree ──────────────────────────────────────────────────────────── |
| 230 | |
| 231 | def test_dirty_tree_switch_succeeds(self, two_branch_repo: pathlib.Path) -> None: |
| 232 | """Dirty tracked file does not block checkout when --autostash is used.""" |
| 233 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 234 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 235 | assert result.exit_code == 0, result.stderr |
| 236 | assert read_current_branch(two_branch_repo) == "feat" |
| 237 | |
| 238 | def test_dirty_tree_stash_popped_after_switch(self, two_branch_repo: pathlib.Path) -> None: |
| 239 | """After the switch, stash stack is empty (pop happened).""" |
| 240 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 241 | _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 242 | assert _stash_size(two_branch_repo) == 0 |
| 243 | |
| 244 | def test_dirty_tree_changes_restored_on_new_branch(self, two_branch_repo: pathlib.Path) -> None: |
| 245 | """Stashed changes are visible in the working tree after arriving on feat.""" |
| 246 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 247 | _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 248 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" |
| 249 | |
| 250 | def test_text_mode_prints_stash_then_applied(self, two_branch_repo: pathlib.Path) -> None: |
| 251 | """Text mode: 'Stashing…' before switch, 'Applied autostash.' after.""" |
| 252 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 253 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 254 | assert "Stashing" in result.output |
| 255 | assert "Applied autostash" in result.output |
| 256 | |
| 257 | # ── JSON mode ───────────────────────────────────────────────────────────── |
| 258 | |
| 259 | def test_json_mode_stdout_is_valid_json(self, two_branch_repo: pathlib.Path) -> None: |
| 260 | """In --json mode, stdout must be parseable JSON (no autostash noise).""" |
| 261 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 262 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "--json", "feat"]) |
| 263 | assert result.exit_code == 0, result.stderr |
| 264 | # stdout has two lines: checkout JSON + possibly "Stashing..." but |
| 265 | # Stashing goes to stdout in text mode. In JSON mode it must be |
| 266 | # parseable — find the JSON line. |
| 267 | lines = [l for l in result.output.strip().splitlines() if l.strip().startswith("{")] |
| 268 | assert len(lines) >= 1 |
| 269 | payload = json.loads(lines[0]) |
| 270 | assert payload["action"] == "switched" |
| 271 | assert payload["branch"] == "feat" |
| 272 | |
| 273 | def test_json_mode_applied_autostash_goes_to_stderr(self, two_branch_repo: pathlib.Path) -> None: |
| 274 | """'Applied autostash' must not appear on stdout in JSON mode.""" |
| 275 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 276 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "--json", "feat"]) |
| 277 | assert "Applied autostash" not in result.output |
| 278 | |
| 279 | # ── new file survives switch ────────────────────────────────────────────── |
| 280 | |
| 281 | def test_new_file_on_main_survives_to_feat(self, two_branch_repo: pathlib.Path) -> None: |
| 282 | """A file added only on main (not committed) is stashed and popped onto feat.""" |
| 283 | (two_branch_repo / "wip.py").write_text("todo\n", encoding="utf-8") |
| 284 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 285 | assert result.exit_code == 0, result.stderr |
| 286 | # wip.py should be restored on feat (it was in the stash delta) |
| 287 | assert (two_branch_repo / "wip.py").read_text(encoding="utf-8") == "todo\n" |
| 288 | |
| 289 | # ── mutually exclusive flags ────────────────────────────────────────────── |
| 290 | |
| 291 | def test_autostash_and_force_rejected(self, two_branch_repo: pathlib.Path) -> None: |
| 292 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "--force", "feat"]) |
| 293 | assert result.exit_code != 0 |
| 294 | assert "mutually exclusive" in (result.stderr or result.output).lower() |
| 295 | |
| 296 | def test_autostash_and_merge_rejected(self, two_branch_repo: pathlib.Path) -> None: |
| 297 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "--merge", "feat"]) |
| 298 | assert result.exit_code != 0 |
| 299 | assert "mutually exclusive" in (result.stderr or result.output).lower() |
| 300 | |
| 301 | # ── --dry-run + --autostash ──────────────────────────────────────────────── |
| 302 | |
| 303 | def test_dry_run_no_stash_created(self, two_branch_repo: pathlib.Path) -> None: |
| 304 | """--dry-run never actually stashes — it just previews.""" |
| 305 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 306 | _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) |
| 307 | assert _stash_size(two_branch_repo) == 0 |
| 308 | |
| 309 | def test_dry_run_no_branch_switch(self, two_branch_repo: pathlib.Path) -> None: |
| 310 | """--dry-run + --autostash must not change the current branch.""" |
| 311 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 312 | _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) |
| 313 | assert read_current_branch(two_branch_repo) == "main" |
| 314 | |
| 315 | def test_dry_run_working_tree_unchanged(self, two_branch_repo: pathlib.Path) -> None: |
| 316 | """--dry-run must not touch the working tree at all.""" |
| 317 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 318 | _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) |
| 319 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" |
| 320 | |
| 321 | def test_dry_run_exits_zero_on_existing_branch(self, two_branch_repo: pathlib.Path) -> None: |
| 322 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "--dry-run", "feat"]) |
| 323 | assert result.exit_code == 0 |
| 324 | |
| 325 | # ── -b + --autostash ────────────────────────────────────────────────────── |
| 326 | |
| 327 | def test_create_branch_autostash_is_noop(self, two_branch_repo: pathlib.Path) -> None: |
| 328 | """``-b`` stays at HEAD — no snapshot switch, so no stash needed.""" |
| 329 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 330 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "-b", "experiment"]) |
| 331 | assert result.exit_code == 0, result.stderr |
| 332 | assert read_current_branch(two_branch_repo) == "experiment" |
| 333 | # Changes preserved (no stash push on -b) |
| 334 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" |
| 335 | assert _stash_size(two_branch_repo) == 0 |
| 336 | |
| 337 | # ── already on target branch ────────────────────────────────────────────── |
| 338 | |
| 339 | def test_already_on_branch_no_stash(self, two_branch_repo: pathlib.Path) -> None: |
| 340 | """Checking out the current branch is a no-op; autostash not triggered.""" |
| 341 | result = _invoke(two_branch_repo, ["checkout", "--autostash", "main"]) |
| 342 | assert result.exit_code == 0 |
| 343 | assert _stash_size(two_branch_repo) == 0 |
| 344 | |
| 345 | # ── error message (no --autostash) ─────────────────────────────────────── |
| 346 | |
| 347 | def test_dirty_without_autostash_shows_all_remedies(self, two_branch_repo: pathlib.Path) -> None: |
| 348 | """guard.py error lists muse stash, --merge, --force, and --autostash.""" |
| 349 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 350 | result = _invoke(two_branch_repo, ["checkout", "feat"]) |
| 351 | assert result.exit_code != 0 |
| 352 | err = result.stderr or "" |
| 353 | assert "muse stash" in err |
| 354 | assert "--merge" in err |
| 355 | assert "--force" in err |
| 356 | assert "--autostash" in err |
| 357 | |
| 358 | # ── stash message format ────────────────────────────────────────────────── |
| 359 | |
| 360 | def test_autostash_message_matches_git_idiom(self, two_branch_repo: pathlib.Path) -> None: |
| 361 | """Stash message follows git's 'WIP on <branch>: autostash' format.""" |
| 362 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 363 | |
| 364 | # Intercept before pop by pushing manually and inspecting |
| 365 | entry = _stash_push_programmatic( |
| 366 | two_branch_repo, |
| 367 | message=f"WIP on main: autostash", |
| 368 | ) |
| 369 | assert entry is not None |
| 370 | assert entry["message"].startswith("WIP on main") |
| 371 | # Clean up |
| 372 | _stash_pop_programmatic(two_branch_repo) |
| 373 | |
| 374 | # ── round-trip fidelity ─────────────────────────────────────────────────── |
| 375 | |
| 376 | def test_multiple_dirty_files_all_restored(self, two_branch_repo: pathlib.Path) -> None: |
| 377 | """All modified files are stashed and fully restored after the switch.""" |
| 378 | (two_branch_repo / "a.py").write_text("a-wip\n", encoding="utf-8") |
| 379 | (two_branch_repo / "c.py").write_text("new-file\n", encoding="utf-8") |
| 380 | _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 381 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "a-wip\n" |
| 382 | assert (two_branch_repo / "c.py").read_text(encoding="utf-8") == "new-file\n" |
| 383 | assert _stash_size(two_branch_repo) == 0 |
| 384 | |
| 385 | def test_switch_back_restores_original_state(self, two_branch_repo: pathlib.Path) -> None: |
| 386 | """Round-trip main→feat→main with autostash; a.py ends at the wip value.""" |
| 387 | (two_branch_repo / "a.py").write_text("wip\n", encoding="utf-8") |
| 388 | _invoke(two_branch_repo, ["checkout", "--autostash", "feat"]) |
| 389 | assert read_current_branch(two_branch_repo) == "feat" |
| 390 | # Switch back |
| 391 | _invoke(two_branch_repo, ["checkout", "--autostash", "main"]) |
| 392 | assert read_current_branch(two_branch_repo) == "main" |
| 393 | # wip was re-stashed on feat and re-popped on main |
| 394 | assert (two_branch_repo / "a.py").read_text(encoding="utf-8") == "wip\n" |
File History
1 commit
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
26 days ago