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