test_diff_pathspec.py
python
sha256:e6186c2a5be8e7a1361b66ad64d74d3ed1865f3e713c55e7931b4c983e3c9df6
fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived …
Sonnet 5
patch
6 hours ago
| 1 | """TDD — muse#36: muse diff treats a bare path as a commit ref instead of a pathspec. |
| 2 | |
| 3 | Covers the ticket's acceptance criteria: |
| 4 | - `muse diff <path>` scopes by path when the token is not a valid ref but is |
| 5 | a tracked path. |
| 6 | - `muse diff -- <path>` (argparse consumes the literal "--" before run() ever |
| 7 | sees args, so this exercises the identical code path as the bare case). |
| 8 | - Ref-vs-path ambiguity: a token that is both a valid ref and a tracked path |
| 9 | resolves as a ref (the documented, sanctioned alternative when a literal |
| 10 | "--" can't be observed after argparse parsing). |
| 11 | - Consistent exit code for a token that resolves as neither ref nor path. |
| 12 | - --path/-p unaffected (out of scope per the ticket — must keep working). |
| 13 | """ |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import json |
| 17 | import os |
| 18 | import pathlib |
| 19 | |
| 20 | import pytest |
| 21 | |
| 22 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 23 | |
| 24 | runner = CliRunner() |
| 25 | |
| 26 | |
| 27 | def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: |
| 28 | saved = os.getcwd() |
| 29 | try: |
| 30 | os.chdir(repo) |
| 31 | return runner.invoke(None, args) |
| 32 | finally: |
| 33 | os.chdir(saved) |
| 34 | |
| 35 | |
| 36 | def _diff(repo: pathlib.Path, *extra: str) -> InvokeResult: |
| 37 | return _invoke(repo, ["diff", *extra]) |
| 38 | |
| 39 | |
| 40 | def _commit(repo: pathlib.Path, msg: str) -> InvokeResult: |
| 41 | _invoke(repo, ["code", "add", "."]) |
| 42 | return _invoke(repo, ["commit", "-m", msg]) |
| 43 | |
| 44 | |
| 45 | @pytest.fixture() |
| 46 | def repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 47 | """Repo with one commit and an uncommitted change to a.py, plus sub/b.py.""" |
| 48 | saved = os.getcwd() |
| 49 | try: |
| 50 | os.chdir(tmp_path) |
| 51 | runner.invoke(None, ["init"]) |
| 52 | finally: |
| 53 | os.chdir(saved) |
| 54 | (tmp_path / "a.py").write_text("x = 1\n") |
| 55 | (tmp_path / "sub").mkdir() |
| 56 | (tmp_path / "sub" / "b.py").write_text("y = 1\n") |
| 57 | _commit(tmp_path, "first") |
| 58 | (tmp_path / "a.py").write_text("x = 2\n") # uncommitted change |
| 59 | return tmp_path |
| 60 | |
| 61 | |
| 62 | class TestBarePathScoping: |
| 63 | def test_bare_path_scopes_diff_instead_of_erroring(self, repo: pathlib.Path) -> None: |
| 64 | result = _diff(repo, "a.py", "--json") |
| 65 | assert result.exit_code == 0, result.output |
| 66 | data = json.loads(result.output) |
| 67 | assert data["modified"] == ["a.py"] |
| 68 | |
| 69 | def test_bare_path_matches_text_output_of_explicit_path_flag( |
| 70 | self, repo: pathlib.Path |
| 71 | ) -> None: |
| 72 | bare = _diff(repo, "a.py") |
| 73 | flagged = _diff(repo, "--path", "a.py") |
| 74 | assert bare.exit_code == 0 |
| 75 | assert bare.output == flagged.output |
| 76 | |
| 77 | def test_bare_directory_path_scopes_by_prefix(self, repo: pathlib.Path) -> None: |
| 78 | (repo / "sub" / "b.py").write_text("y = 2\n") |
| 79 | result = _diff(repo, "sub", "--json") |
| 80 | assert result.exit_code == 0, result.output |
| 81 | data = json.loads(result.output) |
| 82 | assert data["modified"] == ["sub/b.py"] |
| 83 | |
| 84 | def test_bare_path_with_no_changes_reports_no_changes( |
| 85 | self, repo: pathlib.Path |
| 86 | ) -> None: |
| 87 | result = _diff(repo, "sub/b.py", "--json") |
| 88 | assert result.exit_code == 0, result.output |
| 89 | data = json.loads(result.output) |
| 90 | assert data["has_changes"] is False |
| 91 | |
| 92 | |
| 93 | class TestDashDashSeparator: |
| 94 | def test_dash_dash_path_scopes_diff(self, repo: pathlib.Path) -> None: |
| 95 | result = _diff(repo, "--", "a.py") |
| 96 | assert result.exit_code == 0, result.output |
| 97 | |
| 98 | def test_dash_dash_path_json_matches_bare_path(self, repo: pathlib.Path) -> None: |
| 99 | bare = _diff(repo, "a.py", "--json") |
| 100 | dashdash = _diff(repo, "--json", "--", "a.py") |
| 101 | assert bare.exit_code == dashdash.exit_code == 0 |
| 102 | assert json.loads(bare.output)["modified"] == json.loads(dashdash.output)["modified"] |
| 103 | |
| 104 | |
| 105 | class TestRefVsPathAmbiguity: |
| 106 | def test_unresolvable_token_still_errors_consistently(self, repo: pathlib.Path) -> None: |
| 107 | """A token that is neither a valid ref nor a tracked path must still |
| 108 | fail clearly — this is NOT a silent no-op.""" |
| 109 | result = _diff(repo, "totally-unknown-thing") |
| 110 | assert result.exit_code != 0 |
| 111 | |
| 112 | def test_valid_ref_still_takes_precedence_over_same_named_path( |
| 113 | self, repo: pathlib.Path |
| 114 | ) -> None: |
| 115 | """Documented divergence: when a token is BOTH a valid ref and a |
| 116 | tracked path, muse always prefers the ref (git would require `--` |
| 117 | to force path interpretation; muse can't observe `--` after argparse |
| 118 | has consumed it, so ref wins deterministically instead).""" |
| 119 | _invoke(repo, ["branch", "a.py"]) # branch literally named "a.py" |
| 120 | result = _diff(repo, "a.py", "--json") |
| 121 | assert result.exit_code == 0, result.output |
| 122 | data = json.loads(result.output) |
| 123 | assert data["to_ref"] == "a.py" |
| 124 | # Ref interpretation: HEAD vs branch "a.py" (same commit) -> no changes, |
| 125 | # NOT a path-scoped working-tree diff (which would show has_changes=True). |
| 126 | assert data["has_changes"] is False |
| 127 | |
| 128 | def test_exit_code_consistent_for_unresolvable_single_positional( |
| 129 | self, repo: pathlib.Path |
| 130 | ) -> None: |
| 131 | """Both the plain and text-mode invocations of an unresolvable token |
| 132 | exit identically (muse#36 problem 3: was inconsistent before).""" |
| 133 | plain = _diff(repo, "nonexistent-ref-or-path") |
| 134 | dashdash = _diff(repo, "--", "nonexistent-ref-or-path") |
| 135 | assert plain.exit_code == dashdash.exit_code |
| 136 | assert plain.exit_code != 0 |
| 137 | |
| 138 | |
| 139 | class TestPathFlagUnaffected: |
| 140 | """Out of scope per the ticket: --path/-p must keep working unchanged.""" |
| 141 | |
| 142 | def test_path_flag_still_works(self, repo: pathlib.Path) -> None: |
| 143 | result = _diff(repo, "--path", "a.py", "--json") |
| 144 | assert result.exit_code == 0, result.output |
| 145 | assert json.loads(result.output)["modified"] == ["a.py"] |
| 146 | |
| 147 | def test_short_path_flag_still_works(self, repo: pathlib.Path) -> None: |
| 148 | result = _diff(repo, "-p", "a.py", "--json") |
| 149 | assert result.exit_code == 0, result.output |
| 150 | assert json.loads(result.output)["modified"] == ["a.py"] |
| 151 | |
| 152 | |
| 153 | class TestDefaultAndTwoRefFormsUnaffected: |
| 154 | def test_no_args_default_diff_unaffected(self, repo: pathlib.Path) -> None: |
| 155 | result = _diff(repo, "--json") |
| 156 | assert result.exit_code == 0, result.output |
| 157 | assert json.loads(result.output)["modified"] == ["a.py"] |
| 158 | |
| 159 | def test_two_ref_form_unaffected(self, repo: pathlib.Path) -> None: |
| 160 | result = _diff(repo, "HEAD", "HEAD", "--json") |
| 161 | assert result.exit_code == 0, result.output |
| 162 | assert json.loads(result.output)["has_changes"] is False |
| 163 | |
| 164 | def test_single_valid_ref_unaffected(self, repo: pathlib.Path) -> None: |
| 165 | """A bare positional that IS a real ref keeps its existing |
| 166 | HEAD-vs-that-commit meaning — this is the 100%-unchanged case.""" |
| 167 | result = _diff(repo, "HEAD", "--json") |
| 168 | assert result.exit_code == 0, result.output |
| 169 | data = json.loads(result.output) |
| 170 | assert data["to_ref"] == "HEAD" |
| 171 | assert data["has_changes"] is False |
File History
1 commit
sha256:e6186c2a5be8e7a1361b66ad64d74d3ed1865f3e713c55e7931b4c983e3c9df6
fix: get_signing_identity's MUSE_AGENT_KEY_FD path derived …
Sonnet 5
patch
6 hours ago