"""Comprehensive directory lifecycle tests — all states × all commands. State matrix: U — untracked: on disk, not in HEAD, not staged SA — staged added: on disk, not in HEAD, staged A C — clean: on disk, in HEAD, not staged UD — unstaged del: not on disk, in HEAD, not staged SD — staged deleted: not on disk, in HEAD, staged D SR — staged renamed: muse mv produced D(old)+A(new)+rename_map Commands covered: muse status (text+json), muse diff, muse code add, muse mv, muse code reset, muse commit. """ from __future__ import annotations import datetime import json import os import pathlib import shutil 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() # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- def _make_repo(path: pathlib.Path, dirs: list[str] | None = None) -> pathlib.Path: """Init a minimal code-domain repo with one committed file.""" 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": "test", "domain": "code"}), encoding="utf-8" ) content = b"readme\n" oid = blob_id(content) write_object(path, oid, content) manifest = {"readme.md": oid} snap_id = hash_snapshot(manifest, dirs or []) write_snapshot(path, SnapshotRecord( snapshot_id=snap_id, manifest=manifest, directories=dirs or [] )) now = datetime.datetime.now(datetime.timezone.utc) cid = hash_commit( parent_ids=[], snapshot_id=snap_id, message="init", committed_at_iso=now.isoformat(), ) write_commit(path, CommitRecord( commit_id=cid, branch="main", snapshot_id=snap_id, message="init", committed_at=now, parent_commit_id=None, )) ref_path(path, "main").write_text(cid, encoding="utf-8") (path / "readme.md").write_bytes(content) return path def _env(root: pathlib.Path) -> Mapping[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _status_json(root: pathlib.Path) -> Mapping[str, object]: return json.loads(runner.invoke(None, ["status", "--json"], env=_env(root)).output) def _diff_json(root: pathlib.Path) -> Mapping[str, object]: return json.loads(runner.invoke(None, ["diff", "--json"], env=_env(root)).output) # --------------------------------------------------------------------------- # State U — untracked empty directory # --------------------------------------------------------------------------- class TestStateUntracked: """Empty dir exists on disk, not in HEAD, not staged.""" def test_status_json_shows_untracked(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() d = _status_json(root) assert "newdir/" in d["untracked"] assert d["clean"] is False def test_status_text_labels_untracked_directory(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() out = runner.invoke(None, ["status"], env=_env(root)).output assert "untracked directory" in out assert "newdir/" in out def test_not_in_added_or_deleted(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() d = _status_json(root) assert "newdir/" not in d["added"] assert "newdir/" not in d["deleted"] def test_diff_does_not_show_untracked(self, tmp_path: pathlib.Path) -> None: """Untracked dirs are invisible to diff (same as git).""" root = _make_repo(tmp_path) (root / "newdir").mkdir() d = _diff_json(root) assert d["has_changes"] is False # --------------------------------------------------------------------------- # State SA — staged added (new empty dir, staged via muse code add) # --------------------------------------------------------------------------- class TestStateStagedAdded: """Empty dir on disk, not in HEAD, staged as A sentinel.""" def test_shows_in_staged_added(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "newdir/"], env=_env(root)) d = _status_json(root) assert "newdir/" in d["staged"]["added"] assert "newdir/" in d["added"] assert "newdir/" not in d["untracked"] def test_status_text_shows_new_directory(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "newdir/"], env=_env(root)) out = runner.invoke(None, ["status"], env=_env(root)).output assert "new directory" in out assert "newdir/" in out def test_diff_shows_as_added(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "newdir/"], env=_env(root)) d = _diff_json(root) assert "newdir/" in d["added"] def test_reset_clears_back_to_untracked(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "newdir/"], env=_env(root)) runner.invoke(None, ["code", "reset"], env=_env(root)) d = _status_json(root) assert "newdir/" in d["untracked"] assert d["staged"]["added"] == [] def test_commit_includes_directory(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path) (root / "newdir").mkdir() monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "newdir/"], env=_env(root)) runner.invoke(None, ["commit", "-m", "add dir"], env=_env(root)) # After commit, dir is clean d = _status_json(root) assert d["clean"] is True # --------------------------------------------------------------------------- # State C — clean (committed dir still on disk) # --------------------------------------------------------------------------- class TestStateClean: """Empty dir on disk, in HEAD dirs, not staged → nothing to report.""" def test_clean_when_committed_dir_unchanged(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() d = _status_json(root) assert d["clean"] is True assert d["untracked"] == [] assert d["added"] == [] assert d["deleted"] == [] def test_status_text_says_nothing_to_commit(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() out = runner.invoke(None, ["status"], env=_env(root)).output assert "working tree clean" in out # --------------------------------------------------------------------------- # State UD — unstaged deletion (committed dir removed from disk) # --------------------------------------------------------------------------- class TestStateUnstagedDeleted: """Committed empty dir removed from disk, not yet staged.""" def test_shows_in_deleted(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") d = _status_json(root) assert "mydir/" in d["deleted"] assert d["clean"] is False def test_in_unstaged_deleted(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") d = _status_json(root) assert "mydir/" in d["unstaged"]["deleted"] assert "mydir/" not in d["staged"]["deleted"] def test_status_text_shows_deleted(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") out = runner.invoke(None, ["status"], env=_env(root)).output assert "deleted" in out assert "mydir/" in out def test_code_add_dot_stages_deletion(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") monkeypatch.chdir(root) out = runner.invoke(None, ["code", "add", "."], env=_env(root)).output assert "director" in out # "1 directory" or "directories" d = _status_json(root) assert "mydir/" in d["staged"]["deleted"] def test_code_add_explicit_path_stages_deletion(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "mydir/"], env=_env(root)) d = _status_json(root) assert "mydir/" in d["staged"]["deleted"] # --------------------------------------------------------------------------- # State SD — staged deletion # --------------------------------------------------------------------------- class TestStateStagedDeleted: """Committed empty dir staged for deletion.""" def test_shows_in_staged_deleted(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "."], env=_env(root)) d = _status_json(root) assert "mydir/" in d["staged"]["deleted"] assert "mydir/" not in d["unstaged"]["deleted"] def test_status_text_shows_deleted_directory(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "."], env=_env(root)) out = runner.invoke(None, ["status"], env=_env(root)).output assert "deleted directory" in out assert "mydir/" in out def test_reset_moves_back_to_unstaged(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "."], env=_env(root)) runner.invoke(None, ["code", "reset"], env=_env(root)) d = _status_json(root) assert "mydir/" in d["unstaged"]["deleted"] assert d["staged"]["deleted"] == [] def test_no_double_entry_in_deleted(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """mydir/ must appear exactly once in deleted[], not twice.""" root = _make_repo(tmp_path, dirs=["mydir"]) (root / "mydir").mkdir() shutil.rmtree(root / "mydir") monkeypatch.chdir(root) runner.invoke(None, ["code", "add", "."], env=_env(root)) d = _status_json(root) assert d["deleted"].count("mydir/") == 1 # --------------------------------------------------------------------------- # State SR — staged rename (muse mv) # --------------------------------------------------------------------------- class TestStateStagedRenamed: """Committed empty dir renamed via muse mv — D(old)+A(new)+rename_map.""" def test_status_json_shows_renamed(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root)) d = _status_json(root) assert "olddir/" in d["renamed"] assert d["renamed"]["olddir/"] == "newdir/" def test_staged_renamed_not_in_added_or_deleted(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root)) d = _status_json(root) assert "olddir/" not in d["added"] assert "newdir/" not in d["added"] assert "olddir/" not in d["deleted"] assert "newdir/" not in d["deleted"] def test_total_changes_is_one(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root)) d = _status_json(root) assert d["total_changes"] == 1 def test_status_text_shows_renamed_directory(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root)) out = runner.invoke(None, ["status"], env=_env(root)).output assert "renamed directory" in out assert "olddir/" in out assert "newdir/" in out def test_diff_shows_r_not_a_plus_d(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root)) d = _diff_json(root) assert "olddir/" in d["renamed"] assert d["renamed"]["olddir/"] == "newdir/" assert "olddir/" not in d["deleted"] assert "newdir/" not in d["added"] def test_reset_clears_rename_map(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir"], env=_env(root)) runner.invoke(None, ["code", "reset"], env=_env(root)) d = _status_json(root) assert d["renamed"] == {} # After reset: olddir deleted from disk, newdir untracked assert "olddir/" in d["deleted"] assert "newdir/" in d["untracked"] def test_trailing_slash_on_dest_is_cosmetic(self, tmp_path: pathlib.Path) -> None: """muse mv olddir newdir/ where newdir doesn't exist → rename to newdir.""" root = _make_repo(tmp_path, dirs=["olddir"]) (root / "olddir").mkdir() runner.invoke(None, ["mv", "olddir", "newdir/"], env=_env(root)) d = _status_json(root) assert "olddir/" in d["renamed"] assert d["renamed"]["olddir/"] == "newdir/" def test_rename_chain_collapses(self, tmp_path: pathlib.Path) -> None: """A→B then B→C collapses to A→C in the rename map.""" root = _make_repo(tmp_path, dirs=["alpha"]) (root / "alpha").mkdir() runner.invoke(None, ["mv", "alpha", "beta"], env=_env(root)) runner.invoke(None, ["mv", "beta", "gamma"], env=_env(root)) d = _status_json(root) assert "alpha/" in d["renamed"] assert d["renamed"]["alpha/"] == "gamma/" assert "beta/" not in d["renamed"] def test_move_inside_existing_dir(self, tmp_path: pathlib.Path) -> None: """muse mv src existingdir/ where existingdir exists → moves inside.""" root = _make_repo(tmp_path, dirs=["src", "lib"]) (root / "src").mkdir() (root / "lib").mkdir() runner.invoke(None, ["mv", "src", "lib/"], env=_env(root)) # lib/ exists → src moves inside to lib/src assert (root / "lib" / "src").exists() assert not (root / "src").exists() # --------------------------------------------------------------------------- # muse mv for non-empty directories (files inside) # --------------------------------------------------------------------------- class TestMvNonEmptyDir: def test_mv_nonempty_dir_restages_all_files(self, tmp_path: pathlib.Path) -> None: root = _make_repo(tmp_path) content = b"hello\n" oid = blob_id(content) write_object(root, oid, content) # Manually add a file in a subdirectory to HEAD from muse.core.refs import get_head_commit_id, read_current_branch from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot, write_snapshot branch = read_current_branch(root) cid = get_head_commit_id(root, branch) commit = read_commit(root, cid) snap = read_snapshot(root, commit.snapshot_id) new_manifest = dict(snap.manifest) new_manifest["src/hello.py"] = oid new_snap_id = hash_snapshot(new_manifest, []) write_snapshot(root, SnapshotRecord( snapshot_id=new_snap_id, manifest=new_manifest, directories=[] )) now = datetime.datetime.now(datetime.timezone.utc) new_cid = hash_commit( parent_ids=[cid], snapshot_id=new_snap_id, message="add file", committed_at_iso=now.isoformat(), ) write_commit(root, CommitRecord( commit_id=new_cid, branch=branch, snapshot_id=new_snap_id, message="add file", committed_at=now, parent_commit_id=cid, )) ref_path(root, branch).write_text(new_cid, encoding="utf-8") (root / "src").mkdir() (root / "src" / "hello.py").write_bytes(content) runner.invoke(None, ["mv", "src", "lib"], env=_env(root)) d = _status_json(root) assert "lib/hello.py" in d["staged"]["added"] assert "src/hello.py" in d["staged"]["deleted"] assert (root / "lib" / "hello.py").exists() assert not (root / "src").exists()