"""Directory tracking in ``muse diff --json``. Directories are first-class symbols in Muse — fully symmetric with files. Empty directories appear in ``added``/``deleted`` with a trailing ``/``, exactly as they do in ``muse status --json``. The separate ``directories`` sub-object is gone. Agents check one place. Coverage matrix --------------- DD Directory tracking in muse diff DD1 staged empty dir → "foobar/" in added (not in a directories sub-key) DD2 unstaged delete of committed dir → "emptydir/" in deleted DD3 total_changes includes dir additions DD4 directories key absent from muse diff --json output DD5 dirs have trailing slash; regular files do not DD6 clean repo — no trailing-slash entries in added or deleted """ 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() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _env(root: pathlib.Path) -> Mapping[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _run(root: pathlib.Path, *args: str) -> str: result = runner.invoke(cli, list(args), env=_env(root)) assert result.exit_code == 0, f"{args} failed:\n{result.output}" return result.output.strip() def _diff(root: pathlib.Path, *extra: str) -> Mapping[str, object]: result = runner.invoke(cli, ["diff", "--json"] + list(extra), env=_env(root)) assert result.exit_code == 0, f"diff --json failed:\n{result.output}" return json.loads(result.output.strip()) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def base_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Code repo with one committed file, clean working tree.""" monkeypatch.chdir(tmp_path) _run(tmp_path, "init", "--domain", "code") (tmp_path / "main.py").write_text("x = 1\n") _run(tmp_path, "code", "add", ".") _run(tmp_path, "commit", "-m", "initial") return tmp_path @pytest.fixture() def repo_with_committed_dir( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> pathlib.Path: """Code repo with a committed empty directory ``emptydir``.""" monkeypatch.chdir(tmp_path) _run(tmp_path, "init", "--domain", "code") (tmp_path / "main.py").write_text("x = 1\n") (tmp_path / "emptydir").mkdir() _run(tmp_path, "code", "add", ".") _run(tmp_path, "commit", "-m", "initial with emptydir") return tmp_path # --------------------------------------------------------------------------- # DD Directory tracking in muse diff # --------------------------------------------------------------------------- class TestDiffDirTracking: def test_DD1_staged_empty_dir_in_added(self, base_repo: pathlib.Path) -> None: """DD1: staged empty dir → 'foobar/' in added (flat, not in a sub-key).""" root = base_repo (root / "foobar").mkdir() _run(root, "code", "add", "foobar") data = _diff(root, "--staged") assert "foobar/" in data["added"], ( "staged empty dir must appear in added with trailing slash" ) def test_DD2_deleted_committed_dir_in_deleted( self, repo_with_committed_dir: pathlib.Path ) -> None: """DD2: unstaged delete of committed dir → 'emptydir/' in deleted.""" root = repo_with_committed_dir (root / "emptydir").rmdir() data = _diff(root) assert "emptydir/" in data["deleted"], ( "deleted committed dir must appear in deleted with trailing slash" ) def test_DD3_total_changes_includes_dir_additions( self, base_repo: pathlib.Path ) -> None: """DD3: total_changes counts staged directory additions.""" root = base_repo (root / "foobar").mkdir() _run(root, "code", "add", "foobar") data = _diff(root, "--staged") expected = ( len(data["added"]) + len(data["modified"]) + len(data["deleted"]) + len(data["renamed"]) ) assert data["total_changes"] == expected assert data["total_changes"] >= 1, "at least the new dir must count" def test_DD4_no_directories_sub_key(self, base_repo: pathlib.Path) -> None: """DD4: 'directories' key must be absent from muse diff --json output.""" root = base_repo (root / "foobar").mkdir() _run(root, "code", "add", "foobar") data = _diff(root, "--staged") assert "directories" not in data, ( "'directories' sub-object must not appear in diff output; " "dirs are folded into added/deleted" ) def test_DD5_dirs_have_trailing_slash_files_do_not( self, base_repo: pathlib.Path ) -> None: """DD5: dirs have trailing slash; regular files do not.""" root = base_repo (root / "newfile.py").write_text("y = 2\n") (root / "newdir").mkdir() _run(root, "code", "add", ".") data = _diff(root, "--staged") assert "newfile.py" in data["added"], "new file must appear without trailing slash" assert "newdir/" in data["added"], "new dir must appear with trailing slash" assert "newfile.py/" not in data["added"], "file must not have trailing slash" assert "newdir" in data["added"] or "newdir/" in data["added"] # Confirm the bare dir name (without slash) is not present assert "newdir" not in [ p for p in data["added"] if not p.endswith("/") ], "dir must appear with trailing slash only" def test_DD6_clean_repo_no_trailing_slash_entries( self, base_repo: pathlib.Path ) -> None: """DD6: clean staged diff → no trailing-slash entries in added or deleted.""" data = _diff(base_repo, "--staged") trailing_in_added = [p for p in data["added"] if isinstance(p, str) and p.endswith("/")] trailing_in_deleted = [p for p in data["deleted"] if isinstance(p, str) and p.endswith("/")] assert trailing_in_added == [], ( f"clean diff must have no dir entries in added; got {trailing_in_added}" ) assert trailing_in_deleted == [], ( f"clean diff must have no dir entries in deleted; got {trailing_in_deleted}" )