"""TDD — ``muse shelf save --path`` scoped shelving. Makes ``muse shelf`` the idiomatic, native drop-in replacement for manually copying files to /tmp as a backup before reverting an in-progress fix to verify a test genuinely fails without it (and restoring afterward). Matches ``git stash push -- `` semantics: only changes under the given path(s) are shelved; everything else in the working tree — including untracked files that don't match — is left completely untouched. """ from __future__ import annotations import json import pathlib from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner cli = None runner = CliRunner() def _env(root: pathlib.Path) -> Mapping[str, str]: return {"MUSE_REPO_ROOT": str(root)} @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Repo with one committed file (fix_file.py).""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False) assert r.exit_code == 0, r.output (tmp_path / "fix_file.py").write_text("original implementation\n") r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False) assert r.exit_code == 0, r.output return tmp_path class TestScopedSaveLeavesOtherFilesUntouched: def test_modified_tracked_file_reverted_untracked_file_untouched( self, repo: pathlib.Path ) -> None: """The exact RED/GREEN verification use case: a fix (modified tracked file) plus a new test (untracked file). Scoping the shelf to the fix must revert only the fix, leaving the new test on disk.""" (repo / "fix_file.py").write_text("FIXED implementation\n") (repo / "new_test.py").write_text("new test content\n") r = runner.invoke( cli, ["shelf", "save", "--path", "fix_file.py", "--json"], env=_env(repo), catch_exceptions=False, ) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["status"] == "shelved" assert data["paths"] == ["fix_file.py"] assert data["files_count"] == 1 assert (repo / "fix_file.py").read_text() == "original implementation\n", ( "the fix must be reverted to HEAD" ) assert (repo / "new_test.py").read_text() == "new test content\n", ( "the untracked test file must be completely untouched" ) def test_unmatched_dirty_tracked_file_untouched(self, repo: pathlib.Path) -> None: """A second tracked file with its own dirty, uncommitted edit, not matching the pathspec, must also be left exactly as-is.""" (repo / "other.py").write_text("committed other\n") runner.invoke(cli, ["code", "add", "."], env=_env(repo)) runner.invoke(cli, ["commit", "-m", "add other"], env=_env(repo)) (repo / "fix_file.py").write_text("FIXED implementation\n") (repo / "other.py").write_text("unrelated dirty edit\n") r = runner.invoke( cli, ["shelf", "save", "--path", "fix_file.py", "--json"], env=_env(repo), catch_exceptions=False, ) assert r.exit_code == 0, r.output assert (repo / "fix_file.py").read_text() == "original implementation\n" assert (repo / "other.py").read_text() == "unrelated dirty edit\n", ( "an out-of-scope dirty tracked file must not be touched or reverted" ) def test_pop_restores_only_the_scoped_fix(self, repo: pathlib.Path) -> None: (repo / "fix_file.py").write_text("FIXED implementation\n") (repo / "new_test.py").write_text("new test content\n") runner.invoke( cli, ["shelf", "save", "--path", "fix_file.py"], env=_env(repo), ) r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["restored"] == 1 assert (repo / "fix_file.py").read_text() == "FIXED implementation\n" assert (repo / "new_test.py").read_text() == "new test content\n" class TestScopedSaveNewlyAddedFile: def test_newly_added_in_scope_file_removed_then_restored_on_pop( self, repo: pathlib.Path ) -> None: (repo / "brand_new.py").write_text("brand new\n") (repo / "unrelated.py").write_text("unrelated untracked\n") r = runner.invoke( cli, ["shelf", "save", "--path", "brand_new.py", "--json"], env=_env(repo), catch_exceptions=False, ) assert r.exit_code == 0, r.output assert not (repo / "brand_new.py").exists(), ( "a newly-added in-scope file didn't exist in HEAD, so shelving " "it must remove it from disk" ) assert (repo / "unrelated.py").exists(), "unmatched untracked file must survive" pop_r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False) assert pop_r.exit_code == 0, pop_r.output assert (repo / "brand_new.py").read_text() == "brand new\n" class TestScopedSaveDeletion: def test_deleted_in_scope_file_restored_then_redeleted_on_pop( self, repo: pathlib.Path ) -> None: (repo / "fix_file.py").unlink() r = runner.invoke( cli, ["shelf", "save", "--path", "fix_file.py", "--json"], env=_env(repo), catch_exceptions=False, ) assert r.exit_code == 0, r.output assert (repo / "fix_file.py").read_text() == "original implementation\n", ( "shelving a deletion restores HEAD's content so the deletion " "intent can be popped back later" ) pop_r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False) assert pop_r.exit_code == 0, pop_r.output pop_r_data = json.loads(pop_r.output) assert pop_r_data["deleted"] == 1 assert not (repo / "fix_file.py").exists() class TestScopedSaveDirectoryPrefix: def test_directory_pathspec_matches_nested_files(self, repo: pathlib.Path) -> None: (repo / "sub").mkdir() (repo / "sub" / "nested.py").write_text("v1\n") runner.invoke(cli, ["code", "add", "."], env=_env(repo)) runner.invoke(cli, ["commit", "-m", "add sub"], env=_env(repo)) (repo / "sub" / "nested.py").write_text("v2\n") (repo / "top_level.py").write_text("unrelated untracked\n") r = runner.invoke( cli, ["shelf", "save", "--path", "sub", "--json"], env=_env(repo), catch_exceptions=False, ) assert r.exit_code == 0, r.output assert (repo / "sub" / "nested.py").read_text() == "v1\n" assert (repo / "top_level.py").read_text() == "unrelated untracked\n" class TestScopedSaveNoMatch: def test_no_changes_under_path_reports_nothing_to_shelf( self, repo: pathlib.Path ) -> None: (repo / "untracked_elsewhere.py").write_text("dirty\n") r = runner.invoke( cli, ["shelf", "save", "--path", "fix_file.py", "--json"], env=_env(repo), catch_exceptions=False, ) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["status"] == "nothing_to_shelf" assert data["paths"] == ["fix_file.py"] # The out-of-scope dirty file must survive untouched. assert (repo / "untracked_elsewhere.py").read_text() == "dirty\n" class TestUnscopedBehaviorUnaffected: """Regression guard: muse shelf save with no --path keeps its existing whole-working-tree semantics exactly.""" def test_unscoped_save_still_shelves_everything(self, repo: pathlib.Path) -> None: (repo / "fix_file.py").write_text("FIXED implementation\n") (repo / "new_test.py").write_text("new test content\n") r = runner.invoke(cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["paths"] == [] assert (repo / "fix_file.py").read_text() == "original implementation\n" assert not (repo / "new_test.py").exists(), ( "unscoped shelf save must still shelve untracked files too (unchanged behavior)" ) def test_unscoped_pop_restores_everything(self, repo: pathlib.Path) -> None: (repo / "fix_file.py").write_text("FIXED implementation\n") (repo / "new_test.py").write_text("new test content\n") runner.invoke(cli, ["shelf", "save"], env=_env(repo)) r = runner.invoke(cli, ["shelf", "pop", "--json"], env=_env(repo), catch_exceptions=False) assert r.exit_code == 0, r.output assert (repo / "fix_file.py").read_text() == "FIXED implementation\n" assert (repo / "new_test.py").read_text() == "new test content\n" class TestListAndReadExposePaths: def test_list_json_includes_paths_field(self, repo: pathlib.Path) -> None: (repo / "fix_file.py").write_text("FIXED implementation\n") runner.invoke(cli, ["shelf", "save", "--path", "fix_file.py"], env=_env(repo)) r = runner.invoke(cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["entries"][0]["paths"] == ["fix_file.py"] def test_read_json_includes_paths_field(self, repo: pathlib.Path) -> None: (repo / "fix_file.py").write_text("FIXED implementation\n") runner.invoke(cli, ["shelf", "save", "--path", "fix_file.py"], env=_env(repo)) r = runner.invoke(cli, ["shelf", "read", "--json"], env=_env(repo), catch_exceptions=False) assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["paths"] == ["fix_file.py"] def test_unscoped_entry_paths_field_is_empty_list(self, repo: pathlib.Path) -> None: (repo / "fix_file.py").write_text("FIXED implementation\n") runner.invoke(cli, ["shelf", "save"], env=_env(repo)) r = runner.invoke(cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False) data = json.loads(r.output) assert data["entries"][0]["paths"] == []