test_cmd_diff.py
python
sha256:e029dfbf5482b23e127b4793b93953b3e2b5af9ca3aa533e6c6e08a6d1887e17
fixing more failing tests
Human
4 days ago
| 1 | """Comprehensive tests for ``muse diff``. |
| 2 | |
| 3 | Coverage tiers |
| 4 | -------------- |
| 5 | Unit — parser flags, _classify_patch_op, _op_category, _filter_manifest, |
| 6 | _use_color, dead-code removal. |
| 7 | Integration — HEAD vs working tree, staged, unstaged, two-commit diff, |
| 8 | path filtering, --stat, --text, added/deleted/modified counts. |
| 9 | End-to-end — CLI invocations: text and JSON output, --exit-code, --json. |
| 10 | Security — ANSI injection in paths, commit refs. |
| 11 | Stress — 500-file repos, many changes, concurrent reads. |
| 12 | """ |
| 13 | |
| 14 | from __future__ import annotations |
| 15 | |
| 16 | import json |
| 17 | import os |
| 18 | import pathlib |
| 19 | import subprocess |
| 20 | import threading |
| 21 | import time |
| 22 | from collections.abc import Mapping |
| 23 | from typing import TYPE_CHECKING |
| 24 | |
| 25 | import pytest |
| 26 | |
| 27 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 28 | |
| 29 | if TYPE_CHECKING: |
| 30 | import argparse |
| 31 | |
| 32 | from muse.domain import DeleteOp, DomainOp, InsertOp, MoveOp, PatchOp, ReplaceOp |
| 33 | |
| 34 | runner = CliRunner() |
| 35 | |
| 36 | # ────────────────────────────────────────────────────────────────────────────── |
| 37 | # Helpers |
| 38 | # ────────────────────────────────────────────────────────────────────────────── |
| 39 | |
| 40 | |
| 41 | def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: |
| 42 | saved = os.getcwd() |
| 43 | try: |
| 44 | os.chdir(repo) |
| 45 | return runner.invoke(None, args) |
| 46 | finally: |
| 47 | os.chdir(saved) |
| 48 | |
| 49 | |
| 50 | def _diff(repo: pathlib.Path, *extra: str) -> InvokeResult: |
| 51 | return _invoke(repo, ["diff", *extra]) |
| 52 | |
| 53 | |
| 54 | def _commit(repo: pathlib.Path, msg: str) -> InvokeResult: |
| 55 | _invoke(repo, ["code", "add", "."]) |
| 56 | return _invoke(repo, ["commit", "-m", msg]) |
| 57 | |
| 58 | |
| 59 | @pytest.fixture() |
| 60 | def repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 61 | """Initialised repo with one tracked file and one commit.""" |
| 62 | saved = os.getcwd() |
| 63 | try: |
| 64 | os.chdir(tmp_path) |
| 65 | runner.invoke(None, ["init"]) |
| 66 | finally: |
| 67 | os.chdir(saved) |
| 68 | (tmp_path / "a.py").write_text("x = 1\n") |
| 69 | _commit(tmp_path, "first") |
| 70 | return tmp_path |
| 71 | |
| 72 | |
| 73 | # ────────────────────────────────────────────────────────────────────────────── |
| 74 | # Unit — parser flags |
| 75 | # ────────────────────────────────────────────────────────────────────────────── |
| 76 | |
| 77 | |
| 78 | class TestRegisterFlags: |
| 79 | def _parse(self, *args: str) -> "argparse.Namespace": |
| 80 | import argparse |
| 81 | |
| 82 | from muse.cli.commands.diff import register |
| 83 | |
| 84 | p = argparse.ArgumentParser() |
| 85 | sub = p.add_subparsers() |
| 86 | register(sub) |
| 87 | return p.parse_args(["diff", *args]) |
| 88 | |
| 89 | def test_default_json_out_is_false(self) -> None: |
| 90 | ns = self._parse() |
| 91 | assert ns.json_out is False |
| 92 | |
| 93 | def test_json_flag_sets_json_out(self) -> None: |
| 94 | ns = self._parse("--json") |
| 95 | assert ns.json_out is True |
| 96 | |
| 97 | def test_j_shorthand_sets_json_out(self) -> None: |
| 98 | ns = self._parse("-j") |
| 99 | assert ns.json_out is True |
| 100 | |
| 101 | def test_exit_code_long_flag(self) -> None: |
| 102 | ns = self._parse("--exit-code") |
| 103 | assert ns.exit_code is True |
| 104 | |
| 105 | def test_exit_code_short_flag(self) -> None: |
| 106 | ns = self._parse("-z") |
| 107 | assert ns.exit_code is True |
| 108 | |
| 109 | def test_exit_code_default_false(self) -> None: |
| 110 | ns = self._parse() |
| 111 | assert ns.exit_code is False |
| 112 | |
| 113 | def test_staged_flag(self) -> None: |
| 114 | ns = self._parse("--staged") |
| 115 | assert ns.staged is True |
| 116 | |
| 117 | def test_unstaged_flag(self) -> None: |
| 118 | ns = self._parse("--unstaged") |
| 119 | assert ns.unstaged is True |
| 120 | |
| 121 | def test_stat_flag(self) -> None: |
| 122 | ns = self._parse("--stat") |
| 123 | assert ns.stat is True |
| 124 | |
| 125 | def test_text_flag(self) -> None: |
| 126 | ns = self._parse("--text") |
| 127 | assert ns.text is True |
| 128 | |
| 129 | def test_path_flag(self) -> None: |
| 130 | ns = self._parse("-p", "foo.py") |
| 131 | assert "foo.py" in ns.paths |
| 132 | |
| 133 | def test_path_flag_repeatable(self) -> None: |
| 134 | ns = self._parse("-p", "a.py", "-p", "b.py") |
| 135 | assert "a.py" in ns.paths and "b.py" in ns.paths |
| 136 | |
| 137 | |
| 138 | # ────────────────────────────────────────────────────────────────────────────── |
| 139 | # Unit — dead-code removal |
| 140 | # ────────────────────────────────────────────────────────────────────────────── |
| 141 | |
| 142 | |
| 143 | class TestDeadCodeRemoved: |
| 144 | def test_read_branch_removed(self) -> None: |
| 145 | import muse.cli.commands.diff as m |
| 146 | |
| 147 | assert not hasattr(m, "_read_branch"), ( |
| 148 | "_read_branch was a dead wrapper; it should have been deleted" |
| 149 | ) |
| 150 | |
| 151 | |
| 152 | # ────────────────────────────────────────────────────────────────────────────── |
| 153 | # Unit — _filter_manifest |
| 154 | # ────────────────────────────────────────────────────────────────────────────── |
| 155 | |
| 156 | |
| 157 | class TestFilterManifest: |
| 158 | def test_empty_paths_returns_all(self) -> None: |
| 159 | from muse.cli.commands.diff import _filter_manifest |
| 160 | |
| 161 | m = {"a.py": "oid1", "b.py": "oid2"} |
| 162 | assert _filter_manifest(m, []) == m |
| 163 | |
| 164 | def test_exact_file_match(self) -> None: |
| 165 | from muse.cli.commands.diff import _filter_manifest |
| 166 | |
| 167 | m = {"a.py": "oid1", "b.py": "oid2"} |
| 168 | result = _filter_manifest(m, ["a.py"]) |
| 169 | assert result == {"a.py": "oid1"} |
| 170 | |
| 171 | def test_directory_prefix_match(self) -> None: |
| 172 | from muse.cli.commands.diff import _filter_manifest |
| 173 | |
| 174 | m = { |
| 175 | "src/foo.py": "oid1", |
| 176 | "src/bar.py": "oid2", |
| 177 | "tests/test_foo.py": "oid3", |
| 178 | } |
| 179 | result = _filter_manifest(m, ["src"]) |
| 180 | assert set(result) == {"src/foo.py", "src/bar.py"} |
| 181 | |
| 182 | def test_trailing_slash_normalised(self) -> None: |
| 183 | from muse.cli.commands.diff import _filter_manifest |
| 184 | |
| 185 | m = {"src/foo.py": "oid1", "other.py": "oid2"} |
| 186 | assert _filter_manifest(m, ["src/"]) == {"src/foo.py": "oid1"} |
| 187 | |
| 188 | def test_multiple_paths(self) -> None: |
| 189 | from muse.cli.commands.diff import _filter_manifest |
| 190 | |
| 191 | m = {"a.py": "oid1", "b.py": "oid2", "c.py": "oid3"} |
| 192 | result = _filter_manifest(m, ["a.py", "c.py"]) |
| 193 | assert set(result) == {"a.py", "c.py"} |
| 194 | |
| 195 | def test_no_match_returns_empty(self) -> None: |
| 196 | from muse.cli.commands.diff import _filter_manifest |
| 197 | |
| 198 | m = {"a.py": "oid1"} |
| 199 | assert _filter_manifest(m, ["z.py"]) == {} |
| 200 | |
| 201 | |
| 202 | # ────────────────────────────────────────────────────────────────────────────── |
| 203 | # Unit — _use_color |
| 204 | # ────────────────────────────────────────────────────────────────────────────── |
| 205 | |
| 206 | |
| 207 | class TestUseColor: |
| 208 | def test_no_color_env_disables_color( |
| 209 | self, monkeypatch: pytest.MonkeyPatch |
| 210 | ) -> None: |
| 211 | from muse.cli.commands.diff import _use_color |
| 212 | |
| 213 | monkeypatch.setenv("NO_COLOR", "1") |
| 214 | assert _use_color() is False |
| 215 | |
| 216 | def test_dumb_term_disables_color( |
| 217 | self, monkeypatch: pytest.MonkeyPatch |
| 218 | ) -> None: |
| 219 | from muse.cli.commands.diff import _use_color |
| 220 | |
| 221 | monkeypatch.setenv("TERM", "dumb") |
| 222 | assert _use_color() is False |
| 223 | |
| 224 | def test_no_color_env_unset_does_not_force_color( |
| 225 | self, monkeypatch: pytest.MonkeyPatch |
| 226 | ) -> None: |
| 227 | from muse.cli.commands.diff import _use_color |
| 228 | |
| 229 | monkeypatch.delenv("NO_COLOR", raising=False) |
| 230 | monkeypatch.delenv("TERM", raising=False) |
| 231 | # stdout is not a TTY in test; just verify the function returns a bool |
| 232 | assert isinstance(_use_color(), bool) |
| 233 | |
| 234 | |
| 235 | # ────────────────────────────────────────────────────────────────────────────── |
| 236 | # Integration — HEAD vs working tree |
| 237 | # ────────────────────────────────────────────────────────────────────────────── |
| 238 | |
| 239 | |
| 240 | class TestHeadVsWorkingTree: |
| 241 | def test_clean_tree_exits_0(self, repo: pathlib.Path) -> None: |
| 242 | result = _diff(repo) |
| 243 | assert result.exit_code == 0 |
| 244 | assert "No differences" in result.output |
| 245 | |
| 246 | def test_modified_file_detected(self, repo: pathlib.Path) -> None: |
| 247 | (repo / "a.py").write_text("x = 99\n") |
| 248 | result = _diff(repo) |
| 249 | assert result.exit_code == 0 |
| 250 | assert "a.py" in result.output |
| 251 | |
| 252 | def test_added_file_detected(self, repo: pathlib.Path) -> None: |
| 253 | (repo / "new.py").write_text("z = 0\n") |
| 254 | result = _diff(repo) |
| 255 | assert "new.py" in result.output |
| 256 | |
| 257 | def test_deleted_file_detected(self, repo: pathlib.Path) -> None: |
| 258 | (repo / "b.py").write_text("b = 1\n") |
| 259 | _commit(repo, "add b") |
| 260 | (repo / "b.py").unlink() |
| 261 | result = _diff(repo) |
| 262 | assert "b.py" in result.output |
| 263 | |
| 264 | |
| 265 | # ────────────────────────────────────────────────────────────────────────────── |
| 266 | # Integration — JSON schema (including critical bug fix) |
| 267 | # ────────────────────────────────────────────────────────────────────────────── |
| 268 | |
| 269 | |
| 270 | class TestJsonSchema: |
| 271 | """All keys agents depend on must be present.""" |
| 272 | |
| 273 | REQUIRED_KEYS = { |
| 274 | "from_ref", |
| 275 | "to_ref", |
| 276 | "from_commit_id", |
| 277 | "to_commit_id", |
| 278 | "has_changes", |
| 279 | "summary", |
| 280 | "added", |
| 281 | "deleted", |
| 282 | "modified", |
| 283 | "total_changes", |
| 284 | "duration_ms", |
| 285 | "exit_code", |
| 286 | } |
| 287 | |
| 288 | def test_clean_tree_json_keys(self, repo: pathlib.Path) -> None: |
| 289 | result = _diff(repo, "--json") |
| 290 | assert result.exit_code == 0 |
| 291 | data = json.loads(result.output) |
| 292 | missing = self.REQUIRED_KEYS - set(data) |
| 293 | assert not missing, f"Missing keys: {missing}" |
| 294 | |
| 295 | def test_has_changes_false_on_clean_tree(self, repo: pathlib.Path) -> None: |
| 296 | result = _diff(repo, "--json") |
| 297 | data = json.loads(result.output) |
| 298 | assert data["has_changes"] is False |
| 299 | |
| 300 | def test_has_changes_true_on_modified(self, repo: pathlib.Path) -> None: |
| 301 | (repo / "a.py").write_text("x = 99\n") |
| 302 | result = _diff(repo, "--json") |
| 303 | data = json.loads(result.output) |
| 304 | assert data["has_changes"] is True |
| 305 | |
| 306 | def test_from_commit_id_present(self, repo: pathlib.Path) -> None: |
| 307 | result = _diff(repo, "--json") |
| 308 | data = json.loads(result.output) |
| 309 | # from_commit_id should be the HEAD commit SHA |
| 310 | assert data["from_commit_id"] is not None |
| 311 | assert data["from_commit_id"].startswith("sha256:") |
| 312 | assert len(data["from_commit_id"]) == len("sha256:") + 64 |
| 313 | |
| 314 | def test_to_commit_id_null_for_workdir_diff(self, repo: pathlib.Path) -> None: |
| 315 | result = _diff(repo, "--json") |
| 316 | data = json.loads(result.output) |
| 317 | assert data["to_commit_id"] is None |
| 318 | |
| 319 | # ── Critical bug fix: deleted files must be in "deleted", not "modified" ── |
| 320 | |
| 321 | def test_deleted_file_in_deleted_list_not_modified(self, repo: pathlib.Path) -> None: |
| 322 | """Regression test: files deleted from the working tree must appear in |
| 323 | ``deleted``, not ``modified``. The plugin emits a ``patch`` op with |
| 324 | all-delete child ops for file deletions; the JSON categorizer must |
| 325 | recognise this.""" |
| 326 | (repo / "b.py").write_text("b = 2\n") |
| 327 | _commit(repo, "add b") |
| 328 | (repo / "b.py").unlink() |
| 329 | result = _diff(repo, "--json") |
| 330 | data = json.loads(result.output) |
| 331 | assert "b.py" in data["deleted"], f"b.py not in deleted: {data}" |
| 332 | assert "b.py" not in data["modified"], f"b.py wrongly in modified: {data}" |
| 333 | |
| 334 | def test_added_file_in_added_list_not_modified(self, repo: pathlib.Path) -> None: |
| 335 | """New files must appear in ``added``, not ``modified``.""" |
| 336 | (repo / "new.py").write_text("n = 1\n") |
| 337 | result = _diff(repo, "--json") |
| 338 | data = json.loads(result.output) |
| 339 | assert "new.py" in data["added"], f"new.py not in added: {data}" |
| 340 | assert "new.py" not in data["modified"], f"new.py wrongly in modified: {data}" |
| 341 | |
| 342 | def test_modified_file_in_modified_list(self, repo: pathlib.Path) -> None: |
| 343 | (repo / "a.py").write_text("x = 999\n") |
| 344 | result = _diff(repo, "--json") |
| 345 | data = json.loads(result.output) |
| 346 | assert "a.py" in data["modified"] |
| 347 | assert "a.py" not in data["added"] |
| 348 | assert "a.py" not in data["deleted"] |
| 349 | |
| 350 | def test_combined_add_delete_modify(self, repo: pathlib.Path) -> None: |
| 351 | """All three categories correct simultaneously.""" |
| 352 | (repo / "b.py").write_text("b = 2\n") |
| 353 | (repo / "c.py").write_text("c = 3\n") |
| 354 | _commit(repo, "add b and c") |
| 355 | (repo / "b.py").unlink() # deleted |
| 356 | (repo / "c.py").write_text("c = 99\n") # modified |
| 357 | (repo / "d.py").write_text("d = 4\n") # added |
| 358 | result = _diff(repo, "--json") |
| 359 | data = json.loads(result.output) |
| 360 | assert "b.py" in data["deleted"] |
| 361 | assert "c.py" in data["modified"] |
| 362 | assert "d.py" in data["added"] |
| 363 | |
| 364 | def test_added_list_is_sorted(self, repo: pathlib.Path) -> None: |
| 365 | for name in ["z.py", "a2.py", "m.py"]: |
| 366 | (repo / name).write_text(f"x=1\n") |
| 367 | result = _diff(repo, "--json") |
| 368 | data = json.loads(result.output) |
| 369 | assert data["added"] == sorted(data["added"]) |
| 370 | |
| 371 | def test_from_ref_is_head(self, repo: pathlib.Path) -> None: |
| 372 | result = _diff(repo, "--json") |
| 373 | data = json.loads(result.output) |
| 374 | assert data["from_ref"] == "HEAD" |
| 375 | |
| 376 | def test_to_ref_is_working_tree(self, repo: pathlib.Path) -> None: |
| 377 | result = _diff(repo, "--json") |
| 378 | data = json.loads(result.output) |
| 379 | assert data["to_ref"] == "working tree" |
| 380 | |
| 381 | def test_total_changes_matches_op_count(self, repo: pathlib.Path) -> None: |
| 382 | (repo / "a.py").write_text("x = 50\n") |
| 383 | (repo / "b.py").write_text("b = 1\n") |
| 384 | result = _diff(repo, "--json") |
| 385 | data = json.loads(result.output) |
| 386 | total = len(data["added"]) + len(data["deleted"]) + len(data["modified"]) |
| 387 | # total_changes counts plugin ops, not files; it can exceed the file count |
| 388 | # if a file has multiple symbol ops, but it should be >= file count. |
| 389 | assert data["total_changes"] >= total |
| 390 | |
| 391 | def test_two_commit_diff_has_commit_ids(self, repo: pathlib.Path) -> None: |
| 392 | from muse.core.refs import get_head_commit_id |
| 393 | |
| 394 | cid1 = get_head_commit_id(repo, "main") |
| 395 | (repo / "b.py").write_text("b = 1\n") |
| 396 | _commit(repo, "second") |
| 397 | cid2 = get_head_commit_id(repo, "main") |
| 398 | result = _diff(repo, cid1 or "", cid2 or "", "--json") |
| 399 | data = json.loads(result.output) |
| 400 | assert data["from_commit_id"] == cid1 |
| 401 | assert data["to_commit_id"] == cid2 |
| 402 | |
| 403 | |
| 404 | # ────────────────────────────────────────────────────────────────────────────── |
| 405 | # Integration — --exit-code |
| 406 | # ────────────────────────────────────────────────────────────────────────────── |
| 407 | |
| 408 | |
| 409 | class TestExitCode: |
| 410 | def test_exit_code_0_on_clean_tree(self, repo: pathlib.Path) -> None: |
| 411 | result = _diff(repo, "--exit-code") |
| 412 | assert result.exit_code == 0 |
| 413 | |
| 414 | def test_exit_code_1_when_changes(self, repo: pathlib.Path) -> None: |
| 415 | (repo / "a.py").write_text("x = 99\n") |
| 416 | result = _diff(repo, "--exit-code") |
| 417 | assert result.exit_code == 1 |
| 418 | |
| 419 | def test_exit_code_with_json_clean(self, repo: pathlib.Path) -> None: |
| 420 | result = _diff(repo, "--exit-code", "--json") |
| 421 | assert result.exit_code == 0 |
| 422 | data = json.loads(result.output) |
| 423 | assert data["has_changes"] is False |
| 424 | |
| 425 | def test_exit_code_with_json_dirty(self, repo: pathlib.Path) -> None: |
| 426 | (repo / "a.py").write_text("x = 99\n") |
| 427 | result = _diff(repo, "--exit-code", "--json") |
| 428 | assert result.exit_code == 1 |
| 429 | data = json.loads(result.output) |
| 430 | assert data["has_changes"] is True |
| 431 | |
| 432 | def test_exit_code_with_stat_clean(self, repo: pathlib.Path) -> None: |
| 433 | result = _diff(repo, "--exit-code", "--stat") |
| 434 | assert result.exit_code == 0 |
| 435 | |
| 436 | def test_exit_code_with_stat_dirty(self, repo: pathlib.Path) -> None: |
| 437 | (repo / "a.py").write_text("x = 99\n") |
| 438 | result = _diff(repo, "--exit-code", "--stat") |
| 439 | assert result.exit_code == 1 |
| 440 | |
| 441 | def test_exit_code_with_text_dirty(self, repo: pathlib.Path) -> None: |
| 442 | (repo / "a.py").write_text("x = 99\n") |
| 443 | result = _diff(repo, "--exit-code", "--text") |
| 444 | assert result.exit_code == 1 |
| 445 | |
| 446 | def test_exit_code_with_text_clean(self, repo: pathlib.Path) -> None: |
| 447 | result = _diff(repo, "--exit-code", "--text") |
| 448 | assert result.exit_code == 0 |
| 449 | |
| 450 | |
| 451 | # ────────────────────────────────────────────────────────────────────────────── |
| 452 | # Integration — two-commit diff |
| 453 | # ────────────────────────────────────────────────────────────────────────────── |
| 454 | |
| 455 | |
| 456 | class TestTwoCommitDiff: |
| 457 | def test_two_commits_exits_0(self, repo: pathlib.Path) -> None: |
| 458 | from muse.core.refs import get_head_commit_id |
| 459 | |
| 460 | cid1 = get_head_commit_id(repo, "main") |
| 461 | (repo / "b.py").write_text("b=1\n") |
| 462 | _commit(repo, "second") |
| 463 | cid2 = get_head_commit_id(repo, "main") |
| 464 | result = _diff(repo, cid1 or "", cid2 or "") |
| 465 | assert result.exit_code == 0 |
| 466 | |
| 467 | def test_two_identical_commits_no_differences(self, repo: pathlib.Path) -> None: |
| 468 | from muse.core.refs import get_head_commit_id |
| 469 | |
| 470 | cid = get_head_commit_id(repo, "main") |
| 471 | result = _diff(repo, cid or "", cid or "") |
| 472 | assert "No differences" in result.output |
| 473 | |
| 474 | def test_invalid_commit_ref_exits_1(self, repo: pathlib.Path) -> None: |
| 475 | result = _diff(repo, "deadbeefdeadbeef") |
| 476 | assert result.exit_code == 1 |
| 477 | |
| 478 | |
| 479 | # ────────────────────────────────────────────────────────────────────────────── |
| 480 | # Integration — --stat |
| 481 | # ────────────────────────────────────────────────────────────────────────────── |
| 482 | |
| 483 | |
| 484 | class TestStat: |
| 485 | def test_stat_clean_tree(self, repo: pathlib.Path) -> None: |
| 486 | result = _diff(repo, "--stat") |
| 487 | assert result.exit_code == 0 |
| 488 | assert "No differences" in result.output |
| 489 | |
| 490 | def test_stat_shows_summary(self, repo: pathlib.Path) -> None: |
| 491 | (repo / "a.py").write_text("x = 50\n") |
| 492 | result = _diff(repo, "--stat") |
| 493 | assert result.exit_code == 0 |
| 494 | # Should contain a human-readable summary (not empty) |
| 495 | assert result.output.strip() != "" |
| 496 | assert "No differences" not in result.output |
| 497 | |
| 498 | |
| 499 | # ────────────────────────────────────────────────────────────────────────────── |
| 500 | # Integration — --text (unified diff) |
| 501 | # ────────────────────────────────────────────────────────────────────────────── |
| 502 | |
| 503 | |
| 504 | class TestTextDiff: |
| 505 | def test_text_clean_tree(self, repo: pathlib.Path) -> None: |
| 506 | result = _diff(repo, "--text") |
| 507 | assert result.exit_code == 0 |
| 508 | assert "No differences" in result.output |
| 509 | |
| 510 | def test_text_modified_file_shows_diff(self, repo: pathlib.Path) -> None: |
| 511 | (repo / "a.py").write_text("x = 99\n") |
| 512 | result = _diff(repo, "--text") |
| 513 | assert "a.py" in result.output |
| 514 | |
| 515 | def test_text_added_file_shown(self, repo: pathlib.Path) -> None: |
| 516 | (repo / "new.py").write_text("n = 1\n") |
| 517 | result = _diff(repo, "--text") |
| 518 | assert "new.py" in result.output |
| 519 | |
| 520 | def test_text_deleted_file_shown(self, repo: pathlib.Path) -> None: |
| 521 | (repo / "b.py").write_text("b=1\n") |
| 522 | _commit(repo, "add b") |
| 523 | (repo / "b.py").unlink() |
| 524 | result = _diff(repo, "--text") |
| 525 | assert "b.py" in result.output |
| 526 | |
| 527 | |
| 528 | # ────────────────────────────────────────────────────────────────────────────── |
| 529 | # Integration — --path filter |
| 530 | # ────────────────────────────────────────────────────────────────────────────── |
| 531 | |
| 532 | |
| 533 | class TestPathFilter: |
| 534 | def test_path_filter_limits_output(self, repo: pathlib.Path) -> None: |
| 535 | (repo / "a.py").write_text("x = 99\n") |
| 536 | (repo / "b.py").write_text("b = 1\n") |
| 537 | result = _diff(repo, "--json", "-p", "a.py") |
| 538 | data = json.loads(result.output) |
| 539 | # Should show a.py changes, not b.py |
| 540 | all_paths = data["added"] + data["deleted"] + data["modified"] |
| 541 | assert all(p.startswith("a") for p in all_paths) |
| 542 | |
| 543 | def test_directory_prefix_filter(self, repo: pathlib.Path) -> None: |
| 544 | (repo / "src").mkdir() |
| 545 | (repo / "src" / "foo.py").write_text("f = 1\n") |
| 546 | (repo / "other.py").write_text("o = 1\n") |
| 547 | result = _diff(repo, "--json", "-p", "src") |
| 548 | data = json.loads(result.output) |
| 549 | all_paths = data["added"] + data["deleted"] + data["modified"] |
| 550 | assert all(p.startswith("src") for p in all_paths) |
| 551 | |
| 552 | def test_path_filter_with_nonexistent_path_returns_clean( |
| 553 | self, repo: pathlib.Path |
| 554 | ) -> None: |
| 555 | (repo / "a.py").write_text("x = 99\n") |
| 556 | result = _diff(repo, "--json", "-p", "nonexistent.py") |
| 557 | data = json.loads(result.output) |
| 558 | assert data["has_changes"] is False |
| 559 | |
| 560 | |
| 561 | # ────────────────────────────────────────────────────────────────────────────── |
| 562 | # Integration — validation |
| 563 | # ────────────────────────────────────────────────────────────────────────────── |
| 564 | |
| 565 | |
| 566 | class TestDiffShelf: |
| 567 | """muse diff --shelf shows the shelved changes vs HEAD.""" |
| 568 | |
| 569 | def test_shelf_flag_shows_shelved_changes(self, repo: pathlib.Path) -> None: |
| 570 | (repo / "a.py").write_text("x = 999\n") |
| 571 | _invoke(repo, ["shelf", "save", "-m", "test shelf"]) |
| 572 | result = _diff(repo, "--shelf") |
| 573 | assert result.exit_code == 0 |
| 574 | assert "a.py" in result.output |
| 575 | |
| 576 | def test_shelf_flag_json_schema(self, repo: pathlib.Path) -> None: |
| 577 | (repo / "a.py").write_text("x = 999\n") |
| 578 | _invoke(repo, ["shelf", "save", "-m", "test shelf"]) |
| 579 | result = _diff(repo, "--shelf", "--json") |
| 580 | assert result.exit_code == 0 |
| 581 | data = json.loads(result.output) |
| 582 | assert "from_ref" in data |
| 583 | assert "to_ref" in data |
| 584 | assert data["from_ref"] == "HEAD" |
| 585 | assert "shelf" in data["to_ref"] |
| 586 | |
| 587 | def test_shelf_flag_no_shelf_exits_1(self, repo: pathlib.Path) -> None: |
| 588 | result = _diff(repo, "--shelf") |
| 589 | assert result.exit_code == 1 |
| 590 | |
| 591 | def test_shelf_flag_with_index(self, repo: pathlib.Path) -> None: |
| 592 | # Create two shelf entries, diff the second (index 1). |
| 593 | (repo / "a.py").write_text("x = 10\n") |
| 594 | _invoke(repo, ["shelf", "save", "-m", "first"]) |
| 595 | (repo / "a.py").write_text("x = 20\n") |
| 596 | _invoke(repo, ["shelf", "save", "-m", "second"]) |
| 597 | # shelf/0 = second (newest), shelf/1 = first (oldest) |
| 598 | result = _diff(repo, "--shelf", "1") |
| 599 | assert result.exit_code == 0 |
| 600 | |
| 601 | def test_shelf_mutually_exclusive_with_staged(self, repo: pathlib.Path) -> None: |
| 602 | result = _diff(repo, "--shelf", "--staged") |
| 603 | assert result.exit_code == 1 |
| 604 | |
| 605 | def test_shelf_mutually_exclusive_with_unstaged(self, repo: pathlib.Path) -> None: |
| 606 | result = _diff(repo, "--shelf", "--unstaged") |
| 607 | assert result.exit_code == 1 |
| 608 | |
| 609 | |
| 610 | class TestValidation: |
| 611 | def test_staged_and_unstaged_mutually_exclusive(self, repo: pathlib.Path) -> None: |
| 612 | result = _diff(repo, "--staged", "--unstaged") |
| 613 | assert result.exit_code == 1 |
| 614 | |
| 615 | def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None: |
| 616 | result = _diff(repo, "--format", "xml") |
| 617 | assert result.exit_code != 0 |
| 618 | |
| 619 | |
| 620 | # ────────────────────────────────────────────────────────────────────────────── |
| 621 | # Security — ANSI injection prevention |
| 622 | # ────────────────────────────────────────────────────────────────────────────── |
| 623 | |
| 624 | |
| 625 | class TestSecurityAnsi: |
| 626 | """Text output must never emit raw ANSI sequences from user-controlled input.""" |
| 627 | |
| 628 | def _has_ansi(self, s: str) -> bool: |
| 629 | return "\x1b[" in s or "\x1b]" in s |
| 630 | |
| 631 | def test_ansi_in_commit_ref_sanitized(self, repo: pathlib.Path) -> None: |
| 632 | """An ANSI escape in a commit ref must not leak into terminal output.""" |
| 633 | malicious = "\x1b[31mmalicious\x1b[0m" |
| 634 | result = _diff(repo, malicious) |
| 635 | assert not self._has_ansi(result.output) |
| 636 | |
| 637 | def test_ansi_in_path_filter_handled(self, repo: pathlib.Path) -> None: |
| 638 | """An ANSI escape in --path must not leak into output.""" |
| 639 | result = _diff(repo, "--json", "-p", "\x1b[31mmalicious\x1b[0m") |
| 640 | assert not self._has_ansi(result.output) |
| 641 | |
| 642 | def test_text_diff_path_headers_sanitized(self, repo: pathlib.Path) -> None: |
| 643 | """The a/path and b/path headers in unified diff must be sanitized.""" |
| 644 | # We can't create files with ESC in names on most OS, so test via |
| 645 | # the sanitize_display path indirectly by verifying clean output |
| 646 | (repo / "normal.py").write_text("n = 1\n") |
| 647 | result = _diff(repo, "--text") |
| 648 | assert not self._has_ansi(result.output) |
| 649 | |
| 650 | |
| 651 | # ────────────────────────────────────────────────────────────────────────────── |
| 652 | # End-to-end — text output |
| 653 | # ────────────────────────────────────────────────────────────────────────────── |
| 654 | |
| 655 | |
| 656 | class TestTextOutput: |
| 657 | def test_no_differences_on_clean_tree(self, repo: pathlib.Path) -> None: |
| 658 | result = _diff(repo) |
| 659 | assert "No differences" in result.output |
| 660 | |
| 661 | def test_summary_line_on_changes(self, repo: pathlib.Path) -> None: |
| 662 | (repo / "a.py").write_text("x = 50\n") |
| 663 | result = _diff(repo) |
| 664 | # Summary line should appear after the file listing |
| 665 | assert result.output.strip() != "" |
| 666 | assert "No differences" not in result.output |
| 667 | |
| 668 | def test_deleted_file_shows_d_prefix(self, repo: pathlib.Path) -> None: |
| 669 | (repo / "b.py").write_text("b=1\n") |
| 670 | _commit(repo, "add b") |
| 671 | (repo / "b.py").unlink() |
| 672 | result = _diff(repo) |
| 673 | assert "b.py" in result.output |
| 674 | # Should show D (delete) status, not A or M |
| 675 | assert "D" in result.output or "removed" in result.output.lower() |
| 676 | |
| 677 | |
| 678 | # ────────────────────────────────────────────────────────────────────────────── |
| 679 | # Stress — large repos |
| 680 | # ────────────────────────────────────────────────────────────────────────────── |
| 681 | |
| 682 | |
| 683 | @pytest.mark.slow |
| 684 | class TestStressLargeRepo: |
| 685 | def test_diff_500_files_10_changes_under_1s(self, repo: pathlib.Path) -> None: |
| 686 | for i in range(500): |
| 687 | (repo / f"f{i:04d}.py").write_text(f"x = {i}\n") |
| 688 | _commit(repo, "base") |
| 689 | for i in range(10): |
| 690 | (repo / f"f{i:04d}.py").write_text(f"x = {i * 100}\n") |
| 691 | t0 = time.perf_counter() |
| 692 | result = _diff(repo, "--json") |
| 693 | elapsed = (time.perf_counter() - t0) * 1000 |
| 694 | assert result.exit_code == 0 |
| 695 | data = json.loads(result.output) |
| 696 | assert data["has_changes"] is True |
| 697 | assert elapsed < 1000, f"diff took {elapsed:.0f}ms (limit 1000ms)" |
| 698 | |
| 699 | def test_diff_1000_added_files(self, repo: pathlib.Path) -> None: |
| 700 | _commit(repo, "base") |
| 701 | for i in range(1000): |
| 702 | (repo / f"g{i:04d}.py").write_text(f"y = {i}\n") |
| 703 | t0 = time.perf_counter() |
| 704 | result = _diff(repo, "--json") |
| 705 | elapsed = (time.perf_counter() - t0) * 1000 |
| 706 | assert result.exit_code == 0 |
| 707 | data = json.loads(result.output) |
| 708 | assert data["has_changes"] is True |
| 709 | assert elapsed < 3000, f"diff took {elapsed:.0f}ms (limit 3000ms)" |
| 710 | |
| 711 | def test_diff_with_100_deleted_files_correct_categorization( |
| 712 | self, repo: pathlib.Path |
| 713 | ) -> None: |
| 714 | for i in range(100): |
| 715 | (repo / f"h{i:04d}.py").write_text(f"h = {i}\n") |
| 716 | _commit(repo, "base with 100 files") |
| 717 | for i in range(100): |
| 718 | (repo / f"h{i:04d}.py").unlink() |
| 719 | result = _diff(repo, "--json") |
| 720 | data = json.loads(result.output) |
| 721 | # All 100 must be in deleted, not modified |
| 722 | assert len(data["deleted"]) == 100 |
| 723 | assert len(data["modified"]) == 0 |
| 724 | |
| 725 | |
| 726 | @pytest.mark.slow |
| 727 | class TestStressConcurrent: |
| 728 | def test_concurrent_diffs_to_separate_repos(self, tmp_path: pathlib.Path) -> None: |
| 729 | errors: list[str] = [] |
| 730 | |
| 731 | def do_diff(idx: int) -> None: |
| 732 | repo_dir = tmp_path / f"repo_{idx}" |
| 733 | repo_dir.mkdir() |
| 734 | subprocess.run( |
| 735 | ["muse", "init"], cwd=str(repo_dir), capture_output=True |
| 736 | ) |
| 737 | (repo_dir / "x.py").write_text(f"x = {idx}\n") |
| 738 | subprocess.run( |
| 739 | ["muse", "commit", "-m", "base"], |
| 740 | cwd=str(repo_dir), capture_output=True, |
| 741 | ) |
| 742 | (repo_dir / "x.py").write_text(f"x = {idx + 100}\n") |
| 743 | r = subprocess.run( |
| 744 | ["muse", "diff", "--json"], |
| 745 | cwd=str(repo_dir), capture_output=True, text=True, |
| 746 | ) |
| 747 | if r.returncode != 0: |
| 748 | errors.append(f"repo_{idx}: diff failed") |
| 749 | return |
| 750 | data = json.loads(r.stdout) |
| 751 | if not data.get("has_changes"): |
| 752 | errors.append(f"repo_{idx}: expected has_changes=true") |
| 753 | |
| 754 | threads = [threading.Thread(target=do_diff, args=(i,)) for i in range(8)] |
| 755 | for t in threads: |
| 756 | t.start() |
| 757 | for t in threads: |
| 758 | t.join() |
| 759 | |
| 760 | |
| 761 | # ────────────────────────────────────────────────────────────────────────────── |
| 762 | # TestDiffConflict — muse diff --conflict (Cohen Transform labeled diff) |
| 763 | # ────────────────────────────────────────────────────────────────────────────── |
| 764 | |
| 765 | |
| 766 | def _make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 767 | """Return a repo on *main* with an in-progress conflicting checkout -m. |
| 768 | |
| 769 | The repo has: |
| 770 | - ``shared.py`` on main: line1 / line2 / line3 |
| 771 | - branch ``other``: line1 / LINE2 / line3 (other changed line2) |
| 772 | - dirty workdir on main: line1 / OURS2 / line3 (ours changed line2) |
| 773 | |
| 774 | Running ``checkout -m other`` produces a conflict on shared.py and writes |
| 775 | MERGE_STATE.json. The caller receives the repo in that conflicted state. |
| 776 | """ |
| 777 | saved = os.getcwd() |
| 778 | try: |
| 779 | os.chdir(tmp_path) |
| 780 | runner.invoke(None, ["init"]) |
| 781 | finally: |
| 782 | os.chdir(saved) |
| 783 | |
| 784 | (tmp_path / "shared.py").write_text("line1\nline2\nline3\n") |
| 785 | _commit(tmp_path, "initial") |
| 786 | |
| 787 | _invoke(tmp_path, ["branch", "other"]) |
| 788 | _invoke(tmp_path, ["checkout", "other"]) |
| 789 | (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n") |
| 790 | _commit(tmp_path, "other changes line2") |
| 791 | |
| 792 | _invoke(tmp_path, ["checkout", "main"]) |
| 793 | (tmp_path / "shared.py").write_text("line1\nOURS2\nline3\n") |
| 794 | # Trigger the conflicting checkout -m to create MERGE_STATE |
| 795 | _invoke(tmp_path, ["checkout", "-m", "other"]) |
| 796 | return tmp_path |
| 797 | |
| 798 | |
| 799 | class TestDiffConflictParser: |
| 800 | """Parser-level tests for the ``--conflict`` flag.""" |
| 801 | |
| 802 | def _parse(self, *args: str) -> "argparse.Namespace": |
| 803 | import argparse |
| 804 | |
| 805 | from muse.cli.commands.diff import register |
| 806 | |
| 807 | p = argparse.ArgumentParser() |
| 808 | sub = p.add_subparsers() |
| 809 | register(sub) |
| 810 | return p.parse_args(["diff", *args]) |
| 811 | |
| 812 | def test_conflict_flag_parsed(self) -> None: |
| 813 | ns = self._parse("--conflict") |
| 814 | assert ns.conflict is True |
| 815 | |
| 816 | def test_conflict_false_by_default(self) -> None: |
| 817 | ns = self._parse() |
| 818 | assert ns.conflict is False |
| 819 | |
| 820 | def test_conflict_and_json_coexist(self) -> None: |
| 821 | ns = self._parse("--conflict", "--json") |
| 822 | assert ns.conflict is True |
| 823 | assert ns.json_out is True |
| 824 | |
| 825 | def test_conflict_and_path_coexist(self) -> None: |
| 826 | ns = self._parse("--conflict", "--path", "src/") |
| 827 | assert ns.conflict is True |
| 828 | assert "src/" in ns.paths |
| 829 | |
| 830 | |
| 831 | class TestDiffConflictNoMerge: |
| 832 | """--conflict when no merge is in progress must error cleanly.""" |
| 833 | |
| 834 | def test_no_merge_in_progress_exits_1(self, repo: pathlib.Path) -> None: |
| 835 | r = _diff(repo, "--conflict") |
| 836 | assert r.exit_code == 1 |
| 837 | |
| 838 | def test_no_merge_error_message_on_stderr(self, repo: pathlib.Path) -> None: |
| 839 | r = _diff(repo, "--conflict") |
| 840 | assert "MERGE_STATE" in r.stderr or "merge" in r.stderr.lower() |
| 841 | |
| 842 | def test_no_merge_json_also_exits_1(self, repo: pathlib.Path) -> None: |
| 843 | r = _diff(repo, "--conflict", "--json") |
| 844 | assert r.exit_code == 1 |
| 845 | |
| 846 | |
| 847 | class TestDiffConflictOutput: |
| 848 | """--conflict with an active merge in progress.""" |
| 849 | |
| 850 | def test_exits_nonzero_when_conflicts_exist(self, tmp_path: pathlib.Path) -> None: |
| 851 | repo = _make_conflict_repo(tmp_path) |
| 852 | r = _diff(repo, "--conflict") |
| 853 | assert r.exit_code != 0 |
| 854 | |
| 855 | def test_output_mentions_conflict_file(self, tmp_path: pathlib.Path) -> None: |
| 856 | repo = _make_conflict_repo(tmp_path) |
| 857 | r = _diff(repo, "--conflict") |
| 858 | assert "shared.py" in r.output |
| 859 | |
| 860 | def test_output_contains_ours_side(self, tmp_path: pathlib.Path) -> None: |
| 861 | repo = _make_conflict_repo(tmp_path) |
| 862 | r = _diff(repo, "--conflict") |
| 863 | assert "[ours]" in r.output or "ours" in r.output.lower() |
| 864 | |
| 865 | def test_output_contains_theirs_side(self, tmp_path: pathlib.Path) -> None: |
| 866 | repo = _make_conflict_repo(tmp_path) |
| 867 | r = _diff(repo, "--conflict") |
| 868 | assert "[theirs]" in r.output or "theirs" in r.output.lower() |
| 869 | |
| 870 | def test_output_contains_cohen_action_labels(self, tmp_path: pathlib.Path) -> None: |
| 871 | """Cohen-style hunk labels (e.g. [branchname: modified]) must appear in @@-headers.""" |
| 872 | repo = _make_conflict_repo(tmp_path) |
| 873 | r = _diff(repo, "--conflict") |
| 874 | combined = r.output + r.stderr |
| 875 | # annotate_hunk_action produces [side_label: action] suffixes on @@ headers |
| 876 | assert any( |
| 877 | suffix in combined |
| 878 | for suffix in (": modified]", ": inserted]", ": deleted]") |
| 879 | ) |
| 880 | |
| 881 | def test_json_status_is_conflict(self, tmp_path: pathlib.Path) -> None: |
| 882 | repo = _make_conflict_repo(tmp_path) |
| 883 | r = _diff(repo, "--conflict", "--json") |
| 884 | data = json.loads(r.output) |
| 885 | assert data["status"] == "conflict" |
| 886 | |
| 887 | def test_json_conflicts_list_non_empty(self, tmp_path: pathlib.Path) -> None: |
| 888 | repo = _make_conflict_repo(tmp_path) |
| 889 | r = _diff(repo, "--conflict", "--json") |
| 890 | data = json.loads(r.output) |
| 891 | assert len(data["conflicts"]) >= 1 |
| 892 | |
| 893 | def test_json_conflict_entry_has_path_and_diffs(self, tmp_path: pathlib.Path) -> None: |
| 894 | repo = _make_conflict_repo(tmp_path) |
| 895 | r = _diff(repo, "--conflict", "--json") |
| 896 | data = json.loads(r.output) |
| 897 | entry = data["conflicts"][0] |
| 898 | assert "path" in entry |
| 899 | assert "ours_diff" in entry |
| 900 | assert "theirs_diff" in entry |
| 901 | |
| 902 | def test_json_labels_match_branches(self, tmp_path: pathlib.Path) -> None: |
| 903 | repo = _make_conflict_repo(tmp_path) |
| 904 | r = _diff(repo, "--conflict", "--json") |
| 905 | data = json.loads(r.output) |
| 906 | assert data["ours_label"] in ("other", "main") # one of the branch names |
| 907 | assert data["theirs_label"] in ("other", "main") |
| 908 | |
| 909 | def test_path_filter_limits_output(self, tmp_path: pathlib.Path) -> None: |
| 910 | """``--path`` with a non-matching prefix must produce empty conflicts.""" |
| 911 | repo = _make_conflict_repo(tmp_path) |
| 912 | r = _diff(repo, "--conflict", "--json", "--path", "nonexistent/") |
| 913 | data = json.loads(r.output) |
| 914 | assert len(data["conflicts"]) == 0 |
| 915 | |
| 916 | def test_path_filter_matching_includes_file(self, tmp_path: pathlib.Path) -> None: |
| 917 | """``--path shared.py`` must include the conflicting file.""" |
| 918 | repo = _make_conflict_repo(tmp_path) |
| 919 | r = _diff(repo, "--conflict", "--json", "--path", "shared.py") |
| 920 | data = json.loads(r.output) |
| 921 | assert any(e["path"] == "shared.py" for e in data["conflicts"]) |
| 922 | |
| 923 | |
| 924 | class TestDiffConflictSecurity: |
| 925 | """Security: ANSI injection via branch names / paths must be sanitized.""" |
| 926 | |
| 927 | def test_ansi_in_conflict_path_sanitized(self, tmp_path: pathlib.Path) -> None: |
| 928 | """ANSI escape sequences in a conflict file path must not reach the output. |
| 929 | |
| 930 | The CliRunner strips ANSI from all output, so the raw escape code |
| 931 | must not appear in the combined output string. |
| 932 | """ |
| 933 | repo = _make_conflict_repo(tmp_path) |
| 934 | r = _diff(repo, "--conflict") |
| 935 | # CliRunner already strips ANSI; double-check no raw escape CSI leaks through |
| 936 | assert "\x1b[" not in r.output |
| 937 | |
| 938 | |
| 939 | # ────────────────────────────────────────────────────────────────────────────── |
| 940 | # Unit — PatchOp.file_change field |
| 941 | # ────────────────────────────────────────────────────────────────────────────── |
| 942 | |
| 943 | |
| 944 | class TestPatchOpFileChangeField: |
| 945 | """PatchOp gains a file_change field: 'added' | 'deleted' | 'modified'. |
| 946 | |
| 947 | This field is set by build_diff_ops based on which path bucket the file |
| 948 | belongs to — not inferred from child op direction after the fact. |
| 949 | """ |
| 950 | |
| 951 | def _make_patch(self, file_change: str | None = None) -> "PatchOp": |
| 952 | from muse.domain import PatchOp |
| 953 | |
| 954 | kwargs = dict( |
| 955 | op="patch", |
| 956 | address="file.py", |
| 957 | child_ops=[], |
| 958 | child_domain="code", |
| 959 | child_summary="", |
| 960 | ) |
| 961 | if file_change is not None: |
| 962 | kwargs["file_change"] = file_change |
| 963 | return PatchOp(**kwargs) |
| 964 | |
| 965 | def test_patch_op_accepts_file_change_added(self) -> None: |
| 966 | op = self._make_patch("added") |
| 967 | assert op["file_change"] == "added" |
| 968 | |
| 969 | def test_patch_op_accepts_file_change_deleted(self) -> None: |
| 970 | op = self._make_patch("deleted") |
| 971 | assert op["file_change"] == "deleted" |
| 972 | |
| 973 | def test_patch_op_accepts_file_change_modified(self) -> None: |
| 974 | op = self._make_patch("modified") |
| 975 | assert op["file_change"] == "modified" |
| 976 | |
| 977 | def test_patch_op_file_change_is_optional(self) -> None: |
| 978 | """Existing call sites without file_change must still work.""" |
| 979 | op = self._make_patch() |
| 980 | assert "file_change" not in op |
| 981 | |
| 982 | |
| 983 | # ────────────────────────────────────────────────────────────────────────────── |
| 984 | # Unit — build_diff_ops sets file_change from path bucket |
| 985 | # ────────────────────────────────────────────────────────────────────────────── |
| 986 | |
| 987 | |
| 988 | class TestBuildDiffOpsFileChange: |
| 989 | """build_diff_ops sets PatchOp.file_change from the path bucket (added / |
| 990 | removed / modified), never from child op direction. |
| 991 | """ |
| 992 | |
| 993 | def _trees_with_symbols(self, path: str, names: list[str]) -> Mapping[str, object]: |
| 994 | """Minimal SymbolTree with one entry per name.""" |
| 995 | return { |
| 996 | f"{path}::{name}": { |
| 997 | "name": name, |
| 998 | "kind": "function", |
| 999 | "qualified_name": f"{path}::{name}", |
| 1000 | "lineno": 1, |
| 1001 | "end_lineno": 2, |
| 1002 | "content_id": f"c_{name}", |
| 1003 | "body_hash": f"b_{name}", |
| 1004 | "signature_id": f"s_{name}", |
| 1005 | } |
| 1006 | for name in names |
| 1007 | } |
| 1008 | |
| 1009 | def test_added_file_patch_has_file_change_added(self) -> None: |
| 1010 | from muse.plugins.code.symbol_diff import build_diff_ops |
| 1011 | |
| 1012 | base_files = {} |
| 1013 | target_files = {"new.py": "oid1"} |
| 1014 | base_trees = {} |
| 1015 | target_trees = {"new.py": self._trees_with_symbols("new.py", ["alpha", "beta"])} |
| 1016 | |
| 1017 | ops = build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1018 | patch_ops = [o for o in ops if o["op"] == "patch"] |
| 1019 | assert len(patch_ops) == 1 |
| 1020 | assert patch_ops[0]["file_change"] == "added" |
| 1021 | |
| 1022 | def test_removed_file_patch_has_file_change_deleted(self) -> None: |
| 1023 | from muse.plugins.code.symbol_diff import build_diff_ops |
| 1024 | |
| 1025 | base_files = {"old.py": "oid1"} |
| 1026 | target_files = {} |
| 1027 | base_trees = {"old.py": self._trees_with_symbols("old.py", ["alpha", "beta"])} |
| 1028 | target_trees = {} |
| 1029 | |
| 1030 | ops = build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1031 | patch_ops = [o for o in ops if o["op"] == "patch"] |
| 1032 | assert len(patch_ops) == 1 |
| 1033 | assert patch_ops[0]["file_change"] == "deleted" |
| 1034 | |
| 1035 | def test_modified_file_patch_has_file_change_modified(self) -> None: |
| 1036 | from muse.plugins.code.symbol_diff import build_diff_ops |
| 1037 | |
| 1038 | base_files = {"mod.py": "oid1"} |
| 1039 | target_files = {"mod.py": "oid2"} |
| 1040 | base_trees = {"mod.py": self._trees_with_symbols("mod.py", ["alpha"])} |
| 1041 | target_trees = {"mod.py": self._trees_with_symbols("mod.py", ["alpha", "beta"])} |
| 1042 | |
| 1043 | ops = build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1044 | patch_ops = [o for o in ops if o["op"] == "patch"] |
| 1045 | assert len(patch_ops) == 1 |
| 1046 | assert patch_ops[0]["file_change"] == "modified" |
| 1047 | |
| 1048 | def test_modified_file_all_symbol_deletions_still_file_change_modified(self) -> None: |
| 1049 | """The critical case: a living file that lost all its symbols must carry |
| 1050 | file_change='modified', not 'deleted'. Child op direction must NOT |
| 1051 | determine file status.""" |
| 1052 | from muse.plugins.code.symbol_diff import build_diff_ops |
| 1053 | |
| 1054 | base_files = {"shrunk.py": "oid1"} |
| 1055 | target_files = {"shrunk.py": "oid2"} # file still exists |
| 1056 | base_trees = {"shrunk.py": self._trees_with_symbols("shrunk.py", ["alpha", "beta", "gamma"])} |
| 1057 | target_trees = {"shrunk.py": {}} # all symbols gone, but file lives |
| 1058 | |
| 1059 | ops = build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1060 | patch_ops = [o for o in ops if o["op"] == "patch"] |
| 1061 | assert len(patch_ops) == 1, f"Expected 1 PatchOp, got: {ops}" |
| 1062 | assert patch_ops[0]["file_change"] == "modified", ( |
| 1063 | f"Living file with all-delete children must be 'modified', " |
| 1064 | f"got {patch_ops[0].get('file_change')!r}" |
| 1065 | ) |
| 1066 | |
| 1067 | def test_modified_file_all_symbol_additions_still_file_change_modified(self) -> None: |
| 1068 | """A living file that gained all new symbols is 'modified', not 'added'.""" |
| 1069 | from muse.plugins.code.symbol_diff import build_diff_ops |
| 1070 | |
| 1071 | base_files = {"grew.py": "oid1"} |
| 1072 | target_files = {"grew.py": "oid2"} |
| 1073 | base_trees = {"grew.py": {}} # was empty (no symbols) |
| 1074 | target_trees = {"grew.py": self._trees_with_symbols("grew.py", ["alpha", "beta"])} |
| 1075 | |
| 1076 | ops = build_diff_ops(base_files, target_files, base_trees, target_trees) |
| 1077 | patch_ops = [o for o in ops if o["op"] == "patch"] |
| 1078 | assert len(patch_ops) == 1 |
| 1079 | assert patch_ops[0]["file_change"] == "modified", ( |
| 1080 | f"Living file with all-insert children must be 'modified', " |
| 1081 | f"got {patch_ops[0].get('file_change')!r}" |
| 1082 | ) |
| 1083 | |
| 1084 | |
| 1085 | # ────────────────────────────────────────────────────────────────────────────── |
| 1086 | # Integration — file-level sigil correctness (the AX bug fix) |
| 1087 | # ────────────────────────────────────────────────────────────────────────────── |
| 1088 | |
| 1089 | |
| 1090 | class TestFileSignilCorrectnessTextOutput: |
| 1091 | """Text output: the file-level sigil (A/D/M/R) must reflect whether the |
| 1092 | file was added, deleted, or modified — never inferred from child op counts. |
| 1093 | |
| 1094 | Historically, a modified file that lost all its functions showed 'D' in |
| 1095 | the diff output (misread as 'file deleted' by agents). After the fix, |
| 1096 | that file must show 'M'. |
| 1097 | """ |
| 1098 | |
| 1099 | def test_modified_file_losing_all_symbols_shows_M_sigil( |
| 1100 | self, repo: pathlib.Path |
| 1101 | ) -> None: |
| 1102 | """Living file that lost all named functions → M, not D.""" |
| 1103 | (repo / "funcs.py").write_text( |
| 1104 | "def alpha():\n return 1\n\ndef beta():\n return 2\n" |
| 1105 | ) |
| 1106 | _commit(repo, "add funcs") |
| 1107 | |
| 1108 | # Overwrite with content that has no recognised symbols. |
| 1109 | (repo / "funcs.py").write_text("# no functions here\nPLACEHOLDER = True\n") |
| 1110 | |
| 1111 | result = _diff(repo) |
| 1112 | lines = result.output.splitlines() |
| 1113 | file_line = next( |
| 1114 | (l for l in lines if "funcs.py" in l |
| 1115 | and not l.strip().startswith("├─") |
| 1116 | and not l.strip().startswith("└─")), |
| 1117 | None, |
| 1118 | ) |
| 1119 | assert file_line is not None, f"funcs.py not in diff:\n{result.output}" |
| 1120 | assert file_line.strip().startswith("M"), ( |
| 1121 | f"Expected 'M funcs.py' (modified), got: {file_line!r}\n" |
| 1122 | f"Full output:\n{result.output}" |
| 1123 | ) |
| 1124 | |
| 1125 | def test_modified_file_gaining_all_symbols_shows_M_sigil( |
| 1126 | self, repo: pathlib.Path |
| 1127 | ) -> None: |
| 1128 | """Living file that gained functions → M, not A.""" |
| 1129 | # Start with a file that has no functions |
| 1130 | (repo / "empty.py").write_text("PLACEHOLDER = True\n") |
| 1131 | _commit(repo, "add empty.py") |
| 1132 | |
| 1133 | # Add functions to it |
| 1134 | (repo / "empty.py").write_text( |
| 1135 | "def alpha():\n return 1\n\ndef beta():\n return 2\n" |
| 1136 | ) |
| 1137 | |
| 1138 | result = _diff(repo) |
| 1139 | lines = result.output.splitlines() |
| 1140 | file_line = next( |
| 1141 | (l for l in lines if "empty.py" in l |
| 1142 | and not l.strip().startswith("├─") |
| 1143 | and not l.strip().startswith("└─")), |
| 1144 | None, |
| 1145 | ) |
| 1146 | assert file_line is not None, f"empty.py not in diff:\n{result.output}" |
| 1147 | assert file_line.strip().startswith("M"), ( |
| 1148 | f"Expected 'M empty.py' (modified), got: {file_line!r}\n" |
| 1149 | f"Full output:\n{result.output}" |
| 1150 | ) |
| 1151 | |
| 1152 | def test_actually_deleted_file_still_shows_D_sigil( |
| 1153 | self, repo: pathlib.Path |
| 1154 | ) -> None: |
| 1155 | """Sanity check: a file that is truly gone still shows D.""" |
| 1156 | (repo / "gone.py").write_text("def foo(): pass\n") |
| 1157 | _commit(repo, "add gone") |
| 1158 | (repo / "gone.py").unlink() |
| 1159 | |
| 1160 | result = _diff(repo) |
| 1161 | lines = result.output.splitlines() |
| 1162 | file_line = next( |
| 1163 | (l for l in lines if "gone.py" in l |
| 1164 | and not l.strip().startswith("├─") |
| 1165 | and not l.strip().startswith("└─")), |
| 1166 | None, |
| 1167 | ) |
| 1168 | assert file_line is not None |
| 1169 | assert file_line.strip().startswith("D"), ( |
| 1170 | f"Expected 'D gone.py' (deleted), got: {file_line!r}" |
| 1171 | ) |
| 1172 | |
| 1173 | def test_newly_added_file_still_shows_A_sigil( |
| 1174 | self, repo: pathlib.Path |
| 1175 | ) -> None: |
| 1176 | """Sanity check: a brand-new file still shows A.""" |
| 1177 | (repo / "brand_new.py").write_text("def foo(): pass\n") |
| 1178 | |
| 1179 | result = _diff(repo) |
| 1180 | lines = result.output.splitlines() |
| 1181 | file_line = next( |
| 1182 | (l for l in lines if "brand_new.py" in l |
| 1183 | and not l.strip().startswith("├─") |
| 1184 | and not l.strip().startswith("└─")), |
| 1185 | None, |
| 1186 | ) |
| 1187 | assert file_line is not None |
| 1188 | assert file_line.strip().startswith("A"), ( |
| 1189 | f"Expected 'A brand_new.py' (added), got: {file_line!r}" |
| 1190 | ) |
| 1191 | |
| 1192 | |
| 1193 | class TestFileSignilCorrectnessJsonOutput: |
| 1194 | """JSON output must categorize files by actual existence, not child op direction.""" |
| 1195 | |
| 1196 | def test_modified_file_losing_all_symbols_in_modified_not_deleted( |
| 1197 | self, repo: pathlib.Path |
| 1198 | ) -> None: |
| 1199 | (repo / "funcs.py").write_text( |
| 1200 | "def alpha():\n return 1\n\ndef beta():\n return 2\n" |
| 1201 | ) |
| 1202 | _commit(repo, "add funcs") |
| 1203 | (repo / "funcs.py").write_text("# no functions here\nPLACEHOLDER = True\n") |
| 1204 | |
| 1205 | result = _diff(repo, "--json") |
| 1206 | data = json.loads(result.output) |
| 1207 | assert "funcs.py" in data["modified"], ( |
| 1208 | f"funcs.py must be in modified, got: {data}" |
| 1209 | ) |
| 1210 | assert "funcs.py" not in data["deleted"], ( |
| 1211 | f"funcs.py must NOT be in deleted (file still exists), got: {data}" |
| 1212 | ) |
| 1213 | |
| 1214 | def test_modified_file_gaining_all_symbols_in_modified_not_added( |
| 1215 | self, repo: pathlib.Path |
| 1216 | ) -> None: |
| 1217 | (repo / "empty.py").write_text("PLACEHOLDER = True\n") |
| 1218 | _commit(repo, "add empty.py") |
| 1219 | (repo / "empty.py").write_text( |
| 1220 | "def alpha():\n return 1\n\ndef beta():\n return 2\n" |
| 1221 | ) |
| 1222 | |
| 1223 | result = _diff(repo, "--json") |
| 1224 | data = json.loads(result.output) |
| 1225 | assert "empty.py" in data["modified"], ( |
| 1226 | f"empty.py must be in modified, got: {data}" |
| 1227 | ) |
| 1228 | assert "empty.py" not in data["added"], ( |
| 1229 | f"empty.py must NOT be in added (file pre-existed), got: {data}" |
| 1230 | ) |
| 1231 | |
| 1232 | |
| 1233 | # ────────────────────────────────────────────────────────────────────────────── |
| 1234 | # Dead-code removal — _classify_patch_op and _op_category must be deleted |
| 1235 | # ────────────────────────────────────────────────────────────────────────────── |
| 1236 | |
| 1237 | |
| 1238 | class TestInferenceHelpersRemoved: |
| 1239 | """_classify_patch_op and _op_category exist only to infer file status from |
| 1240 | child op counts. Once PatchOp.file_change carries authoritative status, |
| 1241 | both helpers are dead code and must be deleted. |
| 1242 | """ |
| 1243 | |
| 1244 | def test_classify_patch_op_deleted(self) -> None: |
| 1245 | import muse.cli.commands.diff as m |
| 1246 | |
| 1247 | assert not hasattr(m, "_classify_patch_op"), ( |
| 1248 | "_classify_patch_op is dead code — file status is now read from " |
| 1249 | "PatchOp.file_change, not inferred from child ops" |
| 1250 | ) |
| 1251 | |
| 1252 | def test_op_category_deleted(self) -> None: |
| 1253 | import muse.cli.commands.diff as m |
| 1254 | |
| 1255 | assert not hasattr(m, "_op_category"), ( |
| 1256 | "_op_category is dead code — it existed only to wrap _classify_patch_op" |
| 1257 | ) |
File History
1 commit
sha256:e029dfbf5482b23e127b4793b93953b3e2b5af9ca3aa533e6c6e08a6d1887e17
fixing more failing tests
Human
4 days ago