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