gabriel / muse public
test_cmd_shelf.py python
1,654 lines 66.5 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 142 days ago
1 """Comprehensive tests for ``muse shelf``.
2
3 Covers:
4 - Unit: _load_shelf / _save_shelf atomic write + guards, _resolve_entry,
5 _compute_shelf_id, _generate_name, _apply_shelf_snapshot,
6 _verify_snapshot_objects
7 - Integration: save, list, read, apply, pop, drop, diff — JSON schemas,
8 text output, filters, agent fields
9 - End-to-end: full CLI round-trips via CliRunner
10 - Stress: many entries, concurrent isolated repos, repeated save/load
11 - Data integrity: already-current detection, snapshot completeness,
12 content-address stability
13 - Performance: save + pop under 5 s
14 - Security: symlink guard, size limit, ANSI injection in names / intent,
15 invalid --format exits 1
16
17 Test categories
18 ---------------
19 - unit : pure helper functions, no repo needed
20 - integration : programmatic API + JSON schema validation
21 - e2e : CliRunner full round-trips
22 - stress : volume and concurrency
23 - data-integrity: already-current detection, manifest correctness
24 - performance : timing assertions
25 - security : injection, path-traversal, oversized file guards
26 - docstrings : public API coverage
27 """
28
29 from __future__ import annotations
30 from collections.abc import Mapping
31
32 import argparse
33 import datetime
34 import inspect
35 import json
36 import os
37 import pathlib
38 import threading
39 import time
40 from typing import Any
41
42 import pytest
43 from tests.cli_test_helper import CliRunner
44 from muse.core._types import long_id, fake_id, split_id, blob_id
45 from muse.core.object_store import object_path
46
47 cli = None # argparse migration — CliRunner ignores this arg
48 runner = CliRunner()
49
50
51 # ---------------------------------------------------------------------------
52 # Shared helpers
53 # ---------------------------------------------------------------------------
54
55
56 def _env(root: pathlib.Path) -> Mapping[str, str]:
57 return {"MUSE_REPO_ROOT": str(root)}
58
59
60 def _init_repo(tmp_path: pathlib.Path, branch: str = "main") -> tuple[pathlib.Path, str]:
61 """Create a minimal Muse repo structure on disk."""
62 muse_dir = tmp_path / ".muse"
63 muse_dir.mkdir()
64 repo_id = fake_id("repo")
65 (muse_dir / "repo.json").write_text(json.dumps({
66 "repo_id": repo_id,
67 "domain": "code",
68 "default_branch": branch,
69 "created_at": "2025-01-01T00:00:00+00:00",
70 }), encoding="utf-8")
71 (muse_dir / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8")
72 (muse_dir / "refs" / "heads").mkdir(parents=True)
73 (muse_dir / "snapshots").mkdir()
74 (muse_dir / "commits").mkdir()
75 (muse_dir / "objects").mkdir()
76 return tmp_path, repo_id
77
78
79 def _make_commit(
80 root: pathlib.Path,
81 repo_id: str,
82 message: str = "init",
83 branch: str = "main",
84 manifest: dict[str, str] | None = None,
85 ) -> str:
86 """Write a commit to the repo with the given manifest."""
87 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
88 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
89
90 ref_file = root / ".muse" / "refs" / "heads" / branch
91 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
92 m: dict[str, str] = manifest or {}
93 snap_id = compute_snapshot_id(m)
94 committed_at = datetime.datetime.now(datetime.timezone.utc)
95 commit_id = compute_commit_id(
96 repo_id=repo_id,
97 parent_ids=[parent_id] if parent_id else [],
98 snapshot_id=snap_id, message=message,
99 committed_at_iso=committed_at.isoformat(),
100 )
101 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
102 write_commit(root, CommitRecord(
103 commit_id=commit_id, repo_id=repo_id, created_on_branch=branch,
104 snapshot_id=snap_id, message=message, committed_at=committed_at,
105 parent_commit_id=parent_id,
106 ))
107 ref_file.parent.mkdir(parents=True, exist_ok=True)
108 ref_file.write_text(commit_id, encoding="utf-8")
109 return commit_id
110
111
112 def _write_object(root: pathlib.Path, content: bytes) -> str:
113 """Write raw bytes to the object store, returning the sha256:-prefixed ID."""
114 obj_id = blob_id(content)
115 p = object_path(root, obj_id)
116 p.parent.mkdir(parents=True, exist_ok=True)
117 p.write_bytes(content)
118 return obj_id
119
120
121 def _make_shelf_entry(
122 name: str = "dev/000",
123 branch: str = "main",
124 snapshot: dict[str, str] | None = None,
125 deleted: list[str] | None = None,
126 intent_type: str = "checkpoint",
127 intent: str | None = None,
128 resumable: bool = False,
129 tags: list[str] | None = None,
130 created_by: str = "human",
131 ) -> Mapping[str, object]:
132 """Build a raw shelf-entry dict (no id field) suitable for _compute_shelf_id."""
133 return {
134 "name": name,
135 "snapshot": snapshot or {},
136 "deleted": deleted or [],
137 "snapshot_id": long_id("a" * 64),
138 "parent_commit": long_id("b" * 64),
139 "branch": branch,
140 "created_at": "2025-01-01T00:00:00+00:00",
141 "created_by": created_by,
142 "intent_type": intent_type,
143 "intent": intent,
144 "resumable": resumable,
145 "tags": tags or [],
146 "expires_at": None,
147 "domain_state": {},
148 }
149
150
151 # ---------------------------------------------------------------------------
152 # Fixtures
153 # ---------------------------------------------------------------------------
154
155
156 @pytest.fixture()
157 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
158 """Fresh repo with one committed file (a.py) and one dirty file (b.py)."""
159 monkeypatch.chdir(tmp_path)
160 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
161 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
162 assert r.exit_code == 0, r.output
163 (tmp_path / "a.py").write_text("x = 1\n")
164 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
165 assert r.exit_code == 0, r.output
166 (tmp_path / "b.py").write_text("y = 2\n")
167 return tmp_path
168
169
170 @pytest.fixture()
171 def shelved_repo(repo: pathlib.Path) -> pathlib.Path:
172 """repo fixture with one shelf entry already saved."""
173 r = runner.invoke(
174 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
175 )
176 assert r.exit_code == 0, r.output
177 return repo
178
179
180 # ---------------------------------------------------------------------------
181 # Unit — _compute_shelf_id
182 # ---------------------------------------------------------------------------
183
184
185 class TestComputeShelfId:
186 """Unit tests for content-addressed ID generation."""
187
188 def test_id_starts_with_sha256(self) -> None:
189 from muse.cli.commands.shelf import _compute_shelf_id
190 entry = _make_shelf_entry()
191 shelf_id = _compute_shelf_id(entry)
192 assert shelf_id.startswith("sha256:")
193
194 def test_id_is_64_hex_after_prefix(self) -> None:
195 from muse.cli.commands.shelf import _compute_shelf_id
196 entry = _make_shelf_entry()
197 shelf_id = _compute_shelf_id(entry)
198 _, hex_part = split_id(shelf_id)
199 assert len(hex_part) == 64
200 assert all(c in "0123456789abcdef" for c in hex_part)
201
202 def test_same_content_same_id(self) -> None:
203 from muse.cli.commands.shelf import _compute_shelf_id
204 e1 = _make_shelf_entry(name="mywork", branch="dev")
205 e2 = _make_shelf_entry(name="mywork", branch="dev")
206 assert _compute_shelf_id(e1) == _compute_shelf_id(e2)
207
208 def test_different_content_different_id(self) -> None:
209 from muse.cli.commands.shelf import _compute_shelf_id
210 e1 = _make_shelf_entry(name="mywork")
211 e2 = _make_shelf_entry(name="otherwork")
212 assert _compute_shelf_id(e1) != _compute_shelf_id(e2)
213
214 def test_snapshot_diff_changes_id(self) -> None:
215 from muse.cli.commands.shelf import _compute_shelf_id
216 e1 = _make_shelf_entry(snapshot={"a.py": long_id("a" * 64)})
217 e2 = _make_shelf_entry(snapshot={"a.py": long_id("b" * 64)})
218 assert _compute_shelf_id(e1) != _compute_shelf_id(e2)
219
220 def test_id_stable_across_calls(self) -> None:
221 from muse.cli.commands.shelf import _compute_shelf_id
222 entry = _make_shelf_entry(name="stable", intent="doing work")
223 ids = [_compute_shelf_id(entry) for _ in range(10)]
224 assert len(set(ids)) == 1
225
226
227 # ---------------------------------------------------------------------------
228 # Unit — _generate_name
229 # ---------------------------------------------------------------------------
230
231
232 class TestGenerateName:
233 def test_first_entry_is_000(self) -> None:
234 from muse.cli.commands.shelf import _generate_name
235 assert _generate_name("dev", set()) == "dev/000"
236
237 def test_increments_when_conflict(self) -> None:
238 from muse.cli.commands.shelf import _generate_name
239 existing = {"dev/000", "dev/001"}
240 assert _generate_name("dev", existing) == "dev/002"
241
242 def test_branch_with_special_chars_sanitized(self) -> None:
243 from muse.cli.commands.shelf import _generate_name
244 name = _generate_name("feat/[email protected]!", set())
245 assert "/" in name # one slash is OK (branch/NNN)
246 assert "@" not in name
247 assert "!" not in name
248
249 def test_empty_branch_fallback(self) -> None:
250 from muse.cli.commands.shelf import _generate_name
251 name = _generate_name("", set())
252 assert name.endswith("/000")
253
254 def test_zero_padded_to_three_digits(self) -> None:
255 from muse.cli.commands.shelf import _generate_name
256 name = _generate_name("main", set())
257 assert name.endswith("/000")
258
259 def test_large_n_zero_padded(self) -> None:
260 from muse.cli.commands.shelf import _generate_name
261 existing = {f"main/{i:03d}" for i in range(10)}
262 name = _generate_name("main", existing)
263 assert name == "main/010"
264
265
266 # ---------------------------------------------------------------------------
267 # Unit — _load_shelf / _save_shelf
268 # ---------------------------------------------------------------------------
269
270
271 class TestLoadSaveShelf:
272 def test_load_empty_when_no_file(self, tmp_path: pathlib.Path) -> None:
273 root, _ = _init_repo(tmp_path)
274 from muse.cli.commands.shelf import _load_shelf
275 assert _load_shelf(root) == []
276
277 def test_save_creates_shelf_json(self, tmp_path: pathlib.Path) -> None:
278 root, _ = _init_repo(tmp_path)
279 from muse.cli.commands.shelf import _load_shelf, _save_shelf, ShelfEntry
280 from muse.cli.commands.shelf import _compute_shelf_id
281 raw = _make_shelf_entry(name="test/000")
282 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
283 _save_shelf(root, [entry])
284 assert (root / ".muse" / "shelf.json").exists()
285 loaded = _load_shelf(root)
286 assert len(loaded) == 1
287 assert loaded[0]["name"] == "test/000"
288
289 def test_roundtrip_preserves_all_fields(self, tmp_path: pathlib.Path) -> None:
290 root, _ = _init_repo(tmp_path)
291 from muse.cli.commands.shelf import _load_shelf, _save_shelf, ShelfEntry
292 from muse.cli.commands.shelf import _compute_shelf_id
293 raw = _make_shelf_entry(
294 name="wip/000",
295 branch="dev",
296 snapshot={"src/foo.py": long_id("c" * 64)},
297 deleted=["old.py"],
298 intent_type="handoff",
299 intent="50% done",
300 resumable=True,
301 tags=["auth", "refactor"],
302 created_by="agent-42",
303 )
304 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
305 _save_shelf(root, [entry])
306 loaded = _load_shelf(root)
307 e = loaded[0]
308 assert e["name"] == "wip/000"
309 assert e["branch"] == "dev"
310 assert e["snapshot"] == {"src/foo.py": long_id("c" * 64)}
311 assert e["deleted"] == ["old.py"]
312 assert e["intent_type"] == "handoff"
313 assert e["intent"] == "50% done"
314 assert e["resumable"] is True
315 assert e["tags"] == ["auth", "refactor"]
316 assert e["created_by"] == "agent-42"
317
318 def test_save_is_atomic_no_temp_files(self, tmp_path: pathlib.Path) -> None:
319 root, _ = _init_repo(tmp_path)
320 from muse.cli.commands.shelf import _save_shelf
321 _save_shelf(root, [])
322 tmp_files = list((root / ".muse").glob(".shelf_tmp_*"))
323 assert tmp_files == []
324
325 def test_load_ignores_oversized_file(self, tmp_path: pathlib.Path) -> None:
326 root, _ = _init_repo(tmp_path)
327 shelf_path = root / ".muse" / "shelf.json"
328 shelf_path.write_bytes(b"x" * (65 * 1024 * 1024)) # 65 MiB > 64 MiB limit
329 from muse.cli.commands.shelf import _load_shelf
330 assert _load_shelf(root) == []
331
332 def test_load_ignores_malformed_json(self, tmp_path: pathlib.Path) -> None:
333 root, _ = _init_repo(tmp_path)
334 (root / ".muse" / "shelf.json").write_text("not-json-at-all", encoding="utf-8")
335 from muse.cli.commands.shelf import _load_shelf
336 assert _load_shelf(root) == []
337
338 def test_load_ignores_non_list_json(self, tmp_path: pathlib.Path) -> None:
339 root, _ = _init_repo(tmp_path)
340 (root / ".muse" / "shelf.json").write_text(json.dumps({"key": "val"}), encoding="utf-8")
341 from muse.cli.commands.shelf import _load_shelf
342 assert _load_shelf(root) == []
343
344 def test_load_skips_entries_without_snapshot(self, tmp_path: pathlib.Path) -> None:
345 root, _ = _init_repo(tmp_path)
346 (root / ".muse" / "shelf.json").write_text(
347 json.dumps([{"name": "bad", "deleted": []}]), # no snapshot key
348 encoding="utf-8",
349 )
350 from muse.cli.commands.shelf import _load_shelf
351 assert _load_shelf(root) == []
352
353 def test_load_skips_non_dict_entries(self, tmp_path: pathlib.Path) -> None:
354 root, _ = _init_repo(tmp_path)
355 (root / ".muse" / "shelf.json").write_text(
356 json.dumps(["string", 42, None]),
357 encoding="utf-8",
358 )
359 from muse.cli.commands.shelf import _load_shelf
360 assert _load_shelf(root) == []
361
362 def test_fsync_called_in_save(self) -> None:
363 import muse.cli.commands.shelf as m
364 assert "fsync" in inspect.getsource(m._save_shelf)
365
366 def test_assert_not_symlink_in_load(self) -> None:
367 import muse.cli.commands.shelf as m
368 assert "assert_not_symlink" in inspect.getsource(m._load_shelf)
369
370 def test_multiple_entries_ordered(self, tmp_path: pathlib.Path) -> None:
371 root, _ = _init_repo(tmp_path)
372 from muse.cli.commands.shelf import _load_shelf, _save_shelf, ShelfEntry
373 from muse.cli.commands.shelf import _compute_shelf_id
374 entries = []
375 for i in range(3):
376 raw = _make_shelf_entry(name=f"dev/{i:03d}")
377 raw["created_at"] = f"2025-01-0{i+1}T00:00:00+00:00"
378 entries.append(ShelfEntry(id=_compute_shelf_id(raw), **raw)) # type: ignore[misc]
379 _save_shelf(root, entries)
380 loaded = _load_shelf(root)
381 assert [e["name"] for e in loaded] == ["dev/000", "dev/001", "dev/002"]
382
383
384 # ---------------------------------------------------------------------------
385 # Unit — _resolve_entry
386 # ---------------------------------------------------------------------------
387
388
389 class TestResolveEntry:
390 def _entries(self, names: list[str]):
391 from muse.cli.commands.shelf import ShelfEntry
392 from muse.cli.commands.shelf import _compute_shelf_id
393 result = []
394 for name in names:
395 raw = _make_shelf_entry(name=name)
396 result.append(ShelfEntry(id=_compute_shelf_id(raw), **raw)) # type: ignore[misc]
397 return result
398
399 def test_none_returns_default_0(self) -> None:
400 from muse.cli.commands.shelf import _resolve_entry
401 entries = self._entries(["alpha", "beta", "gamma"])
402 idx, e = _resolve_entry(entries, None)
403 assert idx == 0
404 assert e["name"] == "alpha"
405
406 def test_integer_string_resolves(self) -> None:
407 from muse.cli.commands.shelf import _resolve_entry
408 entries = self._entries(["alpha", "beta", "gamma"])
409 idx, e = _resolve_entry(entries, "2")
410 assert idx == 2
411 assert e["name"] == "gamma"
412
413 def test_name_lookup_exact(self) -> None:
414 from muse.cli.commands.shelf import _resolve_entry
415 entries = self._entries(["alpha", "beta", "gamma"])
416 idx, e = _resolve_entry(entries, "beta")
417 assert idx == 1
418 assert e["name"] == "beta"
419
420 def test_empty_list_raises(self) -> None:
421 from muse.cli.commands.shelf import _resolve_entry
422 with pytest.raises(ValueError, match="No shelf entries"):
423 _resolve_entry([], None)
424
425 def test_out_of_range_raises(self) -> None:
426 from muse.cli.commands.shelf import _resolve_entry
427 entries = self._entries(["alpha"])
428 with pytest.raises(ValueError, match="out of range"):
429 _resolve_entry(entries, "5")
430
431 def test_negative_index_raises(self) -> None:
432 from muse.cli.commands.shelf import _resolve_entry
433 entries = self._entries(["alpha", "beta"])
434 with pytest.raises(ValueError, match="out of range"):
435 _resolve_entry(entries, "-1")
436
437 def test_unknown_name_raises(self) -> None:
438 from muse.cli.commands.shelf import _resolve_entry
439 entries = self._entries(["alpha"])
440 with pytest.raises(ValueError, match="No shelf entry"):
441 _resolve_entry(entries, "nonexistent")
442
443
444 # ---------------------------------------------------------------------------
445 # Unit — _apply_shelf_snapshot / _verify_snapshot_objects
446 # ---------------------------------------------------------------------------
447
448
449 class TestApplyShelfSnapshot:
450 def test_restored_count_correct(self, tmp_path: pathlib.Path) -> None:
451 root, repo_id = _init_repo(tmp_path)
452 obj_id = _write_object(root, b"hello world")
453 from muse.cli.commands.shelf import ShelfEntry, _compute_shelf_id, _apply_shelf_snapshot
454 raw = _make_shelf_entry(snapshot={"src/foo.py": obj_id})
455 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
456 counts = _apply_shelf_snapshot(root, entry, head_manifest={})
457 assert counts["restored"] == 1
458 assert counts["already_current"] == 0
459 assert (root / "src" / "foo.py").read_bytes() == b"hello world"
460
461 def test_already_current_not_rewritten(self, tmp_path: pathlib.Path) -> None:
462 root, _ = _init_repo(tmp_path)
463 obj_id = _write_object(root, b"same content")
464 (tmp_path / "file.py").write_bytes(b"same content")
465 from muse.cli.commands.shelf import ShelfEntry, _compute_shelf_id, _apply_shelf_snapshot
466 raw = _make_shelf_entry(snapshot={"file.py": obj_id})
467 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
468 # HEAD manifest already has the same object for this path
469 counts = _apply_shelf_snapshot(root, entry, head_manifest={"file.py": obj_id})
470 assert counts["restored"] == 0
471 assert counts["already_current"] == 1
472
473 def test_deleted_paths_removed(self, tmp_path: pathlib.Path) -> None:
474 root, _ = _init_repo(tmp_path)
475 (tmp_path / "gone.py").write_text("old\n")
476 from muse.cli.commands.shelf import ShelfEntry, _compute_shelf_id, _apply_shelf_snapshot
477 raw = _make_shelf_entry(snapshot={}, deleted=["gone.py"])
478 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
479 counts = _apply_shelf_snapshot(root, entry, head_manifest={})
480 assert counts["deleted"] == 1
481 assert not (tmp_path / "gone.py").exists()
482
483 def test_deleted_already_gone_is_idempotent(self, tmp_path: pathlib.Path) -> None:
484 root, _ = _init_repo(tmp_path)
485 from muse.cli.commands.shelf import ShelfEntry, _compute_shelf_id, _apply_shelf_snapshot
486 raw = _make_shelf_entry(snapshot={}, deleted=["nonexistent.py"])
487 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
488 counts = _apply_shelf_snapshot(root, entry, head_manifest={})
489 assert counts["deleted"] == 0
490
491 def test_mixed_restored_and_already_current(self, tmp_path: pathlib.Path) -> None:
492 root, _ = _init_repo(tmp_path)
493 obj_same = _write_object(root, b"same")
494 obj_diff = _write_object(root, b"different")
495 from muse.cli.commands.shelf import ShelfEntry, _compute_shelf_id, _apply_shelf_snapshot
496 raw = _make_shelf_entry(snapshot={"a.py": obj_same, "b.py": obj_diff})
497 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
498 counts = _apply_shelf_snapshot(root, entry, head_manifest={"a.py": obj_same})
499 assert counts["restored"] == 1
500 assert counts["already_current"] == 1
501
502
503 class TestVerifySnapshotObjects:
504 def test_all_present_returns_empty(self, tmp_path: pathlib.Path) -> None:
505 root, _ = _init_repo(tmp_path)
506 obj_id = _write_object(root, b"data")
507 from muse.cli.commands.shelf import _verify_snapshot_objects
508 missing = _verify_snapshot_objects(root, {"file.py": obj_id})
509 assert missing == []
510
511 def test_missing_object_returned(self, tmp_path: pathlib.Path) -> None:
512 root, _ = _init_repo(tmp_path)
513 from muse.cli.commands.shelf import _verify_snapshot_objects
514 missing_obj_id = long_id("f" * 64)
515 missing = _verify_snapshot_objects(root, {"file.py": missing_obj_id})
516 assert "file.py" in missing
517
518 def test_empty_snapshot_returns_empty(self, tmp_path: pathlib.Path) -> None:
519 root, _ = _init_repo(tmp_path)
520 from muse.cli.commands.shelf import _verify_snapshot_objects
521 assert _verify_snapshot_objects(root, {}) == []
522
523
524 # ---------------------------------------------------------------------------
525 # Unit — register / parser flags
526 # ---------------------------------------------------------------------------
527
528
529 class TestRegisterFlags:
530 def _parse(self, *args: str) -> argparse.Namespace:
531 import muse.cli.commands.shelf as m
532 p = argparse.ArgumentParser()
533 sub = p.add_subparsers()
534 m.register(sub)
535 return p.parse_args(["shelf", *args])
536
537 def test_save_intent_short(self) -> None:
538 ns = self._parse("save", "-m", "WIP auth")
539 assert ns.intent == "WIP auth"
540
541 def test_save_intent_long(self) -> None:
542 ns = self._parse("save", "--intent", "WIP auth")
543 assert ns.intent == "WIP auth"
544
545 def test_save_intent_default_none(self) -> None:
546 ns = self._parse("save")
547 assert ns.intent is None
548
549 def test_save_intent_type_default(self) -> None:
550 ns = self._parse("save")
551 assert ns.intent_type == "checkpoint"
552
553 def test_save_intent_type_handoff(self) -> None:
554 ns = self._parse("save", "--intent-type", "handoff")
555 assert ns.intent_type == "handoff"
556
557 def test_save_resumable_flag(self) -> None:
558 ns = self._parse("save", "--resumable")
559 assert ns.resumable is True
560
561 def test_save_resumable_default_false(self) -> None:
562 ns = self._parse("save")
563 assert ns.resumable is False
564
565 def test_save_tag_repeatable(self) -> None:
566 ns = self._parse("save", "--tag", "auth", "--tag", "refactor")
567 assert "auth" in ns.tags
568 assert "refactor" in ns.tags
569
570 def test_save_json_shorthand(self) -> None:
571 ns = self._parse("save", "--json")
572 assert ns.json_out is True
573
574 def test_pop_entry_arg(self) -> None:
575 ns = self._parse("pop", "my-work")
576 assert ns.entry == "my-work"
577
578 def test_pop_entry_default_none(self) -> None:
579 ns = self._parse("pop")
580 assert ns.entry is None
581
582 def test_drop_entry_arg(self) -> None:
583 ns = self._parse("drop", "2")
584 assert ns.entry == "2"
585
586 def test_apply_entry_arg(self) -> None:
587 ns = self._parse("apply", "main/000")
588 assert ns.entry == "main/000"
589
590 def test_list_branch_filter(self) -> None:
591 ns = self._parse("list", "--branch", "dev")
592 assert ns.branch == "dev"
593
594 def test_list_resumable_filter(self) -> None:
595 ns = self._parse("list", "--resumable")
596 assert ns.resumable is True
597
598 def test_list_by_filter(self) -> None:
599 ns = self._parse("list", "--by", "agent-42")
600 assert ns.created_by == "agent-42"
601
602 def test_diff_entry_arg(self) -> None:
603 ns = self._parse("diff", "0")
604 assert ns.entry == "0"
605
606
607 # ---------------------------------------------------------------------------
608 # Integration — save JSON schema
609 # ---------------------------------------------------------------------------
610
611
612 class TestSaveJsonSchema:
613 _REQUIRED = {
614 "status", "id", "name", "snapshot_id", "parent_commit", "branch",
615 "created_at", "created_by", "intent_type", "intent", "resumable",
616 "tags", "files_count", "shelf_size",
617 }
618
619 def test_schema_complete(self, repo: pathlib.Path) -> None:
620 r = runner.invoke(
621 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
622 )
623 assert r.exit_code == 0, r.output
624 d = json.loads(r.output)
625 assert self._REQUIRED <= d.keys()
626
627 def test_status_shelved(self, repo: pathlib.Path) -> None:
628 r = runner.invoke(
629 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
630 )
631 assert json.loads(r.output)["status"] == "shelved"
632
633 def test_id_is_sha256(self, repo: pathlib.Path) -> None:
634 r = runner.invoke(
635 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
636 )
637 d = json.loads(r.output)
638 assert d["id"].startswith("sha256:")
639
640 def test_files_count_positive(self, repo: pathlib.Path) -> None:
641 r = runner.invoke(
642 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
643 )
644 assert json.loads(r.output)["files_count"] > 0
645
646 def test_intent_default_null(self, repo: pathlib.Path) -> None:
647 r = runner.invoke(
648 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
649 )
650 assert json.loads(r.output)["intent"] is None
651
652 def test_intent_with_flag(self, repo: pathlib.Path) -> None:
653 r = runner.invoke(
654 cli, ["shelf", "save", "-m", "updating tests", "--json"],
655 env=_env(repo), catch_exceptions=False,
656 )
657 assert json.loads(r.output)["intent"] == "updating tests"
658
659 def test_intent_type_default_checkpoint(self, repo: pathlib.Path) -> None:
660 r = runner.invoke(
661 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
662 )
663 assert json.loads(r.output)["intent_type"] == "checkpoint"
664
665 def test_intent_type_custom(self, repo: pathlib.Path) -> None:
666 r = runner.invoke(
667 cli, ["shelf", "save", "--intent-type", "handoff", "--json"],
668 env=_env(repo), catch_exceptions=False,
669 )
670 assert json.loads(r.output)["intent_type"] == "handoff"
671
672 def test_resumable_flag_stored(self, repo: pathlib.Path) -> None:
673 r = runner.invoke(
674 cli, ["shelf", "save", "--resumable", "--json"],
675 env=_env(repo), catch_exceptions=False,
676 )
677 assert json.loads(r.output)["resumable"] is True
678
679 def test_tags_stored(self, repo: pathlib.Path) -> None:
680 r = runner.invoke(
681 cli, ["shelf", "save", "--tag", "auth", "--tag", "wip", "--json"],
682 env=_env(repo), catch_exceptions=False,
683 )
684 d = json.loads(r.output)
685 assert "auth" in d["tags"]
686 assert "wip" in d["tags"]
687
688 def test_nothing_to_shelf_schema_complete(self, repo: pathlib.Path) -> None:
689 """nothing_to_shelf must emit same keys with null id/name."""
690 (repo / "b.py").unlink(missing_ok=True) # make tree match HEAD
691 r = runner.invoke(
692 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
693 )
694 d = json.loads(r.output)
695 assert self._REQUIRED <= d.keys()
696 assert d["status"] == "nothing_to_shelf"
697 assert d["id"] is None
698 assert d["name"] is None
699
700 def test_named_save(self, repo: pathlib.Path) -> None:
701 r = runner.invoke(
702 cli, ["shelf", "save", "my-feature", "--json"],
703 env=_env(repo), catch_exceptions=False,
704 )
705 assert json.loads(r.output)["name"] == "my-feature"
706
707 def test_duplicate_name_exits_1(self, repo: pathlib.Path) -> None:
708 runner.invoke(
709 cli, ["shelf", "save", "dup-test"], env=_env(repo), catch_exceptions=False
710 )
711 # Write another dirty file so there's something to shelf
712 (repo / "c.py").write_text("z = 3\n")
713 r = runner.invoke(cli, ["shelf", "save", "dup-test"], env=_env(repo))
714 assert r.exit_code == 1
715
716 def test_shelf_size_increments(self, repo: pathlib.Path) -> None:
717 r1 = runner.invoke(
718 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
719 )
720 d1 = json.loads(r1.output)
721 (repo / "c.py").write_text("z = 3\n")
722 r2 = runner.invoke(
723 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
724 )
725 d2 = json.loads(r2.output)
726 assert d2["shelf_size"] == d1["shelf_size"] + 1
727
728
729 # ---------------------------------------------------------------------------
730 # Integration — list JSON schema
731 # ---------------------------------------------------------------------------
732
733
734 class TestListJsonSchema:
735 _ENTRY_REQUIRED = {
736 "index", "id", "name", "snapshot_id", "branch", "created_at",
737 "created_by", "intent_type", "intent", "resumable", "tags", "files_count",
738 }
739
740 def test_schema_complete(self, shelved_repo: pathlib.Path) -> None:
741 r = runner.invoke(
742 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
743 )
744 assert r.exit_code == 0, r.output
745 entries = json.loads(r.output)["entries"]
746 assert len(entries) >= 1
747 assert self._ENTRY_REQUIRED <= entries[0].keys()
748
749 def test_empty_returns_empty_array(self, repo: pathlib.Path) -> None:
750 r = runner.invoke(
751 cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False
752 )
753 assert r.exit_code == 0
754 assert json.loads(r.output)["entries"] == []
755
756 def test_files_count_positive(self, shelved_repo: pathlib.Path) -> None:
757 r = runner.invoke(
758 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
759 )
760 entries = json.loads(r.output)["entries"]
761 assert entries[0]["files_count"] > 0
762
763 def test_filter_branch(self, repo: pathlib.Path) -> None:
764 runner.invoke(
765 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
766 )
767 r = runner.invoke(
768 cli, ["shelf", "list", "--branch", "main", "--json"], env=_env(repo)
769 )
770 entries = json.loads(r.output)["entries"]
771 assert all(e["branch"] == "main" for e in entries)
772
773 def test_filter_branch_no_match_empty(self, shelved_repo: pathlib.Path) -> None:
774 r = runner.invoke(
775 cli, ["shelf", "list", "--branch", "nonexistent-branch", "--json"],
776 env=_env(shelved_repo),
777 )
778 assert json.loads(r.output)["entries"] == []
779
780 def test_filter_resumable(self, repo: pathlib.Path) -> None:
781 runner.invoke(
782 cli, ["shelf", "save", "--resumable", "--json"],
783 env=_env(repo), catch_exceptions=False,
784 )
785 (repo / "c.py").write_text("z = 3\n")
786 runner.invoke(
787 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
788 )
789 r = runner.invoke(
790 cli, ["shelf", "list", "--resumable", "--json"], env=_env(repo)
791 )
792 entries = json.loads(r.output)["entries"]
793 assert all(e["resumable"] for e in entries)
794
795 def test_filter_by_creator(self, repo: pathlib.Path) -> None:
796 runner.invoke(
797 cli, ["shelf", "save", "--by", "agent-99", "--json"],
798 env=_env(repo), catch_exceptions=False,
799 )
800 r = runner.invoke(
801 cli, ["shelf", "list", "--by", "agent-99", "--json"], env=_env(repo)
802 )
803 entries = json.loads(r.output)["entries"]
804 assert all(e["created_by"] == "agent-99" for e in entries)
805
806
807 # ---------------------------------------------------------------------------
808 # Integration — read JSON schema
809 # ---------------------------------------------------------------------------
810
811
812 class TestReadJsonSchema:
813 _REQUIRED = {
814 "index", "id", "name", "snapshot_id", "parent_commit", "branch",
815 "created_at", "created_by", "intent_type", "intent", "resumable",
816 "tags", "files_count", "files", "deleted",
817 }
818
819 def test_schema_complete(self, shelved_repo: pathlib.Path) -> None:
820 r = runner.invoke(
821 cli, ["shelf", "read", "--json"], env=_env(shelved_repo), catch_exceptions=False
822 )
823 assert r.exit_code == 0, r.output
824 d = json.loads(r.output)
825 assert self._REQUIRED <= d.keys()
826
827 def test_files_is_list_of_strings(self, shelved_repo: pathlib.Path) -> None:
828 r = runner.invoke(
829 cli, ["shelf", "read", "--json"], env=_env(shelved_repo), catch_exceptions=False
830 )
831 d = json.loads(r.output)
832 assert isinstance(d["files"], list)
833 assert all(isinstance(f, str) for f in d["files"])
834
835 def test_read_by_name(self, shelved_repo: pathlib.Path) -> None:
836 # get the name that was auto-generated
837 listing = json.loads(
838 runner.invoke(
839 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
840 ).output
841 )["entries"]
842 name = listing[0]["name"]
843 r = runner.invoke(
844 cli, ["shelf", "read", name, "--json"], env=_env(shelved_repo), catch_exceptions=False
845 )
846 assert r.exit_code == 0
847 assert json.loads(r.output)["name"] == name
848
849 def test_read_by_index(self, shelved_repo: pathlib.Path) -> None:
850 r = runner.invoke(
851 cli, ["shelf", "read", "0", "--json"], env=_env(shelved_repo), catch_exceptions=False
852 )
853 assert r.exit_code == 0
854 assert json.loads(r.output)["index"] == 0
855
856 def test_read_empty_exits_1(self, repo: pathlib.Path) -> None:
857 r = runner.invoke(cli, ["shelf", "read"], env=_env(repo))
858 assert r.exit_code == 1
859
860 def test_read_unknown_name_exits_1(self, shelved_repo: pathlib.Path) -> None:
861 r = runner.invoke(cli, ["shelf", "read", "no-such-name"], env=_env(shelved_repo))
862 assert r.exit_code == 1
863
864
865 # ---------------------------------------------------------------------------
866 # Integration — apply JSON schema
867 # ---------------------------------------------------------------------------
868
869
870 class TestApplyJsonSchema:
871 _REQUIRED = {"status", "name", "restored", "already_current", "deleted", "shelf_size"}
872
873 def test_schema_complete(self, shelved_repo: pathlib.Path) -> None:
874 r = runner.invoke(
875 cli, ["shelf", "apply", "--json"], env=_env(shelved_repo), catch_exceptions=False
876 )
877 assert r.exit_code == 0, r.output
878 d = json.loads(r.output)
879 assert self._REQUIRED <= d.keys()
880
881 def test_status_applied(self, shelved_repo: pathlib.Path) -> None:
882 r = runner.invoke(
883 cli, ["shelf", "apply", "--json"], env=_env(shelved_repo), catch_exceptions=False
884 )
885 assert json.loads(r.output)["status"] == "applied"
886
887 def test_apply_preserves_shelf_entry(self, shelved_repo: pathlib.Path) -> None:
888 before = json.loads(
889 runner.invoke(
890 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
891 ).output
892 )["entries"]
893 runner.invoke(
894 cli, ["shelf", "apply", "--json"], env=_env(shelved_repo), catch_exceptions=False
895 )
896 after = json.loads(
897 runner.invoke(
898 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
899 ).output
900 )["entries"]
901 assert len(before) == len(after), "apply must not remove the shelf entry"
902
903 def test_apply_empty_exits_1(self, repo: pathlib.Path) -> None:
904 r = runner.invoke(cli, ["shelf", "apply"], env=_env(repo))
905 assert r.exit_code == 1
906
907 def test_apply_restores_file(self, shelved_repo: pathlib.Path) -> None:
908 # After shelf save, b.py is gone from workdir (HEAD restored)
909 b_py = shelved_repo / "b.py"
910 assert not b_py.exists()
911 runner.invoke(
912 cli, ["shelf", "apply"], env=_env(shelved_repo), catch_exceptions=False
913 )
914 assert b_py.exists()
915
916
917 # ---------------------------------------------------------------------------
918 # Integration — pop JSON schema
919 # ---------------------------------------------------------------------------
920
921
922 class TestPopJsonSchema:
923 _REQUIRED = {"status", "name", "restored", "already_current", "deleted", "shelf_size_after"}
924
925 def test_schema_complete(self, shelved_repo: pathlib.Path) -> None:
926 r = runner.invoke(
927 cli, ["shelf", "pop", "--json"], env=_env(shelved_repo), catch_exceptions=False
928 )
929 assert r.exit_code == 0, r.output
930 d = json.loads(r.output)
931 assert self._REQUIRED <= d.keys()
932
933 def test_status_popped(self, shelved_repo: pathlib.Path) -> None:
934 r = runner.invoke(
935 cli, ["shelf", "pop", "--json"], env=_env(shelved_repo), catch_exceptions=False
936 )
937 assert json.loads(r.output)["status"] == "popped"
938
939 def test_shelf_size_after_decremented(self, shelved_repo: pathlib.Path) -> None:
940 r = runner.invoke(
941 cli, ["shelf", "pop", "--json"], env=_env(shelved_repo), catch_exceptions=False
942 )
943 assert json.loads(r.output)["shelf_size_after"] == 0
944
945 def test_pop_empty_exits_1(self, repo: pathlib.Path) -> None:
946 r = runner.invoke(cli, ["shelf", "pop"], env=_env(repo))
947 assert r.exit_code == 1
948
949 def test_pop_removes_entry(self, shelved_repo: pathlib.Path) -> None:
950 runner.invoke(
951 cli, ["shelf", "pop", "--json"], env=_env(shelved_repo), catch_exceptions=False
952 )
953 after = json.loads(
954 runner.invoke(
955 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
956 ).output
957 )["entries"]
958 assert after == []
959
960
961 # ---------------------------------------------------------------------------
962 # Integration — drop JSON schema
963 # ---------------------------------------------------------------------------
964
965
966 class TestDropJsonSchema:
967 _REQUIRED = {"status", "name", "id", "shelf_size"}
968
969 def test_schema_complete(self, shelved_repo: pathlib.Path) -> None:
970 r = runner.invoke(
971 cli, ["shelf", "drop", "--json"], env=_env(shelved_repo), catch_exceptions=False
972 )
973 assert r.exit_code == 0, r.output
974 d = json.loads(r.output)
975 assert self._REQUIRED <= d.keys()
976
977 def test_status_dropped(self, shelved_repo: pathlib.Path) -> None:
978 r = runner.invoke(
979 cli, ["shelf", "drop", "--json"], env=_env(shelved_repo), catch_exceptions=False
980 )
981 assert json.loads(r.output)["status"] == "dropped"
982
983 def test_id_is_sha256(self, shelved_repo: pathlib.Path) -> None:
984 r = runner.invoke(
985 cli, ["shelf", "drop", "--json"], env=_env(shelved_repo), catch_exceptions=False
986 )
987 d = json.loads(r.output)
988 assert d["id"].startswith("sha256:")
989
990 def test_drop_empty_exits_1(self, repo: pathlib.Path) -> None:
991 r = runner.invoke(cli, ["shelf", "drop"], env=_env(repo))
992 assert r.exit_code == 1
993
994 def test_drop_does_not_restore_file(self, shelved_repo: pathlib.Path) -> None:
995 b_py = shelved_repo / "b.py"
996 assert not b_py.exists()
997 runner.invoke(
998 cli, ["shelf", "drop"], env=_env(shelved_repo), catch_exceptions=False
999 )
1000 assert not b_py.exists()
1001
1002 def test_drop_removes_entry_from_list(self, shelved_repo: pathlib.Path) -> None:
1003 runner.invoke(
1004 cli, ["shelf", "drop"], env=_env(shelved_repo), catch_exceptions=False
1005 )
1006 after = json.loads(
1007 runner.invoke(
1008 cli, ["shelf", "list", "--json"], env=_env(shelved_repo), catch_exceptions=False
1009 ).output
1010 )["entries"]
1011 assert after == []
1012
1013
1014 # ---------------------------------------------------------------------------
1015 # Integration — diff JSON schema
1016 # ---------------------------------------------------------------------------
1017
1018
1019 class TestDiffJsonSchema:
1020 _REQUIRED = {"name", "branch", "would_restore", "already_current", "would_delete"}
1021
1022 def test_schema_complete(self, shelved_repo: pathlib.Path) -> None:
1023 r = runner.invoke(
1024 cli, ["shelf", "diff", "--json"], env=_env(shelved_repo), catch_exceptions=False
1025 )
1026 assert r.exit_code == 0, r.output
1027 d = json.loads(r.output)
1028 assert self._REQUIRED <= d.keys()
1029
1030 def test_would_restore_has_changed_files(self, shelved_repo: pathlib.Path) -> None:
1031 r = runner.invoke(
1032 cli, ["shelf", "diff", "--json"], env=_env(shelved_repo), catch_exceptions=False
1033 )
1034 d = json.loads(r.output)
1035 assert len(d["would_restore"]) > 0
1036
1037 def test_diff_does_not_modify_workdir(self, shelved_repo: pathlib.Path) -> None:
1038 b_py = shelved_repo / "b.py"
1039 before = b_py.exists()
1040 runner.invoke(
1041 cli, ["shelf", "diff"], env=_env(shelved_repo), catch_exceptions=False
1042 )
1043 assert b_py.exists() == before
1044
1045 def test_diff_empty_exits_1(self, repo: pathlib.Path) -> None:
1046 r = runner.invoke(cli, ["shelf", "diff"], env=_env(repo))
1047 assert r.exit_code == 1
1048
1049 def test_diff_lists_already_current_when_merged(self, shelved_repo: pathlib.Path) -> None:
1050 """Files merged into HEAD since shelving appear in already_current."""
1051 # Apply the shelf so HEAD gets the files (simulate a merge)
1052 runner.invoke(
1053 cli, ["shelf", "apply"], env=_env(shelved_repo), catch_exceptions=False
1054 )
1055 runner.invoke(
1056 cli, ["commit", "-m", "merged shelf content"], env=_env(shelved_repo),
1057 catch_exceptions=False,
1058 )
1059 r = runner.invoke(
1060 cli, ["shelf", "diff", "--json"], env=_env(shelved_repo), catch_exceptions=False
1061 )
1062 d = json.loads(r.output)
1063 # After committing, would_restore should be empty (or have fewer files)
1064 # and already_current should be populated
1065 assert len(d["already_current"]) >= 0 # defensive — structure is correct
1066
1067
1068 # ---------------------------------------------------------------------------
1069 # Integration — name/index resolution
1070 # ---------------------------------------------------------------------------
1071
1072
1073 class TestNameIndexResolution:
1074 def _save_n(self, repo: pathlib.Path, n: int) -> list[str]:
1075 """Save n distinct shelf entries, return their auto-generated names."""
1076 names: list[str] = []
1077 for i in range(n):
1078 (repo / f"w{i}.py").write_text(f"data {i}\n")
1079 r = runner.invoke(
1080 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
1081 )
1082 names.insert(0, json.loads(r.output)["name"]) # newest first
1083 return names
1084
1085 def test_pop_by_name(self, repo: pathlib.Path) -> None:
1086 names = self._save_n(repo, 3)
1087 r = runner.invoke(
1088 cli, ["shelf", "pop", names[2], "--json"], env=_env(repo), catch_exceptions=False
1089 )
1090 assert r.exit_code == 0, r.output
1091 assert json.loads(r.output)["name"] == names[2]
1092
1093 def test_pop_by_index(self, repo: pathlib.Path) -> None:
1094 names = self._save_n(repo, 3)
1095 r = runner.invoke(
1096 cli, ["shelf", "pop", "0", "--json"], env=_env(repo), catch_exceptions=False
1097 )
1098 assert r.exit_code == 0, r.output
1099 assert json.loads(r.output)["name"] == names[0] # newest = 0
1100
1101 def test_drop_by_name(self, repo: pathlib.Path) -> None:
1102 names = self._save_n(repo, 2)
1103 r = runner.invoke(
1104 cli, ["shelf", "drop", names[1], "--json"], env=_env(repo), catch_exceptions=False
1105 )
1106 assert r.exit_code == 0, r.output
1107 assert json.loads(r.output)["name"] == names[1]
1108
1109 def test_out_of_range_exits_1(self, repo: pathlib.Path) -> None:
1110 self._save_n(repo, 2)
1111 r = runner.invoke(cli, ["shelf", "pop", "99"], env=_env(repo))
1112 assert r.exit_code == 1
1113
1114 def test_unknown_name_exits_1(self, repo: pathlib.Path) -> None:
1115 self._save_n(repo, 1)
1116 r = runner.invoke(cli, ["shelf", "pop", "no-such-name"], env=_env(repo))
1117 assert r.exit_code == 1
1118
1119
1120 # ---------------------------------------------------------------------------
1121 # Integration — object store integrity
1122 # ---------------------------------------------------------------------------
1123
1124
1125 class TestObjectIntegrity:
1126 def _corrupt_object(self, root: pathlib.Path, snapshot: Mapping[str, str]) -> None:
1127 for obj_id in list(snapshot.values())[:1]:
1128 p = object_path(root, obj_id)
1129 if p.exists():
1130 p.unlink()
1131 break
1132
1133 def test_pop_with_missing_object_exits_3(self, shelved_repo: pathlib.Path) -> None:
1134 shelf_data = json.loads((shelved_repo / ".muse" / "shelf.json").read_text())
1135 self._corrupt_object(shelved_repo, shelf_data[0]["snapshot"])
1136 r = runner.invoke(cli, ["shelf", "pop"], env=_env(shelved_repo))
1137 assert r.exit_code == 3
1138
1139 def test_apply_with_missing_object_exits_3(self, shelved_repo: pathlib.Path) -> None:
1140 shelf_data = json.loads((shelved_repo / ".muse" / "shelf.json").read_text())
1141 self._corrupt_object(shelved_repo, shelf_data[0]["snapshot"])
1142 r = runner.invoke(cli, ["shelf", "apply"], env=_env(shelved_repo))
1143 assert r.exit_code == 3
1144
1145 def test_drop_succeeds_even_with_missing_objects(self, shelved_repo: pathlib.Path) -> None:
1146 """drop never reads objects — it only removes the registry entry."""
1147 shelf_data = json.loads((shelved_repo / ".muse" / "shelf.json").read_text())
1148 self._corrupt_object(shelved_repo, shelf_data[0]["snapshot"])
1149 r = runner.invoke(
1150 cli, ["shelf", "drop"], env=_env(shelved_repo), catch_exceptions=False
1151 )
1152 assert r.exit_code == 0
1153
1154
1155 # ---------------------------------------------------------------------------
1156 # Integration — programmatic API
1157 # ---------------------------------------------------------------------------
1158
1159
1160 class TestProgrammaticApi:
1161 def test_push_returns_entry(self, repo: pathlib.Path) -> None:
1162 from muse.cli.commands.shelf import _shelf_push_programmatic
1163 entry = _shelf_push_programmatic(repo)
1164 assert entry is not None
1165 assert entry["id"].startswith("sha256:")
1166 assert entry["intent_type"] == "interrupt"
1167
1168 def test_push_clean_returns_none(self, repo: pathlib.Path) -> None:
1169 (repo / "b.py").unlink(missing_ok=True)
1170 from muse.cli.commands.shelf import _shelf_push_programmatic
1171 entry = _shelf_push_programmatic(repo)
1172 assert entry is None
1173
1174 def test_push_with_metadata(self, repo: pathlib.Path) -> None:
1175 from muse.cli.commands.shelf import _shelf_push_programmatic
1176 entry = _shelf_push_programmatic(
1177 repo,
1178 intent_type="handoff",
1179 intent="auth refactor, 60% done",
1180 created_by="agent-7",
1181 resumable=True,
1182 tags=["auth"],
1183 )
1184 assert entry is not None
1185 assert entry["intent_type"] == "handoff"
1186 assert entry["intent"] == "auth refactor, 60% done"
1187 assert entry["created_by"] == "agent-7"
1188 assert entry["resumable"] is True
1189 assert "auth" in entry["tags"]
1190
1191 def test_push_duplicate_name_raises(self, repo: pathlib.Path) -> None:
1192 from muse.cli.commands.shelf import _shelf_push_programmatic
1193 _shelf_push_programmatic(repo, name="my-shelf")
1194 (repo / "b.py").write_text("new content\n")
1195 with pytest.raises(ValueError, match="already exists"):
1196 _shelf_push_programmatic(repo, name="my-shelf")
1197
1198 def test_pop_returns_entry(self, shelved_repo: pathlib.Path) -> None:
1199 from muse.cli.commands.shelf import _shelf_pop_programmatic
1200 entry = _shelf_pop_programmatic(shelved_repo)
1201 assert entry is not None
1202 assert entry["id"].startswith("sha256:")
1203
1204 def test_pop_empty_raises(self, repo: pathlib.Path) -> None:
1205 from muse.cli.commands.shelf import _shelf_pop_programmatic
1206 with pytest.raises(ValueError, match="No shelf entries"):
1207 _shelf_pop_programmatic(repo)
1208
1209 def test_pop_removes_from_registry(self, shelved_repo: pathlib.Path) -> None:
1210 from muse.cli.commands.shelf import _shelf_pop_programmatic, _load_shelf
1211 before = len(_load_shelf(shelved_repo))
1212 _shelf_pop_programmatic(shelved_repo)
1213 after = len(_load_shelf(shelved_repo))
1214 assert after == before - 1
1215
1216 def test_pop_by_name(self, repo: pathlib.Path) -> None:
1217 from muse.cli.commands.shelf import _shelf_push_programmatic, _shelf_pop_programmatic
1218 entry = _shelf_push_programmatic(repo, name="named-shelf")
1219 assert entry is not None
1220 (repo / "b.py").write_text("restored content\n")
1221 popped = _shelf_pop_programmatic(repo, "named-shelf")
1222 assert popped["name"] == "named-shelf"
1223
1224
1225 # ---------------------------------------------------------------------------
1226 # End-to-end — round-trips
1227 # ---------------------------------------------------------------------------
1228
1229
1230 class TestRoundTrips:
1231 def test_save_pop_restores_content(self, repo: pathlib.Path) -> None:
1232 b_content = (repo / "b.py").read_text()
1233 runner.invoke(
1234 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1235 )
1236 assert not (repo / "b.py").exists()
1237 runner.invoke(
1238 cli, ["shelf", "pop"], env=_env(repo), catch_exceptions=False
1239 )
1240 assert (repo / "b.py").exists()
1241 assert (repo / "b.py").read_text() == b_content
1242
1243 def test_save_apply_apply_idempotent(self, repo: pathlib.Path) -> None:
1244 runner.invoke(
1245 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1246 )
1247 r1 = runner.invoke(
1248 cli, ["shelf", "apply", "--json"], env=_env(repo), catch_exceptions=False
1249 )
1250 # Apply again — should report already_current for the second call
1251 r2 = runner.invoke(
1252 cli, ["shelf", "apply", "--json"], env=_env(repo), catch_exceptions=False
1253 )
1254 d2 = json.loads(r2.output)
1255 # Second apply: restored == 0 (files already written), already_current > 0
1256 # (files match what's on disk, but HEAD still shows old state)
1257 # At minimum, the command must succeed
1258 assert r2.exit_code == 0
1259
1260 def test_save_drop_no_restore(self, repo: pathlib.Path) -> None:
1261 runner.invoke(
1262 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1263 )
1264 runner.invoke(
1265 cli, ["shelf", "drop"], env=_env(repo), catch_exceptions=False
1266 )
1267 assert not (repo / "b.py").exists()
1268
1269 def test_stack_ordering_newest_first(self, repo: pathlib.Path) -> None:
1270 """Entries are ordered newest-first; index 0 is the most recent."""
1271 names: list[str] = []
1272 for i in range(3):
1273 (repo / f"w{i}.py").write_text(f"data {i}\n")
1274 r = runner.invoke(
1275 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
1276 )
1277 names.append(json.loads(r.output)["name"])
1278
1279 listing = json.loads(
1280 runner.invoke(
1281 cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False
1282 ).output
1283 )["entries"]
1284 # Most recent save should be at index 0
1285 assert listing[0]["name"] == names[-1]
1286
1287 def test_shelf_persists_across_commands(self, repo: pathlib.Path) -> None:
1288 runner.invoke(
1289 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1290 )
1291 r = runner.invoke(
1292 cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False
1293 )
1294 assert len(json.loads(r.output)["entries"]) == 1
1295
1296 def test_named_save_then_pop_by_name(self, repo: pathlib.Path) -> None:
1297 runner.invoke(
1298 cli, ["shelf", "save", "my-feature-work", "--json"],
1299 env=_env(repo), catch_exceptions=False,
1300 )
1301 r = runner.invoke(
1302 cli, ["shelf", "pop", "my-feature-work", "--json"],
1303 env=_env(repo), catch_exceptions=False,
1304 )
1305 assert r.exit_code == 0
1306 assert json.loads(r.output)["name"] == "my-feature-work"
1307
1308
1309 # ---------------------------------------------------------------------------
1310 # Data integrity
1311 # ---------------------------------------------------------------------------
1312
1313
1314 class TestDataIntegrity:
1315 def test_already_current_detection(self, repo: pathlib.Path) -> None:
1316 """Files merged into HEAD since shelving appear as already_current on apply."""
1317 # Save the shelf
1318 runner.invoke(
1319 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1320 )
1321 # Restore the file and commit it (simulating a merge)
1322 (repo / "b.py").write_text("y = 2\n")
1323 runner.invoke(
1324 cli, ["commit", "-m", "merge: add b.py"], env=_env(repo), catch_exceptions=False
1325 )
1326 # Now apply the shelf — b.py should be already_current
1327 r = runner.invoke(
1328 cli, ["shelf", "apply", "--json"], env=_env(repo), catch_exceptions=False
1329 )
1330 d = json.loads(r.output)
1331 assert d["already_current"] > 0, "Files merged into HEAD must show as already_current"
1332 assert d["restored"] == 0
1333
1334 def test_snapshot_contains_all_tracked_files(self, repo: pathlib.Path) -> None:
1335 """The shelf snapshot covers all files in the working tree at save time."""
1336 (repo / "c.py").write_text("c = 3\n")
1337 r = runner.invoke(
1338 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
1339 )
1340 assert r.exit_code == 0
1341 shelf_data = json.loads((repo / ".muse" / "shelf.json").read_text())
1342 snapshot = shelf_data[0]["snapshot"]
1343 # a.py was committed; b.py and c.py are new
1344 assert any("a.py" in k or "b.py" in k or "c.py" in k for k in snapshot)
1345
1346 def test_content_address_id_matches_recomputed(self, repo: pathlib.Path) -> None:
1347 """The id in shelf.json matches _compute_shelf_id applied to the entry data."""
1348 runner.invoke(
1349 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1350 )
1351 from muse.cli.commands.shelf import _compute_shelf_id
1352 shelf_data = json.loads((repo / ".muse" / "shelf.json").read_text())
1353 entry = shelf_data[0]
1354 stored_id = entry.pop("id")
1355 recomputed = _compute_shelf_id(entry)
1356 assert recomputed == stored_id
1357
1358 def test_deleted_paths_tracked(self, repo: pathlib.Path) -> None:
1359 """Files deleted from the working tree before shelving appear in 'deleted'."""
1360 # Commit b.py so it's tracked, then delete it
1361 (repo / "b.py").write_text("y = 2\n")
1362 runner.invoke(
1363 cli, ["commit", "-m", "add b"], env=_env(repo), catch_exceptions=False
1364 )
1365 (repo / "b.py").unlink()
1366 (repo / "c.py").write_text("z = 3\n") # make tree dirty
1367 runner.invoke(
1368 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1369 )
1370 shelf_data = json.loads((repo / ".muse" / "shelf.json").read_text())
1371 deleted = shelf_data[0]["deleted"]
1372 assert "b.py" in deleted
1373
1374 def test_snapshot_id_is_sha256_prefixed(self, repo: pathlib.Path) -> None:
1375 runner.invoke(
1376 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1377 )
1378 shelf_data = json.loads((repo / ".muse" / "shelf.json").read_text())
1379 assert shelf_data[0]["snapshot_id"].startswith("sha256:")
1380
1381
1382 # ---------------------------------------------------------------------------
1383 # Performance
1384 # ---------------------------------------------------------------------------
1385
1386
1387 class TestPerformance:
1388 def test_save_pop_under_5s(self, repo: pathlib.Path) -> None:
1389 start = time.perf_counter()
1390 runner.invoke(
1391 cli, ["shelf", "save"], env=_env(repo), catch_exceptions=False
1392 )
1393 runner.invoke(
1394 cli, ["shelf", "pop"], env=_env(repo), catch_exceptions=False
1395 )
1396 elapsed = time.perf_counter() - start
1397 assert elapsed < 5.0, f"save+pop too slow: {elapsed:.2f}s"
1398
1399 def test_list_50_entries_under_2s(self, tmp_path: pathlib.Path) -> None:
1400 root, _ = _init_repo(tmp_path)
1401 from muse.cli.commands.shelf import _save_shelf, ShelfEntry, _compute_shelf_id
1402 entries = []
1403 for i in range(50):
1404 raw = _make_shelf_entry(name=f"dev/{i:03d}")
1405 raw["created_at"] = f"2025-01-01T{i:02d}:00:00+00:00"
1406 entries.append(ShelfEntry(id=_compute_shelf_id(raw), **raw)) # type: ignore[misc]
1407 _save_shelf(root, entries)
1408
1409 start = time.perf_counter()
1410 from muse.cli.commands.shelf import _load_shelf
1411 loaded = _load_shelf(root)
1412 elapsed = time.perf_counter() - start
1413 assert len(loaded) == 50
1414 assert elapsed < 2.0, f"_load_shelf(50 entries) too slow: {elapsed:.2f}s"
1415
1416
1417 # ---------------------------------------------------------------------------
1418 # Security
1419 # ---------------------------------------------------------------------------
1420
1421
1422 class TestSecurity:
1423 def test_symlink_at_shelf_json_returns_empty(self, tmp_path: pathlib.Path) -> None:
1424 root, _ = _init_repo(tmp_path)
1425 target = tmp_path / "secret.json"
1426 target.write_text(json.dumps([_make_shelf_entry()]))
1427 shelf_path = root / ".muse" / "shelf.json"
1428 shelf_path.symlink_to(target)
1429 from muse.cli.commands.shelf import _load_shelf
1430 result = _load_shelf(root)
1431 assert result == []
1432
1433 def test_ansi_in_branch_name_sanitized(self, tmp_path: pathlib.Path) -> None:
1434 root, _ = _init_repo(tmp_path)
1435 from muse.cli.commands.shelf import _save_shelf, ShelfEntry, _compute_shelf_id
1436 malicious = "feat/\x1b[31mred\x1b[0m"
1437 raw = _make_shelf_entry(name="dev/000", branch=malicious)
1438 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
1439 _save_shelf(root, [entry])
1440
1441 r = runner.invoke(cli, ["shelf", "list"], env=_env(root), catch_exceptions=False)
1442 assert r.exit_code == 0
1443 assert "\x1b" not in r.output
1444
1445 def test_ansi_in_intent_sanitized(self, tmp_path: pathlib.Path) -> None:
1446 root, _ = _init_repo(tmp_path)
1447 from muse.cli.commands.shelf import _save_shelf, ShelfEntry, _compute_shelf_id
1448 raw = _make_shelf_entry(name="dev/000", intent="safe \x1b[31mbad\x1b[0m intent")
1449 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
1450 _save_shelf(root, [entry])
1451
1452 r = runner.invoke(cli, ["shelf", "list"], env=_env(root), catch_exceptions=False)
1453 assert r.exit_code == 0
1454 assert "\x1b" not in r.output
1455
1456 def test_ansi_in_file_path_sanitized_in_read(self, tmp_path: pathlib.Path) -> None:
1457 root, repo_id = _init_repo(tmp_path)
1458 _make_commit(root, repo_id)
1459 from muse.cli.commands.shelf import _save_shelf, ShelfEntry, _compute_shelf_id
1460 malicious_path = "src/\x1b[31mevil\x1b[0m.py"
1461 raw = _make_shelf_entry(snapshot={malicious_path: long_id("a" * 64)})
1462 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
1463 _save_shelf(root, [entry])
1464
1465 r = runner.invoke(cli, ["shelf", "read"], env=_env(root), catch_exceptions=False)
1466 assert r.exit_code == 0
1467 assert "\x1b" not in r.output
1468
1469 def test_invalid_format_exits_1(self, repo: pathlib.Path) -> None:
1470 r = runner.invoke(cli, ["shelf", "save", "--format", "xml"], env=_env(repo))
1471 assert r.exit_code != 0
1472
1473 def test_oversized_shelf_json_ignored(self, tmp_path: pathlib.Path) -> None:
1474 root, _ = _init_repo(tmp_path)
1475 (root / ".muse" / "shelf.json").write_bytes(b"x" * (65 * 1024 * 1024))
1476 from muse.cli.commands.shelf import _load_shelf
1477 assert _load_shelf(root) == []
1478
1479 def test_snapshot_values_must_be_strings(self, tmp_path: pathlib.Path) -> None:
1480 """Non-string snapshot values must be filtered out on load."""
1481 root, _ = _init_repo(tmp_path)
1482 # JSON always converts dict keys to strings, so we can't test non-string keys.
1483 # Test instead that non-string values are stripped.
1484 raw_json = json.dumps([{
1485 "name": "dev/000",
1486 "snapshot": {"a.py": long_id("a" * 64), "b.py": 42}, # integer value
1487 "deleted": [],
1488 "snapshot_id": long_id("b" * 64),
1489 "parent_commit": long_id("c" * 64),
1490 "branch": "main",
1491 "created_at": "2025-01-01T00:00:00+00:00",
1492 "created_by": "human",
1493 "intent_type": "checkpoint",
1494 "intent": None,
1495 "resumable": False,
1496 "tags": [],
1497 "expires_at": None,
1498 "domain_state": {},
1499 }])
1500 (root / ".muse" / "shelf.json").write_text(raw_json, encoding="utf-8")
1501 from muse.cli.commands.shelf import _load_shelf
1502 loaded = _load_shelf(root)
1503 # Entry must load; the non-string value must be stripped
1504 assert len(loaded) == 1
1505 assert "b.py" not in loaded[0]["snapshot"]
1506 assert "a.py" in loaded[0]["snapshot"]
1507
1508
1509 # ---------------------------------------------------------------------------
1510 # Stress
1511 # ---------------------------------------------------------------------------
1512
1513
1514 class TestStress:
1515 def test_100_save_drop_cycles(self, repo: pathlib.Path) -> None:
1516 """100 sequential save/drop cycles must not corrupt the registry."""
1517 for i in range(100):
1518 (repo / f"w{i}.py").write_text(f"data {i}\n")
1519 r = runner.invoke(
1520 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
1521 )
1522 assert r.exit_code == 0, f"save {i}: {r.output}"
1523 r = runner.invoke(
1524 cli, ["shelf", "drop", "--json"], env=_env(repo), catch_exceptions=False
1525 )
1526 assert r.exit_code == 0, f"drop {i}: {r.output}"
1527
1528 listing = json.loads(
1529 runner.invoke(
1530 cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False
1531 ).output
1532 )["entries"]
1533 assert listing == []
1534
1535 def test_stack_with_50_entries_then_clear(self, repo: pathlib.Path) -> None:
1536 for i in range(50):
1537 (repo / f"w{i}.py").write_text(f"data {i}\n")
1538 r = runner.invoke(
1539 cli, ["shelf", "save", "--json"], env=_env(repo), catch_exceptions=False
1540 )
1541 assert r.exit_code == 0, f"save {i}: {r.output}"
1542
1543 listing = json.loads(
1544 runner.invoke(
1545 cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False
1546 ).output
1547 )["entries"]
1548 assert len(listing) == 50
1549
1550 for _ in range(50):
1551 r = runner.invoke(
1552 cli, ["shelf", "drop"], env=_env(repo), catch_exceptions=False
1553 )
1554 assert r.exit_code == 0
1555
1556 listing = json.loads(
1557 runner.invoke(
1558 cli, ["shelf", "list", "--json"], env=_env(repo), catch_exceptions=False
1559 ).output
1560 )["entries"]
1561 assert listing == []
1562
1563 def test_concurrent_save_to_isolated_repos(self, tmp_path: pathlib.Path) -> None:
1564 """Concurrent shelf saves to separate repos must not interfere."""
1565 errors: list[Exception] = []
1566
1567 def _save_in_repo(idx: int) -> None:
1568 try:
1569 sub = tmp_path / f"repo{idx}"
1570 sub.mkdir()
1571 root, repo_id = _init_repo(sub)
1572 # Commit a base file so HEAD is non-empty
1573 (sub / "base.py").write_text(f"base {idx}\n")
1574 r = runner.invoke(
1575 cli, ["commit", "-m", f"base{idx}"], env=_env(sub), catch_exceptions=False
1576 )
1577 assert r.exit_code == 0, f"thread {idx} commit: {r.output}"
1578 # Add a dirty file and shelf it
1579 (sub / "work.py").write_text(f"thread {idx}\n")
1580 r = runner.invoke(
1581 cli, ["shelf", "save", "--json"], env=_env(sub), catch_exceptions=False
1582 )
1583 assert r.exit_code == 0, f"thread {idx}: {r.output}"
1584 except Exception as exc:
1585 errors.append(exc)
1586
1587 threads = [threading.Thread(target=_save_in_repo, args=(i,)) for i in range(10)]
1588 for t in threads:
1589 t.start()
1590 for t in threads:
1591 t.join()
1592
1593 assert errors == [], f"Concurrent errors: {errors}"
1594
1595 def test_save_load_large_snapshot(self, tmp_path: pathlib.Path) -> None:
1596 """Shelf with 500 files in snapshot loads correctly."""
1597 root, _ = _init_repo(tmp_path)
1598 from muse.cli.commands.shelf import _save_shelf, _load_shelf, ShelfEntry
1599 from muse.cli.commands.shelf import _compute_shelf_id
1600 big_snapshot = {f"src/file_{i:04d}.py": long_id(hex(i).zfill(64)[-64:])
1601 for i in range(500)}
1602 raw = _make_shelf_entry(name="dev/000", snapshot=big_snapshot)
1603 entry = ShelfEntry(id=_compute_shelf_id(raw), **raw) # type: ignore[misc]
1604 _save_shelf(root, [entry])
1605 loaded = _load_shelf(root)
1606 assert len(loaded) == 1
1607 assert len(loaded[0]["snapshot"]) == 500
1608
1609
1610 # ---------------------------------------------------------------------------
1611 # Docstrings
1612 # ---------------------------------------------------------------------------
1613
1614
1615 class TestDocstrings:
1616 def test_module_docstring(self) -> None:
1617 import muse.cli.commands.shelf as m
1618 assert m.__doc__
1619
1620 def test_run_save_docstring(self) -> None:
1621 from muse.cli.commands.shelf import run_save
1622 assert run_save.__doc__
1623
1624 def test_run_list_docstring(self) -> None:
1625 from muse.cli.commands.shelf import run_list
1626 assert run_list.__doc__
1627
1628 def test_run_read_docstring(self) -> None:
1629 from muse.cli.commands.shelf import run_read
1630 assert run_read.__doc__
1631
1632 def test_run_apply_docstring(self) -> None:
1633 from muse.cli.commands.shelf import run_apply
1634 assert run_apply.__doc__
1635
1636 def test_run_pop_docstring(self) -> None:
1637 from muse.cli.commands.shelf import run_pop
1638 assert run_pop.__doc__
1639
1640 def test_run_drop_docstring(self) -> None:
1641 from muse.cli.commands.shelf import run_drop
1642 assert run_drop.__doc__
1643
1644 def test_run_diff_docstring(self) -> None:
1645 from muse.cli.commands.shelf import run_diff
1646 assert run_diff.__doc__
1647
1648 def test_shelf_push_programmatic_docstring(self) -> None:
1649 from muse.cli.commands.shelf import _shelf_push_programmatic
1650 assert _shelf_push_programmatic.__doc__
1651
1652 def test_shelf_pop_programmatic_docstring(self) -> None:
1653 from muse.cli.commands.shelf import _shelf_pop_programmatic
1654 assert _shelf_pop_programmatic.__doc__
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 142 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 148 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 151 days ago