gabriel / muse public
test_status_directory_tracking.py python
235 lines 8.1 KB
Raw
1 """Directory tracking in ``muse status``.
2
3 Directories are first-class symbols in Muse, fully symmetric with files.
4 The only distinguisher is a trailing ``/`` on their path.
5
6 Symmetry rules
7 --------------
8 Untracked file → untracked: ["foo.py"]
9 Untracked dir → untracked: ["foobar/"]
10 Staged file → staged.added: ["foo.py"], added: ["foo.py"]
11 Staged dir → staged.added: ["foobar/"], added: ["foobar/"]
12 Deleted file → deleted: ["old.py"], unstaged.deleted: ["old.py"]
13 Deleted dir → deleted: ["old/"], unstaged.deleted: ["old/"]
14
15 Coverage matrix
16 ---------------
17 D Directory tracking in muse status
18 D1 mkdir foobar → "foobar/" in untracked (same as an untracked file)
19 D2 dirty=True and total_changes==0, untracked_count==1 after mkdir alone
20 D3 after muse code add foobar → "foobar/" in staged.added and added
21 D4 staged dir not in untracked
22 D5 committed dir deleted → "emptydir/" in deleted and unstaged.deleted
23 D6 non-empty dir (has files) not in untracked — covered by its files
24 D7 file inside new dir appears in untracked, dir itself does not
25 D8 clean repo has no trailing-slash entries anywhere
26 """
27
28 from __future__ import annotations
29
30 import json
31 import pathlib
32 from collections.abc import Mapping
33
34 import pytest
35
36 from tests.cli_test_helper import CliRunner
37
38 cli = None
39 runner = CliRunner()
40
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46
47 def _env(root: pathlib.Path) -> Mapping[str, str]:
48 return {"MUSE_REPO_ROOT": str(root)}
49
50
51 def _run(root: pathlib.Path, *args: str) -> str:
52 result = runner.invoke(cli, list(args), env=_env(root))
53 assert result.exit_code == 0, f"{args} failed: {result.output}"
54 return result.output.strip()
55
56
57 def _status(root: pathlib.Path) -> Mapping[str, object]:
58 return json.loads(_run(root, "status", "--json"))
59
60
61 @pytest.fixture()
62 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
63 """Code repo with one committed file and no pending changes."""
64 monkeypatch.chdir(tmp_path)
65 _run(tmp_path, "init", "--domain", "code")
66 (tmp_path / "main.py").write_text("x = 1\n")
67 _run(tmp_path, "code", "add", ".")
68 _run(tmp_path, "commit", "-m", "initial")
69 return tmp_path
70
71
72 @pytest.fixture()
73 def repo_with_committed_dir(
74 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
75 ) -> pathlib.Path:
76 """Code repo with a committed empty directory ``emptydir``."""
77 monkeypatch.chdir(tmp_path)
78 _run(tmp_path, "init", "--domain", "code")
79 (tmp_path / "main.py").write_text("x = 1\n")
80 (tmp_path / "emptydir").mkdir()
81 _run(tmp_path, "code", "add", ".")
82 _run(tmp_path, "commit", "-m", "initial with emptydir")
83 return tmp_path
84
85
86 # ---------------------------------------------------------------------------
87 # D Directory tracking
88 # ---------------------------------------------------------------------------
89
90
91 class TestDirectoryTracking:
92 def test_D1_new_empty_dir_in_untracked(
93 self, code_repo: pathlib.Path
94 ) -> None:
95 """D1: mkdir foobar → 'foobar/' in untracked, same as an untracked file."""
96 root = code_repo
97 (root / "foobar").mkdir()
98
99 data = _status(root)
100
101 assert "foobar/" in data["untracked"], (
102 "new empty dir must appear in untracked with a trailing slash"
103 )
104
105 def test_D2_untracked_dir_counts_as_untracked_not_change(
106 self, code_repo: pathlib.Path
107 ) -> None:
108 """D2: mkdir alone → dirty, untracked_count==1, total_changes==0."""
109 root = code_repo
110 (root / "foobar").mkdir()
111
112 data = _status(root)
113
114 assert data["dirty"] is True
115 assert data["clean"] is False
116 assert data["untracked_count"] == 1
117 assert data["total_changes"] == 0
118
119 def test_D3_staged_dir_in_staged_added_and_added(
120 self, code_repo: pathlib.Path
121 ) -> None:
122 """D3: after muse code add foobar → 'foobar/' in staged.added and added."""
123 root = code_repo
124 (root / "foobar").mkdir()
125 _run(root, "code", "add", "foobar")
126
127 data = _status(root)
128
129 assert "foobar/" in data["staged"]["added"], (
130 "staged dir must appear in staged.added with trailing slash"
131 )
132 assert "foobar/" in data["added"], (
133 "staged dir must appear in flat added"
134 )
135
136 def test_D4_staged_dir_not_in_untracked(
137 self, code_repo: pathlib.Path
138 ) -> None:
139 """D4: staged directory does not appear in untracked."""
140 root = code_repo
141 (root / "foobar").mkdir()
142 _run(root, "code", "add", "foobar")
143
144 data = _status(root)
145
146 assert "foobar/" not in data["untracked"]
147
148 def test_D5_deleted_committed_dir_in_deleted_and_unstaged(
149 self, repo_with_committed_dir: pathlib.Path
150 ) -> None:
151 """D5: committed empty dir deleted → 'emptydir/' in deleted and unstaged.deleted."""
152 root = repo_with_committed_dir
153 (root / "emptydir").rmdir()
154
155 data = _status(root)
156
157 assert "emptydir/" in data["deleted"], (
158 "deleted committed dir must appear in deleted with trailing slash"
159 )
160 assert "emptydir/" in data["unstaged"]["deleted"], (
161 "deleted committed dir must appear in unstaged.deleted with trailing slash"
162 )
163
164 def test_D6_non_empty_dir_not_in_untracked(
165 self, code_repo: pathlib.Path
166 ) -> None:
167 """D6: dir with a new file — 'src/' not in untracked, its file is."""
168 root = code_repo
169 (root / "src").mkdir()
170 (root / "src" / "util.py").write_text("pass\n")
171
172 data = _status(root)
173
174 assert "src/" not in data["untracked"], (
175 "non-empty dir must not appear in untracked; its files represent it"
176 )
177
178 def test_D7_file_in_new_dir_in_untracked(
179 self, code_repo: pathlib.Path
180 ) -> None:
181 """D7: file inside a new dir appears in untracked, dir itself does not."""
182 root = code_repo
183 (root / "src").mkdir()
184 (root / "src" / "util.py").write_text("pass\n")
185
186 data = _status(root)
187
188 assert "src/util.py" in data["untracked"]
189 assert "src/" not in data["untracked"]
190
191 def test_D8_clean_repo_has_no_dir_entries(
192 self, code_repo: pathlib.Path
193 ) -> None:
194 """D8: clean repo has no trailing-slash entries in any bucket."""
195 data = _status(code_repo)
196
197 def has_dir_entry(val: list[str] | Mapping[str, object] | str | bool | int | None) -> bool:
198 if isinstance(val, list):
199 return any(isinstance(v, str) and v.endswith("/") for v in val)
200 if isinstance(val, dict):
201 return any(has_dir_entry(v) for v in val.values())
202 return False
203
204 assert not has_dir_entry(data["added"])
205 assert not has_dir_entry(data["deleted"])
206 assert not has_dir_entry(data["staged"])
207 assert not has_dir_entry(data["untracked"])
208
209 def test_D9_committed_dir_not_in_staged_added_after_code_add(
210 self, repo_with_committed_dir: pathlib.Path
211 ) -> None:
212 """D9: running muse code add . after committing an empty dir must not
213 show that dir as staged.added — it is already committed."""
214 root = repo_with_committed_dir
215 _run(root, "code", "add", ".")
216
217 data = _status(root)
218
219 assert "emptydir/" not in data["staged"]["added"], (
220 "already-committed empty dir must not appear in staged.added after code add ."
221 )
222 assert "emptydir/" not in data["added"], (
223 "already-committed empty dir must not appear in flat added"
224 )
225
226 def test_D10_committed_dir_clean_after_commit(
227 self, repo_with_committed_dir: pathlib.Path
228 ) -> None:
229 """D10: repo with a committed empty dir and no other changes is clean."""
230 data = _status(repo_with_committed_dir)
231
232 assert data["clean"] is True, (
233 "repo with only a committed empty dir and no pending changes must be clean"
234 )
235 assert data["total_changes"] == 0
File History 1 commit