test_status_json_schema.py
python
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