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