"""TDD — first-class directory tracking in muse status and muse diff. Covers: ST-1 muse status text: untracked empty dir appears with trailing slash ST-2 muse status JSON: untracked empty dir in `untracked` list with trailing slash ST-3 muse diff text: new empty dir prints `A test/` (trailing slash) ST-4 muse diff --stat: new empty dir counted as directory, not file ST-5 AddressedInsertOp for new dirs carries trailing slash in address ST-6 AddressedDeleteOp for removed dirs carries trailing slash in address """ from __future__ import annotations import json import pathlib from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner from muse.core.paths import muse_dir, ref_path from muse.core.object_store import write_object from muse.core.ids import hash_commit, hash_snapshot from muse.core.commits import CommitRecord, write_commit from muse.core.snapshots import SnapshotRecord, write_snapshot from muse.core.types import blob_id runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _init_repo(path: pathlib.Path) -> pathlib.Path: """Create a minimal code-domain repo with one commit.""" dot = muse_dir(path) for d in ("commits", "snapshots", "objects", "refs/heads", "code"): (dot / d).mkdir(parents=True, exist_ok=True) (dot / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") (dot / "repo.json").write_text( json.dumps({"repo_id": "dir-track-test", "domain": "code"}), encoding="utf-8", ) return path def _make_commit( root: pathlib.Path, files: Mapping[str, bytes], branch: str = "main", parent: str | None = None, directories: list[str] | None = None, ) -> str: """Write objects + snapshot + commit; advance branch ref.""" import datetime manifest: dict[str, str] = {} for rel, content in files.items(): oid = blob_id(content) write_object(root, oid, content) manifest[rel] = oid snap_id = hash_snapshot(manifest, directories or []) write_snapshot( root, SnapshotRecord( snapshot_id=snap_id, manifest=manifest, directories=directories or [], ), ) committed_at = datetime.datetime.now(datetime.timezone.utc) parent_ids = [parent] if parent else [] commit_id = hash_commit( parent_ids=parent_ids, snapshot_id=snap_id, message="test commit", committed_at_iso=committed_at.isoformat(), ) write_commit( root, CommitRecord( commit_id=commit_id, branch=branch, snapshot_id=snap_id, message="test commit", committed_at=committed_at, parent_commit_id=parent, ), ) ref_path(root, branch).write_text(commit_id, encoding="utf-8") return commit_id def _env(root: pathlib.Path) -> Mapping[str, str]: return {"MUSE_REPO_ROOT": str(root)} # --------------------------------------------------------------------------- # ST-1 muse status text: untracked empty dir shows with trailing slash # --------------------------------------------------------------------------- class TestStatusTextUntracked: def test_untracked_empty_dir_shown_with_trailing_slash( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """muse status long-form text must list an untracked empty dir as `test/`.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() result = runner.invoke(None, ["status"], env=_env(root)) assert result.exit_code == 0 assert "test/" in result.output, ( f"Expected 'test/' in status output but got:\n{result.output}" ) def test_untracked_empty_dir_not_shown_without_slash( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Ensure the bare 'test' (no slash) does NOT appear — slash is the signal.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() result = runner.invoke(None, ["status"], env=_env(root)) lines = [l.strip() for l in result.output.splitlines()] # "test" without trailing slash must not appear as a standalone entry assert "test" not in lines, ( f"Bare 'test' (no slash) appeared in status output:\n{result.output}" ) # --------------------------------------------------------------------------- # ST-2 muse status JSON: untracked empty dir in `untracked` list with slash # --------------------------------------------------------------------------- class TestStatusJsonUntracked: def test_untracked_empty_dir_in_json_untracked( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """muse status --json must include `test/` in the `untracked` list.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() result = runner.invoke(None, ["status", "--json"], env=_env(root)) assert result.exit_code == 0 data = json.loads(result.output) assert "test/" in data["untracked"], ( f"Expected 'test/' in untracked but got: {data['untracked']}" ) def test_dirty_when_untracked_empty_dir_present( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """Repo must be dirty when an untracked empty dir exists.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "mydir").mkdir() result = runner.invoke(None, ["status", "--json"], env=_env(root)) data = json.loads(result.output) assert data["dirty"] is True assert data["clean"] is False # --------------------------------------------------------------------------- # ST-3 muse diff text: new empty dir prints `A test/` (trailing slash) # --------------------------------------------------------------------------- class TestDiffTextDirectory: def test_new_empty_dir_shows_with_trailing_slash( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """muse diff must print `A test/` — not `A test` — for a staged empty dir.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() # Stage the dir first — untracked dirs are invisible to diff (like git). runner.invoke(None, ["code", "add", "test/"], env=_env(root)) result = runner.invoke(None, ["diff"], env=_env(root)) assert result.exit_code == 0 assert "test/" in result.output, ( f"Expected 'test/' in diff output but got:\n{result.output}" ) def test_new_empty_dir_not_shown_without_slash( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The bare `A test` (no slash) must not appear for a directory.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() result = runner.invoke(None, ["diff"], env=_env(root)) # "A test\n" (no slash) must not appear — only "A test/" is correct assert "A test\n" not in result.output, ( f"Bare 'A test' appeared in diff output:\n{result.output}" ) # --------------------------------------------------------------------------- # ST-4 muse diff --stat: new empty dir counted as directory, not file # --------------------------------------------------------------------------- class TestDiffStatDirectory: def test_stat_shows_directory_not_file( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """muse diff --stat must say `1 added directory`, not `1 added file`.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() # Stage first — untracked dirs are invisible to diff (like git). runner.invoke(None, ["code", "add", "test/"], env=_env(root)) result = runner.invoke(None, ["diff", "--stat"], env=_env(root)) assert result.exit_code == 0 assert "directory" in result.output, ( f"Expected 'directory' in --stat output but got:\n{result.output}" ) assert "added file" not in result.output, ( f"'added file' must not appear for a directory in --stat:\n{result.output}" ) def test_stat_file_and_dir_counted_separately( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: """When both a file and a dir are added, stat counts them separately.""" root = _init_repo(tmp_path) _make_commit(root, {"readme.md": b"# hello\n"}) monkeypatch.chdir(root) (root / "test").mkdir() (root / "new.py").write_text("# new\n", encoding="utf-8") # Stage dir first — untracked dirs are invisible to diff (like git). runner.invoke(None, ["code", "add", "test/"], env=_env(root)) result = runner.invoke(None, ["diff", "--stat"], env=_env(root)) assert result.exit_code == 0 # Both a file count and a directory count should appear assert "file" in result.output assert "directory" in result.output # --------------------------------------------------------------------------- # ST-5 AddressedInsertOp for new dirs carries trailing slash in address # --------------------------------------------------------------------------- class TestDirectoryOpsAlgebra: def test_addressed_insert_op_address_has_trailing_slash( self, tmp_path: pathlib.Path ) -> None: """AddressedInsertOp emitted for a new empty dir must have address ending in '/'.""" from muse.domain import SnapshotManifest from muse.plugins.code.plugin import CodePlugin root = _init_repo(tmp_path) plugin = CodePlugin() base = SnapshotManifest(files={}, domain="code", directories=[]) target = SnapshotManifest(files={}, domain="code", directories=["test"]) delta = plugin.diff(base, target) ops = delta["ops"] insert_ops = [o for o in ops if o["op"] == "insert"] assert insert_ops, "Expected at least one insert op for new directory" for op in insert_ops: assert op["address"].endswith("/"), ( f"Directory insert op address must end with '/': {op['address']!r}" ) def test_addressed_delete_op_address_has_trailing_slash( self, tmp_path: pathlib.Path ) -> None: """AddressedDeleteOp emitted for a removed empty dir must have address ending in '/'.""" from muse.domain import SnapshotManifest from muse.plugins.code.plugin import CodePlugin root = _init_repo(tmp_path) plugin = CodePlugin() base = SnapshotManifest(files={}, domain="code", directories=["test"]) target = SnapshotManifest(files={}, domain="code", directories=[]) delta = plugin.diff(base, target) ops = delta["ops"] delete_ops = [o for o in ops if o["op"] == "delete"] assert delete_ops, "Expected at least one delete op for removed directory" for op in delete_ops: assert op["address"].endswith("/"), ( f"Directory delete op address must end with '/': {op['address']!r}" ) def test_rename_op_address_has_trailing_slash( self, tmp_path: pathlib.Path ) -> None: """RenameOp for a directory must have address and from_address both ending in '/'.""" from muse.domain import SnapshotManifest from muse.plugins.code.plugin import CodePlugin root = _init_repo(tmp_path) plugin = CodePlugin() # old/ → new/ rename: same files, different directory prefix content = b"# file\n" oid = blob_id(content) write_object(root, oid, content) base = SnapshotManifest( files={"old/file.py": oid}, domain="code", directories=["old"] ) target = SnapshotManifest( files={"new/file.py": oid}, domain="code", directories=["new"] ) delta = plugin.diff(base, target, repo_root=root) ops = delta["ops"] rename_ops = [o for o in ops if o["op"] == "rename" and "::" not in o["address"]] assert rename_ops, "Expected a rename op for directory rename" for op in rename_ops: assert op["address"].endswith("/"), ( f"RenameOp address must end with '/': {op['address']!r}" ) assert op["from_address"].endswith("/"), ( f"RenameOp from_address must end with '/': {op['from_address']!r}" )