gabriel / muse public
test_cmd_restore.py python
460 lines 15.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
1 """Tests for ``muse restore`` — working-tree and stage file restoration.
2
3 Coverage tiers:
4 - Unit: _resolve_source_manifest, _resolve_file_path helpers
5 - Integration: restore worktree from HEAD (default), restore --staged (unstage),
6 restore --staged --worktree (full reset), --source <ref>,
7 multiple paths, glob patterns, --dry-run, --json
8 - End-to-end: full CLI via CliRunner
9 - Security: path traversal rejected, outside-repo paths rejected
10 - Edge cases: file not in HEAD/source, staged-only file restore
11 - Stress: restore 100 modified files
12 """
13
14 from __future__ import annotations
15 from collections.abc import Mapping
16
17 import datetime
18 import json
19 import pathlib
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner
24
25 from muse.core.object_store import write_object
26 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
27 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
28 from muse.core._types import Manifest, blob_id
29 from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage
30
31 runner = CliRunner()
32
33 _REPO_ID = "restore-test"
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _sha(data: bytes) -> str:
42 return blob_id(data)
43
44
45 _counter = 0
46
47
48 def _init_repo(path: pathlib.Path) -> pathlib.Path:
49 muse = path / ".muse"
50 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
51 (muse / d).mkdir(parents=True, exist_ok=True)
52 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
53 (muse / "repo.json").write_text(
54 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
55 )
56 return path
57
58
59 def _env(repo: pathlib.Path) -> Mapping[str, str]:
60 return {"MUSE_REPO_ROOT": str(repo)}
61
62
63 def _commit_files(root: pathlib.Path, files: Mapping[str, bytes], branch: str = "main") -> str:
64 global _counter
65 _counter += 1
66 manifest: Manifest = {}
67 for rel_path, content in files.items():
68 obj_id = _sha(content)
69 write_object(root, obj_id, content)
70 manifest[rel_path] = obj_id
71 abs_path = root / rel_path
72 abs_path.parent.mkdir(parents=True, exist_ok=True)
73 abs_path.write_bytes(content)
74 snap_id = compute_snapshot_id(manifest)
75 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
76 committed_at = datetime.datetime.now(datetime.timezone.utc)
77 commit_id = compute_commit_id(
78 repo_id=_REPO_ID,
79 parent_ids=[],
80 snapshot_id=snap_id,
81 message=f"commit {_counter}",
82 committed_at_iso=committed_at.isoformat(),
83 )
84 write_commit(
85 root,
86 CommitRecord(
87 commit_id=commit_id,
88 repo_id=_REPO_ID,
89 created_on_branch=branch,
90 snapshot_id=snap_id,
91 message=f"commit {_counter}",
92 committed_at=committed_at,
93 ),
94 )
95 (root / ".muse" / "refs" / "heads" / branch).write_text(commit_id, encoding="utf-8")
96 return commit_id
97
98
99 def _invoke(repo: pathlib.Path, *args: str):
100 from muse.cli.app import main as cli
101 return runner.invoke(cli, ["restore", *args], env=_env(repo))
102
103
104 # ---------------------------------------------------------------------------
105 # Unit — helpers
106 # ---------------------------------------------------------------------------
107
108
109 def test_resolve_source_manifest_from_head(tmp_path: pathlib.Path) -> None:
110 from muse.cli.commands.restore import _resolve_source_manifest
111 root = _init_repo(tmp_path)
112 content = b"hello\n"
113 _commit_files(root, {"f.py": content})
114 manifest = _resolve_source_manifest(root, source_ref=None)
115 assert "f.py" in manifest
116 assert manifest["f.py"] == _sha(content)
117
118
119 def test_resolve_source_manifest_empty_repo(tmp_path: pathlib.Path) -> None:
120 from muse.cli.commands.restore import _resolve_source_manifest
121 root = _init_repo(tmp_path)
122 # No commits yet — should return empty dict, not raise
123 manifest = _resolve_source_manifest(root, source_ref=None)
124 assert manifest == {}
125
126
127 def test_resolve_file_path_inside_repo(tmp_path: pathlib.Path) -> None:
128 from muse.cli.commands.restore import _resolve_file_path
129 root = _init_repo(tmp_path)
130 rel = _resolve_file_path(root, "src/main.py")
131 assert rel == "src/main.py"
132
133
134 def test_resolve_file_path_traversal_raises(tmp_path: pathlib.Path) -> None:
135 from muse.cli.commands.restore import _resolve_file_path
136 root = _init_repo(tmp_path)
137 with pytest.raises(SystemExit) as exc:
138 _resolve_file_path(root, "../../../etc/passwd")
139 assert exc.value.code != 0
140
141
142 # ---------------------------------------------------------------------------
143 # Integration — restore worktree (default)
144 # ---------------------------------------------------------------------------
145
146
147 def test_restore_worktree_overwrites_modified_file(tmp_path: pathlib.Path) -> None:
148 root = _init_repo(tmp_path)
149 original = b"# original\n"
150 _commit_files(root, {"a.py": original})
151 # Modify on disk
152 (root / "a.py").write_bytes(b"# dirty\n")
153
154 result = _invoke(root, "a.py")
155 assert result.exit_code == 0
156 assert (root / "a.py").read_bytes() == original
157
158
159 def test_restore_worktree_does_not_touch_stage(tmp_path: pathlib.Path) -> None:
160 root = _init_repo(tmp_path)
161 _commit_files(root, {"a.py": b"# orig\n"})
162 # Stage a modification
163 new_content = b"# staged\n"
164 obj_id = _sha(new_content)
165 write_object(root, obj_id, new_content)
166 stage = read_stage(root)
167 stage["a.py"] = make_entry(obj_id, "M")
168 write_stage(root, stage)
169 # Dirty the disk
170 (root / "a.py").write_bytes(b"# dirty\n")
171
172 _invoke(root, "a.py")
173 # Stage must be untouched
174 stage_after = read_stage(root)
175 assert "a.py" in stage_after
176 assert stage_after["a.py"]["mode"] == "M"
177
178
179 def test_restore_worktree_from_staged_content(tmp_path: pathlib.Path) -> None:
180 """When a file is staged, default restore pulls from the staged object_id."""
181 root = _init_repo(tmp_path)
182 _commit_files(root, {"b.py": b"# head\n"})
183 staged_content = b"# staged version\n"
184 obj_id = _sha(staged_content)
185 write_object(root, obj_id, staged_content)
186 stage = read_stage(root)
187 stage["b.py"] = make_entry(obj_id, "M")
188 write_stage(root, stage)
189 (root / "b.py").write_bytes(b"# dirty\n")
190
191 _invoke(root, "b.py")
192 assert (root / "b.py").read_bytes() == staged_content
193
194
195 def test_restore_exit_zero_on_success(tmp_path: pathlib.Path) -> None:
196 root = _init_repo(tmp_path)
197 _commit_files(root, {"a.py": b"# a\n"})
198 (root / "a.py").write_bytes(b"# dirty\n")
199 result = _invoke(root, "a.py")
200 assert result.exit_code == 0
201
202
203 def test_restore_file_not_in_head_exits_nonzero(tmp_path: pathlib.Path) -> None:
204 root = _init_repo(tmp_path)
205 _commit_files(root, {"other.py": b"# o\n"})
206 result = _invoke(root, "ghost.py")
207 assert result.exit_code != 0
208
209
210 # ---------------------------------------------------------------------------
211 # Integration — restore --staged
212 # ---------------------------------------------------------------------------
213
214
215 def test_restore_staged_removes_modification(tmp_path: pathlib.Path) -> None:
216 root = _init_repo(tmp_path)
217 _commit_files(root, {"a.py": b"# orig\n"})
218 obj_id = _sha(b"# modified\n")
219 write_object(root, obj_id, b"# modified\n")
220 stage: StagedFileMap = {"a.py": make_entry(obj_id, "M")}
221 write_stage(root, stage)
222
223 result = _invoke(root, "--staged", "a.py")
224 assert result.exit_code == 0
225 stage_after = read_stage(root)
226 assert "a.py" not in stage_after
227
228
229 def test_restore_staged_does_not_touch_disk(tmp_path: pathlib.Path) -> None:
230 root = _init_repo(tmp_path)
231 _commit_files(root, {"a.py": b"# orig\n"})
232 modified_content = b"# modified\n"
233 obj_id = _sha(modified_content)
234 write_object(root, obj_id, modified_content)
235 stage: StagedFileMap = {"a.py": make_entry(obj_id, "M")}
236 write_stage(root, stage)
237 (root / "a.py").write_bytes(modified_content)
238
239 _invoke(root, "--staged", "a.py")
240 # Disk still has the modified content
241 assert (root / "a.py").read_bytes() == modified_content
242
243
244 def test_restore_staged_removes_added_file(tmp_path: pathlib.Path) -> None:
245 """Unstaging a brand-new file (mode 'A', not in HEAD) removes it from stage."""
246 root = _init_repo(tmp_path)
247 _commit_files(root, {"anchor.py": b"# anchor\n"})
248 content = b"# new\n"
249 obj_id = _sha(content)
250 write_object(root, obj_id, content)
251 (root / "new.py").write_bytes(content)
252 stage: StagedFileMap = {"new.py": make_entry(obj_id, "A")}
253 write_stage(root, stage)
254
255 result = _invoke(root, "--staged", "new.py")
256 assert result.exit_code == 0
257 stage_after = read_stage(root)
258 assert "new.py" not in stage_after
259 # Disk file untouched
260 assert (root / "new.py").exists()
261
262
263 def test_restore_staged_undeletes_from_stage(tmp_path: pathlib.Path) -> None:
264 """Restoring --staged a deleted file removes the 'D' tombstone."""
265 root = _init_repo(tmp_path)
266 _commit_files(root, {"gone.py": b"# original\n"})
267 stage: StagedFileMap = {"gone.py": make_entry("", "D")}
268 write_stage(root, stage)
269
270 result = _invoke(root, "--staged", "gone.py")
271 assert result.exit_code == 0
272 stage_after = read_stage(root)
273 assert "gone.py" not in stage_after
274
275
276 def test_restore_staged_not_staged_is_noop(tmp_path: pathlib.Path) -> None:
277 """Restoring --staged a file that isn't staged is a clean no-op."""
278 root = _init_repo(tmp_path)
279 _commit_files(root, {"a.py": b"# a\n"})
280 result = _invoke(root, "--staged", "a.py")
281 assert result.exit_code == 0
282
283
284 # ---------------------------------------------------------------------------
285 # Integration — restore --staged --worktree (full reset)
286 # ---------------------------------------------------------------------------
287
288
289 def test_restore_staged_worktree_resets_both(tmp_path: pathlib.Path) -> None:
290 """--staged --worktree restores disk and clears stage entry."""
291 root = _init_repo(tmp_path)
292 original = b"# original\n"
293 _commit_files(root, {"f.py": original})
294 modified = b"# modified\n"
295 obj_id = _sha(modified)
296 write_object(root, obj_id, modified)
297 stage: StagedFileMap = {"f.py": make_entry(obj_id, "M")}
298 write_stage(root, stage)
299 (root / "f.py").write_bytes(modified)
300
301 result = _invoke(root, "--staged", "--worktree", "f.py")
302 assert result.exit_code == 0
303 assert (root / "f.py").read_bytes() == original
304 stage_after = read_stage(root)
305 assert "f.py" not in stage_after
306
307
308 # ---------------------------------------------------------------------------
309 # Integration — --source <ref>
310 # ---------------------------------------------------------------------------
311
312
313 def test_restore_source_ref_restores_from_commit(tmp_path: pathlib.Path) -> None:
314 """--source <commit_id> restores file from that commit's manifest."""
315 root = _init_repo(tmp_path)
316 v1 = b"# version 1\n"
317 commit_v1 = _commit_files(root, {"versioned.py": v1})
318 # Now update the file in HEAD
319 v2 = b"# version 2\n"
320 _commit_files(root, {"versioned.py": v2})
321 # Disk now has v2; restore to v1 using the first commit id
322 (root / "versioned.py").write_bytes(b"# dirty\n")
323
324 result = _invoke(root, "--source", commit_v1, "versioned.py")
325 assert result.exit_code == 0
326 assert (root / "versioned.py").read_bytes() == v1
327
328
329 def test_restore_source_ref_not_found_exits_nonzero(tmp_path: pathlib.Path) -> None:
330 root = _init_repo(tmp_path)
331 _commit_files(root, {"a.py": b"# a\n"})
332 result = _invoke(root, "--source", "nonexistent-ref", "a.py")
333 assert result.exit_code != 0
334
335
336 def test_restore_source_file_not_in_that_commit_exits_nonzero(tmp_path: pathlib.Path) -> None:
337 root = _init_repo(tmp_path)
338 v1_commit = _commit_files(root, {"only_in_v1.py": b"# v1\n"})
339 _commit_files(root, {"v2_only.py": b"# v2\n"})
340
341 result = _invoke(root, "--source", v1_commit, "v2_only.py")
342 assert result.exit_code != 0
343
344
345 # ---------------------------------------------------------------------------
346 # Integration -- multiple paths
347 # ---------------------------------------------------------------------------
348
349
350 def test_restore_multiple_paths(tmp_path: pathlib.Path) -> None:
351 root = _init_repo(tmp_path)
352 orig_a = b"# a orig\n"
353 orig_b = b"# b orig\n"
354 _commit_files(root, {"a.py": orig_a, "b.py": orig_b})
355 (root / "a.py").write_bytes(b"# a dirty\n")
356 (root / "b.py").write_bytes(b"# b dirty\n")
357
358 result = _invoke(root, "a.py", "b.py")
359 assert result.exit_code == 0
360 assert (root / "a.py").read_bytes() == orig_a
361 assert (root / "b.py").read_bytes() == orig_b
362
363
364 # ---------------------------------------------------------------------------
365 # Integration — --dry-run
366 # ---------------------------------------------------------------------------
367
368
369 def test_restore_dry_run_no_disk_change(tmp_path: pathlib.Path) -> None:
370 root = _init_repo(tmp_path)
371 _commit_files(root, {"a.py": b"# orig\n"})
372 dirty = b"# dirty\n"
373 (root / "a.py").write_bytes(dirty)
374
375 result = _invoke(root, "--dry-run", "a.py")
376 assert result.exit_code == 0
377 assert (root / "a.py").read_bytes() == dirty
378
379
380 def test_restore_dry_run_no_stage_change(tmp_path: pathlib.Path) -> None:
381 root = _init_repo(tmp_path)
382 _commit_files(root, {"a.py": b"# orig\n"})
383 obj_id = _sha(b"# modified\n")
384 write_object(root, obj_id, b"# modified\n")
385 stage: StagedFileMap = {"a.py": make_entry(obj_id, "M")}
386 write_stage(root, stage)
387
388 _invoke(root, "--dry-run", "--staged", "a.py")
389 stage_after = read_stage(root)
390 assert "a.py" in stage_after # stage unchanged
391
392
393 def test_restore_dry_run_json(tmp_path: pathlib.Path) -> None:
394 root = _init_repo(tmp_path)
395 _commit_files(root, {"a.py": b"# orig\n"})
396 (root / "a.py").write_bytes(b"# dirty\n")
397
398 result = _invoke(root, "--dry-run", "--json", "a.py")
399 assert result.exit_code == 0
400 data = json.loads(result.stdout)
401 assert data["dry_run"] is True
402 assert "a.py" in data.get("restored", []) or len(data.get("paths", [])) >= 1
403
404
405 # ---------------------------------------------------------------------------
406 # Integration — --json
407 # ---------------------------------------------------------------------------
408
409
410 def test_restore_json_output_structure(tmp_path: pathlib.Path) -> None:
411 root = _init_repo(tmp_path)
412 _commit_files(root, {"j.py": b"# j\n"})
413 (root / "j.py").write_bytes(b"# dirty\n")
414
415 result = _invoke(root, "--json", "j.py")
416 assert result.exit_code == 0
417 data = json.loads(result.stdout)
418 assert "restored" in data
419 assert "j.py" in data["restored"]
420 assert data["dry_run"] is False
421
422
423 # ---------------------------------------------------------------------------
424 # Security
425 # ---------------------------------------------------------------------------
426
427
428 def test_restore_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
429 root = _init_repo(tmp_path)
430 _commit_files(root, {"anchor.py": b"# a\n"})
431 result = _invoke(root, "../../../etc/passwd")
432 assert result.exit_code != 0
433
434
435 def test_restore_staged_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
436 root = _init_repo(tmp_path)
437 _commit_files(root, {"anchor.py": b"# a\n"})
438 result = _invoke(root, "--staged", "../../evil.py")
439 assert result.exit_code != 0
440
441
442 # ---------------------------------------------------------------------------
443 # Stress
444 # ---------------------------------------------------------------------------
445
446
447 def test_restore_100_modified_files(tmp_path: pathlib.Path) -> None:
448 """Restore 100 modified files in one invocation."""
449 root = _init_repo(tmp_path)
450 originals = {f"f{i}.py": f"# orig {i}\n".encode() for i in range(100)}
451 _commit_files(root, originals)
452
453 # Dirty all 100
454 for name in originals:
455 (root / name).write_bytes(b"# dirty\n")
456
457 result = _invoke(root, *originals.keys())
458 assert result.exit_code == 0
459 for name, orig_content in originals.items():
460 assert (root / name).read_bytes() == orig_content, f"{name} not restored"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 135 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 144 days ago