gabriel / muse public
test_status_json_schema.py python
400 lines 15.4 KB
Raw
sha256:248464b6a2f758985cbef90f864fa62c61842be699d975d6e00b6a9509ef919c fix(delta): detect blob-identical file renames for files wi… Sonnet 4.6 patch 24 days ago
1 """Tests for the canonical ``muse status --json`` schema.
2
3 Every code path that produces ``muse status --json`` output must emit the
4 *same* shape. Agents rely on this stability — a schema that changes
5 depending on whether a stage index is present is a latent bug.
6
7 Canonical schema
8 ----------------
9 ::
10
11 {
12 "branch": str,
13 "head_commit": str | null,
14 "upstream": str | null,
15 "ahead": int | null,
16 "behind": int | null,
17 "clean": bool,
18 "dirty": bool,
19 "total_changes": int,
20
21 // Flat view — always populated, union of staged + unstaged.
22 // Primary interface: agents that only need "what changed" use these.
23 "added": [str, ...],
24 "modified": [str, ...],
25 "deleted": [str, ...],
26 "renamed": {str: str, ...},
27
28 // Staging detail — null when domain has no staging concept.
29 // When non-null, partitions the flat view.
30 "staged": {
31 "added": [str, ...],
32 "modified": [str, ...],
33 "deleted": [str, ...]
34 } | null,
35 "unstaged": {
36 "added": [str, ...],
37 "modified": [str, ...],
38 "deleted": [str, ...]
39 } | null,
40
41 // Files on disk but not tracked by Muse. Always [] for non-code domains.
42 "untracked": [str, ...],
43
44 // Merge state — always present.
45 "conflict_paths": [str, ...],
46 "merge_in_progress": bool,
47 "merge_from": str | null,
48 "conflict_count": int,
49 "checkout_interrupted": bool,
50 "checkout_target": str | null
51 }
52
53 Coverage matrix
54 ---------------
55 I Schema invariants (always-present keys, correct types)
56 I1 Clean repo — all present, all empty/false
57 I2 Code domain with staged changes — same keys, staged non-null
58 I3 Code domain with unstaged changes — staged sub-obj still present
59 I4 Code domain with both staged and unstaged — both sub-objs populated
60 I5 Code domain with untracked files — untracked list populated
61
62 II Flat view correctness
63 II1 added = staged.added ∪ unstaged.added
64 II2 modified = staged.modified ∪ unstaged.modified
65 II3 deleted = staged.deleted ∪ unstaged.deleted
66 II4 total_changes = len(added) + len(modified) + len(deleted) + len(renamed)
67 II5 File in both staged and unstaged appears once in flat view
68
69 III Stage-domain vs no-stage-domain
70 III1 Code domain (stage): staged and unstaged are dicts, not null
71 III2 No stage index present: staged and unstaged are null (non-staged run)
72
73 IV Specific field values
74 IV1 branch matches current branch
75 IV2 head_commit is sha256:-prefixed
76 IV3 clean=True only when no changes
77 IV4 dirty = not clean, always
78 """
79
80 from __future__ import annotations
81 from collections.abc import Mapping
82
83 import json
84 import pathlib
85
86 import pytest
87
88 from tests.cli_test_helper import CliRunner
89
90 cli = None
91 runner = CliRunner()
92
93 # ---------------------------------------------------------------------------
94 # Helpers
95 # ---------------------------------------------------------------------------
96
97 _REQUIRED_TOP_KEYS = {
98 "branch", "head_commit", "upstream", "ahead", "behind",
99 "clean", "dirty", "total_changes",
100 "added", "modified", "deleted", "renamed",
101 "staged", "unstaged", "untracked",
102 "conflict_paths", "merge_in_progress", "merge_from",
103 "conflict_count", "checkout_interrupted", "checkout_target",
104 }
105
106 _STAGED_BUCKET_KEYS = {"added", "modified", "deleted"}
107
108
109 def _env(root: pathlib.Path) -> Mapping[str, str]:
110 return {"MUSE_REPO_ROOT": str(root)}
111
112
113 def _status_json(root: pathlib.Path) -> Mapping[str, object]:
114 result = runner.invoke(cli, ["status", "--json"], env=_env(root))
115 assert result.exit_code == 0, f"status --json failed: {result.output}"
116 return json.loads(result.output.strip())
117
118
119 @pytest.fixture()
120 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
121 """Minimal code-domain repo with one committed file."""
122 monkeypatch.chdir(tmp_path)
123 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
124 assert result.exit_code == 0, result.output
125 (tmp_path / "main.py").write_text("x = 1\n")
126 runner.invoke(cli, ["code", "add", "."], env=_env(tmp_path))
127 result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
128 assert result.exit_code == 0, result.output
129 return tmp_path
130
131
132 # ---------------------------------------------------------------------------
133 # I Schema invariants
134 # ---------------------------------------------------------------------------
135
136
137 class TestSchemaInvariantsI:
138 def test_I1_clean_repo_all_required_keys_present(self, code_repo: pathlib.Path) -> None:
139 """I1: Clean repo — every required key is present with correct type."""
140 root = code_repo
141 data = _status_json(root)
142
143 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
144 f"Missing keys: {_REQUIRED_TOP_KEYS - data.keys()}"
145 )
146 assert data["clean"] is True
147 assert data["dirty"] is False
148 assert data["added"] == []
149 assert data["modified"] == []
150 assert data["deleted"] == []
151 assert data["renamed"] == {}
152 assert data["untracked"] == []
153 assert data["total_changes"] == 0
154 assert data["conflict_paths"] == []
155 assert data["merge_in_progress"] is False
156 assert data["merge_from"] is None
157 assert data["conflict_count"] == 0
158
159 def test_I2_staged_file_schema_unchanged(self, code_repo: pathlib.Path) -> None:
160 """I2: Staged changes — all top-level keys present, staged is a dict not null."""
161 root = code_repo
162 (root / "main.py").write_text("x = 2\n")
163 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
164
165 data = _status_json(root)
166
167 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
168 f"Missing keys: {_REQUIRED_TOP_KEYS - data.keys()}"
169 )
170 assert data["staged"] is not None
171 assert data["unstaged"] is not None
172 assert _STAGED_BUCKET_KEYS == set(data["staged"].keys()), (
173 f"staged sub-object has wrong keys: {data['staged'].keys()}"
174 )
175 assert _STAGED_BUCKET_KEYS == set(data["unstaged"].keys()), (
176 f"unstaged sub-object has wrong keys: {data['unstaged'].keys()}"
177 )
178
179 def test_I3_unstaged_file_schema_unchanged(self, code_repo: pathlib.Path) -> None:
180 """I3: Unstaged changes only (nothing staged) — staged sub-obj still present."""
181 root = code_repo
182 # Modify file but do NOT stage it
183 (root / "main.py").write_text("x = 3\n")
184
185 data = _status_json(root)
186
187 assert _REQUIRED_TOP_KEYS.issubset(data.keys())
188 # staged and unstaged must be present even when nothing is staged
189 assert data["staged"] is not None
190 assert data["unstaged"] is not None
191 assert _STAGED_BUCKET_KEYS == set(data["staged"].keys())
192 assert _STAGED_BUCKET_KEYS == set(data["unstaged"].keys())
193
194 def test_I4_both_staged_and_unstaged(self, code_repo: pathlib.Path) -> None:
195 """I4: Both staged and unstaged changes — both sub-objs populated."""
196 root = code_repo
197 # Stage main.py modification
198 (root / "main.py").write_text("x = 2\n")
199 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
200 # Then modify it again (now staged M + unstaged M)
201 (root / "main.py").write_text("x = 3\n")
202 # Also add a new file unstaged
203 (root / "other.py").write_text("y = 1\n")
204
205 data = _status_json(root)
206
207 assert _REQUIRED_TOP_KEYS.issubset(data.keys())
208 assert data["staged"] is not None
209 assert data["unstaged"] is not None
210 assert data["dirty"] is True
211
212 def test_I5_untracked_files_in_list(self, code_repo: pathlib.Path) -> None:
213 """I5: Untracked files appear in untracked list, not in added."""
214 root = code_repo
215 (root / "brand_new.py").write_text("# not staged\n")
216
217 data = _status_json(root)
218
219 assert "brand_new.py" in data["untracked"]
220 # Untracked (not staged) must NOT appear in flat added
221 assert "brand_new.py" not in data["added"]
222
223
224 # ---------------------------------------------------------------------------
225 # II Flat view correctness
226 # ---------------------------------------------------------------------------
227
228
229 class TestFlatViewCorrectnessII:
230 def test_II1_flat_added_is_union_of_staged_and_unstaged(
231 self, code_repo: pathlib.Path
232 ) -> None:
233 """II1: flat added = staged.added ∪ unstaged.added."""
234 root = code_repo
235 (root / "new_a.py").write_text("a\n")
236 (root / "new_b.py").write_text("b\n")
237 runner.invoke(cli, ["code", "add", "new_a.py"], env=_env(root))
238 # new_b.py is untracked, not in either bucket
239
240 data = _status_json(root)
241
242 flat_added = set(data["added"])
243 staged_added = set(data["staged"]["added"])
244 unstaged_added = set(data["unstaged"]["added"])
245 assert flat_added == staged_added | unstaged_added
246
247 def test_II2_flat_modified_is_union_of_staged_and_unstaged(
248 self, code_repo: pathlib.Path
249 ) -> None:
250 """II2: flat modified = staged.modified ∪ unstaged.modified."""
251 root = code_repo
252 (root / "extra.py").write_text("e = 1\n")
253 runner.invoke(cli, ["code", "add", "extra.py"], env=_env(root))
254 runner.invoke(cli, ["commit", "-m", "add extra"], env=_env(root))
255
256 # Stage main.py modification
257 (root / "main.py").write_text("x = 2\n")
258 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
259 # Unstaged: modify extra.py
260 (root / "extra.py").write_text("e = 99\n")
261
262 data = _status_json(root)
263
264 flat_modified = set(data["modified"])
265 staged_modified = set(data["staged"]["modified"])
266 unstaged_modified = set(data["unstaged"]["modified"])
267 assert flat_modified == staged_modified | unstaged_modified
268 assert "main.py" in staged_modified
269 assert "extra.py" in unstaged_modified
270
271 def test_II3_flat_deleted_is_union_of_staged_and_unstaged(
272 self, code_repo: pathlib.Path
273 ) -> None:
274 """II3: flat deleted = staged.deleted ∪ unstaged.deleted."""
275 root = code_repo
276 (root / "to_delete.py").write_text("d = 1\n")
277 runner.invoke(cli, ["code", "add", "to_delete.py"], env=_env(root))
278 runner.invoke(cli, ["commit", "-m", "add to_delete"], env=_env(root))
279
280 # Delete and stage the deletion
281 (root / "to_delete.py").unlink()
282 runner.invoke(cli, ["code", "add", "to_delete.py"], env=_env(root))
283
284 data = _status_json(root)
285
286 flat_deleted = set(data["deleted"])
287 staged_deleted = set(data["staged"]["deleted"])
288 unstaged_deleted = set(data["unstaged"]["deleted"])
289 assert flat_deleted == staged_deleted | unstaged_deleted
290 assert "to_delete.py" in flat_deleted
291
292 def test_II4_total_changes_is_sum_of_flat(self, code_repo: pathlib.Path) -> None:
293 """II4: total_changes = len(added) + len(modified) + len(deleted) + len(renamed)."""
294 root = code_repo
295 (root / "main.py").write_text("x = 2\n")
296 (root / "new.py").write_text("n = 1\n")
297 runner.invoke(cli, ["code", "add", "main.py", "new.py"], env=_env(root))
298
299 data = _status_json(root)
300
301 expected = (
302 len(data["added"]) + len(data["modified"])
303 + len(data["deleted"]) + len(data["renamed"])
304 )
305 assert data["total_changes"] == expected
306
307 def test_II5_file_in_both_staged_and_unstaged_appears_once_flat(
308 self, code_repo: pathlib.Path
309 ) -> None:
310 """II5: A file staged then modified again appears once in flat modified."""
311 root = code_repo
312 (root / "main.py").write_text("x = 2\n")
313 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
314 (root / "main.py").write_text("x = 3\n") # modify again after staging
315
316 data = _status_json(root)
317
318 assert data["modified"].count("main.py") == 1, (
319 "main.py must appear exactly once in flat modified"
320 )
321
322
323 # ---------------------------------------------------------------------------
324 # III Stage-domain vs no-stage path
325 # ---------------------------------------------------------------------------
326
327
328 class TestStageDomainVsNonStageIII:
329 def test_III1_code_domain_staged_and_unstaged_are_dicts(
330 self, code_repo: pathlib.Path
331 ) -> None:
332 """III1: Code domain (has stage) — staged/unstaged are dicts, not null."""
333 root = code_repo
334 # Even clean — staging infrastructure exists, so never null
335 data = _status_json(root)
336
337 assert data["staged"] is not None, "staged must not be null for code domain"
338 assert data["unstaged"] is not None, "unstaged must not be null for code domain"
339 assert isinstance(data["staged"], dict)
340 assert isinstance(data["unstaged"], dict)
341
342 def test_III2_no_stage_index_staged_and_unstaged_are_null(
343 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
344 ) -> None:
345 """III2: When stage index is absent (domain has no staging), staged/unstaged are null."""
346 monkeypatch.chdir(tmp_path)
347 # Use mist domain — has no StagePlugin (unlike code domain)
348 result = runner.invoke(cli, ["init", "--domain", "mist"], env=_env(tmp_path))
349 assert result.exit_code == 0, result.output
350
351 data = _status_json(tmp_path)
352
353 assert data["staged"] is None, (
354 f"staged must be null for non-stage domain, got {data['staged']}"
355 )
356 assert data["unstaged"] is None, (
357 f"unstaged must be null for non-stage domain, got {data['unstaged']}"
358 )
359
360
361 # ---------------------------------------------------------------------------
362 # IV Specific field values
363 # ---------------------------------------------------------------------------
364
365
366 class TestSpecificFieldValuesIV:
367 def test_IV1_branch_matches_current_branch(self, code_repo: pathlib.Path) -> None:
368 """IV1: branch field matches the actual current branch."""
369 root = code_repo
370 data = _status_json(root)
371 assert data["branch"] == "main"
372
373 def test_IV2_head_commit_is_sha256_prefixed(self, code_repo: pathlib.Path) -> None:
374 """IV2: head_commit is sha256:-prefixed (not bare hex, not null after first commit)."""
375 root = code_repo
376 data = _status_json(root)
377 assert data["head_commit"] is not None
378 assert data["head_commit"].startswith("sha256:"), (
379 f"head_commit must be sha256:-prefixed, got {data['head_commit']!r}"
380 )
381
382 def test_IV3_clean_true_only_when_no_changes(self, code_repo: pathlib.Path) -> None:
383 """IV3: clean=True only when working tree matches HEAD exactly."""
384 root = code_repo
385 assert _status_json(root)["clean"] is True
386
387 (root / "main.py").write_text("x = 99\n")
388 assert _status_json(root)["clean"] is False
389
390 def test_IV4_dirty_is_not_clean(self, code_repo: pathlib.Path) -> None:
391 """IV4: dirty = not clean, always — both are always present."""
392 root = code_repo
393
394 data_clean = _status_json(root)
395 assert data_clean["dirty"] is not data_clean["clean"]
396
397 (root / "main.py").write_text("x = 99\n")
398 data_dirty = _status_json(root)
399 assert data_dirty["dirty"] is not data_dirty["clean"]
400 assert data_dirty["dirty"] is True
File History 1 commit
sha256:248464b6a2f758985cbef90f864fa62c61842be699d975d6e00b6a9509ef919c fix(delta): detect blob-identical file renames for files wi… Sonnet 4.6 patch 24 days ago