gabriel / muse public
test_status_json_schema.py python
495 lines 19.0 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 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 "untracked_count": int,
21
22 // Flat view — always populated, union of staged + unstaged.
23 // Primary interface: agents that only need "what changed" use these.
24 "added": [str, ...],
25 "modified": [str, ...],
26 "deleted": [str, ...],
27 "renamed": {str: str, ...},
28
29 // Staging detail — null when domain has no staging concept.
30 // When non-null, partitions the flat view.
31 "staged": {
32 "added": [str, ...],
33 "modified": [str, ...],
34 "deleted": [str, ...]
35 } | null,
36 "unstaged": {
37 "added": [str, ...],
38 "modified": [str, ...],
39 "deleted": [str, ...]
40 } | null,
41
42 // Files on disk but not tracked by Muse. Always [] for non-code domains.
43 "untracked": [str, ...],
44
45 // Merge state — always present.
46 "conflict_paths": [str, ...],
47 "merge_in_progress": bool,
48 "merge_from": str | null,
49 "conflict_count": int,
50 "checkout_interrupted": bool,
51 "checkout_target": str | null
52 }
53
54 Coverage matrix
55 ---------------
56 I Schema invariants (always-present keys, correct types)
57 I1 Clean repo — all present, all empty/false
58 I2 Code domain with staged changes — same keys, staged non-null
59 I3 Code domain with unstaged changes — staged sub-obj still present
60 I4 Code domain with both staged and unstaged — both sub-objs populated
61 I5 Code domain with untracked files — untracked list populated
62
63 II Flat view correctness
64 II1 added = staged.added ∪ unstaged.added
65 II2 modified = staged.modified ∪ unstaged.modified
66 II3 deleted = staged.deleted ∪ unstaged.deleted
67 II4 total_changes = len(added) + len(modified) + len(deleted) + len(renamed)
68 II5 File in both staged and unstaged appears once in flat view
69
70 III Stage-domain vs no-stage-domain
71 III1 Code domain (stage): staged and unstaged are dicts, not null
72 III2 No stage index present: staged and unstaged are null (non-staged run)
73
74 IV Specific field values
75 IV1 branch matches current branch
76 IV2 head_commit is sha256:-prefixed
77 IV3 clean=True only when no changes
78 IV4 dirty = not clean, always
79
80 V untracked_count
81 V1 untracked_count always present as int
82 V2 untracked_count == len(untracked)
83 V3 untracked_count == 0 for a clean repo
84 V4 untracked_count == 0 when total_changes > 0 but no untracked files
85 V5 untracked_count > 0 with only untracked files (total_changes stays 0)
86 V6 untracked_count and total_changes both nonzero when both kinds present
87 """
88
89 from __future__ import annotations
90 from collections.abc import Mapping
91
92 import json
93 import pathlib
94
95 import pytest
96
97 from tests.cli_test_helper import CliRunner
98
99 cli = None
100 runner = CliRunner()
101
102 # ---------------------------------------------------------------------------
103 # Helpers
104 # ---------------------------------------------------------------------------
105
106 _REQUIRED_TOP_KEYS = {
107 "branch", "head_commit", "upstream", "ahead", "behind",
108 "clean", "dirty", "total_changes", "untracked_count",
109 "added", "modified", "deleted", "renamed",
110 "staged", "unstaged", "untracked",
111 "conflict_paths", "merge_in_progress", "merge_from",
112 "conflict_count", "checkout_interrupted", "checkout_target",
113 }
114
115 _STAGED_BUCKET_KEYS = {"added", "modified", "deleted", "renamed"}
116
117
118 def _env(root: pathlib.Path) -> Mapping[str, str]:
119 return {"MUSE_REPO_ROOT": str(root)}
120
121
122 def _status_json(root: pathlib.Path) -> Mapping[str, object]:
123 result = runner.invoke(cli, ["status", "--json"], env=_env(root))
124 assert result.exit_code == 0, f"status --json failed: {result.output}"
125 return json.loads(result.output.strip())
126
127
128 @pytest.fixture()
129 def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
130 """Minimal code-domain repo with one committed file."""
131 monkeypatch.chdir(tmp_path)
132 result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path))
133 assert result.exit_code == 0, result.output
134 (tmp_path / "main.py").write_text("x = 1\n")
135 runner.invoke(cli, ["code", "add", "."], env=_env(tmp_path))
136 result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path))
137 assert result.exit_code == 0, result.output
138 return tmp_path
139
140
141 # ---------------------------------------------------------------------------
142 # I Schema invariants
143 # ---------------------------------------------------------------------------
144
145
146 class TestSchemaInvariantsI:
147 def test_I1_clean_repo_all_required_keys_present(self, code_repo: pathlib.Path) -> None:
148 """I1: Clean repo — every required key is present with correct type."""
149 root = code_repo
150 data = _status_json(root)
151
152 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
153 f"Missing keys: {_REQUIRED_TOP_KEYS - data.keys()}"
154 )
155 assert data["clean"] is True
156 assert data["dirty"] is False
157 assert data["added"] == []
158 assert data["modified"] == []
159 assert data["deleted"] == []
160 assert data["renamed"] == {}
161 assert data["untracked"] == []
162 assert data["total_changes"] == 0
163 assert data["untracked_count"] == 0
164 assert data["conflict_paths"] == []
165 assert data["merge_in_progress"] is False
166 assert data["merge_from"] is None
167 assert data["conflict_count"] == 0
168
169 def test_I2_staged_file_schema_unchanged(self, code_repo: pathlib.Path) -> None:
170 """I2: Staged changes — all top-level keys present, staged is a dict not null."""
171 root = code_repo
172 (root / "main.py").write_text("x = 2\n")
173 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
174
175 data = _status_json(root)
176
177 assert _REQUIRED_TOP_KEYS.issubset(data.keys()), (
178 f"Missing keys: {_REQUIRED_TOP_KEYS - data.keys()}"
179 )
180 assert data["staged"] is not None
181 assert data["unstaged"] is not None
182 assert _STAGED_BUCKET_KEYS == set(data["staged"].keys()), (
183 f"staged sub-object has wrong keys: {data['staged'].keys()}"
184 )
185 assert _STAGED_BUCKET_KEYS == set(data["unstaged"].keys()), (
186 f"unstaged sub-object has wrong keys: {data['unstaged'].keys()}"
187 )
188
189 def test_I3_unstaged_file_schema_unchanged(self, code_repo: pathlib.Path) -> None:
190 """I3: Unstaged changes only (nothing staged) — staged sub-obj still present."""
191 root = code_repo
192 # Modify file but do NOT stage it
193 (root / "main.py").write_text("x = 3\n")
194
195 data = _status_json(root)
196
197 assert _REQUIRED_TOP_KEYS.issubset(data.keys())
198 # staged and unstaged must be present even when nothing is staged
199 assert data["staged"] is not None
200 assert data["unstaged"] is not None
201 assert _STAGED_BUCKET_KEYS == set(data["staged"].keys())
202 assert _STAGED_BUCKET_KEYS == set(data["unstaged"].keys())
203
204 def test_I4_both_staged_and_unstaged(self, code_repo: pathlib.Path) -> None:
205 """I4: Both staged and unstaged changes — both sub-objs populated."""
206 root = code_repo
207 # Stage main.py modification
208 (root / "main.py").write_text("x = 2\n")
209 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
210 # Then modify it again (now staged M + unstaged M)
211 (root / "main.py").write_text("x = 3\n")
212 # Also add a new file unstaged
213 (root / "other.py").write_text("y = 1\n")
214
215 data = _status_json(root)
216
217 assert _REQUIRED_TOP_KEYS.issubset(data.keys())
218 assert data["staged"] is not None
219 assert data["unstaged"] is not None
220 assert data["dirty"] is True
221
222 def test_I5_untracked_files_in_list(self, code_repo: pathlib.Path) -> None:
223 """I5: Untracked files appear in untracked list, not in added."""
224 root = code_repo
225 (root / "brand_new.py").write_text("# not staged\n")
226
227 data = _status_json(root)
228
229 assert "brand_new.py" in data["untracked"]
230 # Untracked (not staged) must NOT appear in flat added
231 assert "brand_new.py" not in data["added"]
232
233
234 # ---------------------------------------------------------------------------
235 # II Flat view correctness
236 # ---------------------------------------------------------------------------
237
238
239 class TestFlatViewCorrectnessII:
240 def test_II1_flat_added_is_union_of_staged_and_unstaged(
241 self, code_repo: pathlib.Path
242 ) -> None:
243 """II1: flat added = staged.added ∪ unstaged.added."""
244 root = code_repo
245 (root / "new_a.py").write_text("a\n")
246 (root / "new_b.py").write_text("b\n")
247 runner.invoke(cli, ["code", "add", "new_a.py"], env=_env(root))
248 # new_b.py is untracked, not in either bucket
249
250 data = _status_json(root)
251
252 flat_added = set(data["added"])
253 staged_added = set(data["staged"]["added"])
254 unstaged_added = set(data["unstaged"]["added"])
255 assert flat_added == staged_added | unstaged_added
256
257 def test_II2_flat_modified_is_union_of_staged_and_unstaged(
258 self, code_repo: pathlib.Path
259 ) -> None:
260 """II2: flat modified = staged.modified ∪ unstaged.modified."""
261 root = code_repo
262 (root / "extra.py").write_text("e = 1\n")
263 runner.invoke(cli, ["code", "add", "extra.py"], env=_env(root))
264 runner.invoke(cli, ["commit", "-m", "add extra"], env=_env(root))
265
266 # Stage main.py modification
267 (root / "main.py").write_text("x = 2\n")
268 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
269 # Unstaged: modify extra.py
270 (root / "extra.py").write_text("e = 99\n")
271
272 data = _status_json(root)
273
274 flat_modified = set(data["modified"])
275 staged_modified = set(data["staged"]["modified"])
276 unstaged_modified = set(data["unstaged"]["modified"])
277 assert flat_modified == staged_modified | unstaged_modified
278 assert "main.py" in staged_modified
279 assert "extra.py" in unstaged_modified
280
281 def test_II3_flat_deleted_is_union_of_staged_and_unstaged(
282 self, code_repo: pathlib.Path
283 ) -> None:
284 """II3: flat deleted = staged.deleted ∪ unstaged.deleted."""
285 root = code_repo
286 (root / "to_delete.py").write_text("d = 1\n")
287 runner.invoke(cli, ["code", "add", "to_delete.py"], env=_env(root))
288 runner.invoke(cli, ["commit", "-m", "add to_delete"], env=_env(root))
289
290 # Delete and stage the deletion
291 (root / "to_delete.py").unlink()
292 runner.invoke(cli, ["code", "add", "to_delete.py"], env=_env(root))
293
294 data = _status_json(root)
295
296 flat_deleted = set(data["deleted"])
297 staged_deleted = set(data["staged"]["deleted"])
298 unstaged_deleted = set(data["unstaged"]["deleted"])
299 assert flat_deleted == staged_deleted | unstaged_deleted
300 assert "to_delete.py" in flat_deleted
301
302 def test_II4_total_changes_is_sum_of_flat(self, code_repo: pathlib.Path) -> None:
303 """II4: total_changes = len(added) + len(modified) + len(deleted) + len(renamed)."""
304 root = code_repo
305 (root / "main.py").write_text("x = 2\n")
306 (root / "new.py").write_text("n = 1\n")
307 runner.invoke(cli, ["code", "add", "main.py", "new.py"], env=_env(root))
308
309 data = _status_json(root)
310
311 expected = (
312 len(data["added"]) + len(data["modified"])
313 + len(data["deleted"]) + len(data["renamed"])
314 )
315 assert data["total_changes"] == expected
316
317 def test_II5_file_in_both_staged_and_unstaged_appears_once_flat(
318 self, code_repo: pathlib.Path
319 ) -> None:
320 """II5: A file staged then modified again appears once in flat modified."""
321 root = code_repo
322 (root / "main.py").write_text("x = 2\n")
323 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
324 (root / "main.py").write_text("x = 3\n") # modify again after staging
325
326 data = _status_json(root)
327
328 assert data["modified"].count("main.py") == 1, (
329 "main.py must appear exactly once in flat modified"
330 )
331
332
333 # ---------------------------------------------------------------------------
334 # III Stage-domain vs no-stage path
335 # ---------------------------------------------------------------------------
336
337
338 class TestStageDomainVsNonStageIII:
339 def test_III1_code_domain_staged_and_unstaged_are_dicts(
340 self, code_repo: pathlib.Path
341 ) -> None:
342 """III1: Code domain (has stage) — staged/unstaged are dicts, not null."""
343 root = code_repo
344 # Even clean — staging infrastructure exists, so never null
345 data = _status_json(root)
346
347 assert data["staged"] is not None, "staged must not be null for code domain"
348 assert data["unstaged"] is not None, "unstaged must not be null for code domain"
349 assert isinstance(data["staged"], dict)
350 assert isinstance(data["unstaged"], dict)
351
352 def test_III2_no_stage_index_staged_and_unstaged_are_null(
353 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
354 ) -> None:
355 """III2: When stage index is absent (domain has no staging), staged/unstaged are null."""
356 monkeypatch.chdir(tmp_path)
357 # Use mist domain — has no StagePlugin (unlike code domain)
358 result = runner.invoke(cli, ["init", "--domain", "mist"], env=_env(tmp_path))
359 assert result.exit_code == 0, result.output
360
361 data = _status_json(tmp_path)
362
363 assert data["staged"] is None, (
364 f"staged must be null for non-stage domain, got {data['staged']}"
365 )
366 assert data["unstaged"] is None, (
367 f"unstaged must be null for non-stage domain, got {data['unstaged']}"
368 )
369
370
371 # ---------------------------------------------------------------------------
372 # IV Specific field values
373 # ---------------------------------------------------------------------------
374
375
376 class TestSpecificFieldValuesIV:
377 def test_IV1_branch_matches_current_branch(self, code_repo: pathlib.Path) -> None:
378 """IV1: branch field matches the actual current branch."""
379 root = code_repo
380 data = _status_json(root)
381 assert data["branch"] == "main"
382
383 def test_IV2_head_commit_is_sha256_prefixed(self, code_repo: pathlib.Path) -> None:
384 """IV2: head_commit is sha256:-prefixed (not bare hex, not null after first commit)."""
385 root = code_repo
386 data = _status_json(root)
387 assert data["head_commit"] is not None
388 assert data["head_commit"].startswith("sha256:"), (
389 f"head_commit must be sha256:-prefixed, got {data['head_commit']!r}"
390 )
391
392 def test_IV3_clean_true_only_when_no_changes(self, code_repo: pathlib.Path) -> None:
393 """IV3: clean=True only when working tree matches HEAD exactly."""
394 root = code_repo
395 assert _status_json(root)["clean"] is True
396
397 (root / "main.py").write_text("x = 99\n")
398 assert _status_json(root)["clean"] is False
399
400 def test_IV4_dirty_is_not_clean(self, code_repo: pathlib.Path) -> None:
401 """IV4: dirty = not clean, always — both are always present."""
402 root = code_repo
403
404 data_clean = _status_json(root)
405 assert data_clean["dirty"] is not data_clean["clean"]
406
407 (root / "main.py").write_text("x = 99\n")
408 data_dirty = _status_json(root)
409 assert data_dirty["dirty"] is not data_dirty["clean"]
410 assert data_dirty["dirty"] is True
411
412
413 # ---------------------------------------------------------------------------
414 # V untracked_count
415 # ---------------------------------------------------------------------------
416
417
418 class TestUntrackedCountV:
419 def test_V1_untracked_count_always_present_as_int(
420 self, code_repo: pathlib.Path
421 ) -> None:
422 """V1: untracked_count is always present and is an int."""
423 data = _status_json(code_repo)
424 assert "untracked_count" in data, "untracked_count must always be present"
425 assert isinstance(data["untracked_count"], int), (
426 f"untracked_count must be int, got {type(data['untracked_count'])}"
427 )
428
429 def test_V2_untracked_count_equals_len_untracked(
430 self, code_repo: pathlib.Path
431 ) -> None:
432 """V2: untracked_count == len(untracked) — it is a redundant convenience field."""
433 root = code_repo
434 (root / "foo.txt").write_text("foo\n")
435 (root / "bar.txt").write_text("bar\n")
436
437 data = _status_json(root)
438
439 assert data["untracked_count"] == len(data["untracked"]), (
440 f"untracked_count {data['untracked_count']} != len(untracked) {len(data['untracked'])}"
441 )
442
443 def test_V3_untracked_count_zero_for_clean_repo(
444 self, code_repo: pathlib.Path
445 ) -> None:
446 """V3: untracked_count == 0 when the working tree is clean."""
447 data = _status_json(code_repo)
448 assert data["untracked_count"] == 0
449
450 def test_V4_untracked_count_zero_when_only_tracked_changes(
451 self, code_repo: pathlib.Path
452 ) -> None:
453 """V4: untracked_count == 0 when total_changes > 0 but no untracked files."""
454 root = code_repo
455 (root / "main.py").write_text("x = 2\n")
456 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
457
458 data = _status_json(root)
459
460 assert data["total_changes"] > 0
461 assert data["untracked_count"] == 0
462
463 def test_V5_untracked_count_nonzero_total_changes_stays_zero(
464 self, code_repo: pathlib.Path
465 ) -> None:
466 """V5: Only untracked files — untracked_count > 0, total_changes remains 0."""
467 root = code_repo
468 (root / "new_file.txt").write_text("hello\n")
469
470 data = _status_json(root)
471
472 assert data["total_changes"] == 0, (
473 "total_changes must not count untracked files"
474 )
475 assert data["untracked_count"] > 0, (
476 "untracked_count must reflect the untracked file"
477 )
478 assert data["dirty"] is True
479
480 def test_V6_both_tracked_changes_and_untracked_files(
481 self, code_repo: pathlib.Path
482 ) -> None:
483 """V6: When both tracked changes and untracked files exist, both counts are nonzero."""
484 root = code_repo
485 # Tracked modification
486 (root / "main.py").write_text("x = 2\n")
487 runner.invoke(cli, ["code", "add", "main.py"], env=_env(root))
488 # Untracked
489 (root / "scratch.txt").write_text("scratch\n")
490
491 data = _status_json(root)
492
493 assert data["total_changes"] > 0, "total_changes must reflect tracked modification"
494 assert data["untracked_count"] > 0, "untracked_count must reflect untracked file"
495 assert data["dirty"] is True
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago