gabriel / muse public
test_cmd_mv.py python
477 lines 16.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Tests for ``muse mv`` — tracked-file move with staging.
2
3 Coverage tiers:
4 - Unit: _resolve_source, _resolve_dest, _get_source_object_id helpers
5 - Integration: basic move (disk + stage), --dry-run, --force, move-into-dir,
6 directory move, staged-only-file move, --json output
7 - End-to-end: full CLI via CliRunner
8 - Security: path traversal in source/dest rejected, outside-repo paths
9 - Edge cases: source not tracked, dest already tracked, dest exists on disk
10 - Stress: 200-file repo, move half
11 """
12
13 from __future__ import annotations
14
15 import datetime
16 import hashlib
17 import json
18 import pathlib
19
20 import pytest
21 from tests.cli_test_helper import CliRunner
22
23 from muse.core.object_store import write_object
24 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
25 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
26 from muse.core._types import Manifest, long_id
27 from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage
28
29 runner = CliRunner()
30
31 _REPO_ID = "mv-test"
32
33
34 # ---------------------------------------------------------------------------
35 # Bootstrap helpers (same pattern as test_cmd_rm)
36 # ---------------------------------------------------------------------------
37
38
39 def _sha(data: bytes) -> str:
40 return long_id(hashlib.sha256(data).hexdigest())
41
42
43 _counter = 0
44
45
46 def _init_repo(path: pathlib.Path) -> pathlib.Path:
47 muse = path / ".muse"
48 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
49 (muse / d).mkdir(parents=True, exist_ok=True)
50 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
51 (muse / "repo.json").write_text(
52 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
53 )
54 return path
55
56
57 def _env(repo: pathlib.Path) -> dict[str, str]:
58 return {"MUSE_REPO_ROOT": str(repo)}
59
60
61 def _commit_files(root: pathlib.Path, files: dict[str, bytes]) -> str:
62 """Write *files* to disk + object store; create and record a commit."""
63 global _counter
64 _counter += 1
65 manifest: Manifest = {}
66 for rel_path, content in files.items():
67 obj_id = _sha(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 [], snap_id, f"commit {_counter}", committed_at.isoformat()
78 )
79 write_commit(
80 root,
81 CommitRecord(
82 commit_id=commit_id,
83 repo_id=_REPO_ID,
84 branch="main",
85 snapshot_id=snap_id,
86 message=f"commit {_counter}",
87 committed_at=committed_at,
88 ),
89 )
90 (root / ".muse" / "refs" / "heads" / "main").write_text(commit_id, encoding="utf-8")
91 return commit_id
92
93
94 def _invoke(repo: pathlib.Path, *args: str) -> object:
95 from muse.cli.app import main as cli
96 return runner.invoke(cli, ["mv", *args], env=_env(repo))
97
98
99 # ---------------------------------------------------------------------------
100 # help
101 # ---------------------------------------------------------------------------
102
103
104 def test_mv_help() -> None:
105 from muse.cli.app import main as cli
106 result = runner.invoke(cli, ["mv", "--help"])
107 assert result.exit_code == 0
108 assert "mv" in result.output
109
110
111 # ---------------------------------------------------------------------------
112 # Unit — internal helpers
113 # ---------------------------------------------------------------------------
114
115
116 def test_resolve_source_returns_relative_posix(tmp_path: pathlib.Path) -> None:
117 from muse.cli.commands.mv import _resolve_path
118 root = _init_repo(tmp_path)
119 _commit_files(root, {"alpha.py": b"# a\n"})
120 rel = _resolve_path(root, "alpha.py")
121 assert rel == "alpha.py"
122
123
124 def test_resolve_path_rejects_traversal(tmp_path: pathlib.Path) -> None:
125 from muse.cli.commands.mv import _resolve_path
126 import sys
127 root = _init_repo(tmp_path)
128 with pytest.raises(SystemExit) as exc_info:
129 _resolve_path(root, "../../../etc/passwd")
130 assert exc_info.value.code != 0
131
132
133 def test_get_source_object_id_from_head(tmp_path: pathlib.Path) -> None:
134 from muse.cli.commands.mv import _get_source_object_id
135 root = _init_repo(tmp_path)
136 content = b"hello world\n"
137 _commit_files(root, {"src.py": content})
138 stage: StagedFileMap = {}
139 head_manifest: Manifest = {"src.py": _sha(content)}
140 obj_id = _get_source_object_id("src.py", head_manifest, stage)
141 assert obj_id == _sha(content)
142
143
144 def test_get_source_object_id_prefers_stage(tmp_path: pathlib.Path) -> None:
145 """Staged object_id takes precedence over HEAD manifest."""
146 from muse.cli.commands.mv import _get_source_object_id
147 root = _init_repo(tmp_path)
148 old_content = b"old\n"
149 new_content = b"new\n"
150 head_manifest: Manifest = {"src.py": _sha(old_content)}
151 stage: StagedFileMap = {"src.py": make_entry(_sha(new_content), "M")}
152 obj_id = _get_source_object_id("src.py", head_manifest, stage)
153 assert obj_id == _sha(new_content)
154
155
156 # ---------------------------------------------------------------------------
157 # Integration — basic move
158 # ---------------------------------------------------------------------------
159
160
161 def test_mv_renames_file_on_disk(tmp_path: pathlib.Path) -> None:
162 root = _init_repo(tmp_path)
163 _commit_files(root, {"alpha.py": b"# alpha\n"})
164
165 result = _invoke(root, "alpha.py", "beta.py")
166 assert result.exit_code == 0
167 assert not (root / "alpha.py").exists()
168 assert (root / "beta.py").exists()
169 assert (root / "beta.py").read_bytes() == b"# alpha\n"
170
171
172 def test_mv_stages_source_as_deleted(tmp_path: pathlib.Path) -> None:
173 root = _init_repo(tmp_path)
174 _commit_files(root, {"alpha.py": b"# a\n"})
175
176 _invoke(root, "alpha.py", "beta.py")
177 stage = read_stage(root)
178 assert "alpha.py" in stage
179 assert stage["alpha.py"]["mode"] == "D"
180
181
182 def test_mv_stages_dest_as_added(tmp_path: pathlib.Path) -> None:
183 root = _init_repo(tmp_path)
184 content = b"# a\n"
185 _commit_files(root, {"alpha.py": content})
186
187 _invoke(root, "alpha.py", "beta.py")
188 stage = read_stage(root)
189 assert "beta.py" in stage
190 assert stage["beta.py"]["mode"] == "A"
191
192
193 def test_mv_object_id_preserved(tmp_path: pathlib.Path) -> None:
194 """The dest entry must share the source's object_id — content unchanged."""
195 root = _init_repo(tmp_path)
196 content = b"# source content\n"
197 obj_id = _sha(content)
198 _commit_files(root, {"alpha.py": content})
199
200 _invoke(root, "alpha.py", "beta.py")
201 stage = read_stage(root)
202 assert stage["beta.py"]["object_id"] == obj_id
203
204
205 def test_mv_exit_code_zero_on_success(tmp_path: pathlib.Path) -> None:
206 root = _init_repo(tmp_path)
207 _commit_files(root, {"a.py": b"# a\n"})
208 result = _invoke(root, "a.py", "b.py")
209 assert result.exit_code == 0
210
211
212 def test_mv_prints_rename_line(tmp_path: pathlib.Path) -> None:
213 root = _init_repo(tmp_path)
214 _commit_files(root, {"a.py": b"# a\n"})
215 result = _invoke(root, "a.py", "b.py")
216 assert "a.py" in result.output
217 assert "b.py" in result.output
218
219
220 # ---------------------------------------------------------------------------
221 # Integration — --json
222 # ---------------------------------------------------------------------------
223
224
225 def test_mv_json_output_structure(tmp_path: pathlib.Path) -> None:
226 root = _init_repo(tmp_path)
227 content = b"# j\n"
228 _commit_files(root, {"j.py": content})
229
230 result = _invoke(root, "--json", "j.py", "k.py")
231 assert result.exit_code == 0
232 data = json.loads(result.stdout)
233 assert data["source"] == "j.py"
234 assert data["dest"] == "k.py"
235 assert data["status"] in ("moved", "dry_run")
236 assert data["dry_run"] is False
237 assert data["object_id"] == _sha(content)
238
239
240 # ---------------------------------------------------------------------------
241 # Integration — --dry-run
242 # ---------------------------------------------------------------------------
243
244
245 def test_mv_dry_run_no_disk_change(tmp_path: pathlib.Path) -> None:
246 root = _init_repo(tmp_path)
247 _commit_files(root, {"alpha.py": b"# a\n"})
248
249 result = _invoke(root, "--dry-run", "alpha.py", "beta.py")
250 assert result.exit_code == 0
251 assert (root / "alpha.py").exists()
252 assert not (root / "beta.py").exists()
253
254
255 def test_mv_dry_run_no_stage_change(tmp_path: pathlib.Path) -> None:
256 root = _init_repo(tmp_path)
257 _commit_files(root, {"alpha.py": b"# a\n"})
258
259 _invoke(root, "--dry-run", "alpha.py", "beta.py")
260 stage = read_stage(root)
261 assert "alpha.py" not in stage
262 assert "beta.py" not in stage
263
264
265 def test_mv_dry_run_json(tmp_path: pathlib.Path) -> None:
266 root = _init_repo(tmp_path)
267 _commit_files(root, {"alpha.py": b"# a\n"})
268
269 result = _invoke(root, "--dry-run", "--json", "alpha.py", "beta.py")
270 assert result.exit_code == 0
271 data = json.loads(result.stdout)
272 assert data["dry_run"] is True
273 assert data["status"] == "dry_run"
274
275
276 # ---------------------------------------------------------------------------
277 # Integration — error conditions
278 # ---------------------------------------------------------------------------
279
280
281 def test_mv_source_not_tracked_exits_nonzero(tmp_path: pathlib.Path) -> None:
282 root = _init_repo(tmp_path)
283 _commit_files(root, {"other.py": b"# o\n"})
284 (root / "ghost.py").write_text("# untracked\n")
285
286 result = _invoke(root, "ghost.py", "dest.py")
287 assert result.exit_code != 0
288
289
290 def test_mv_source_not_on_disk_exits_nonzero(tmp_path: pathlib.Path) -> None:
291 """Source tracked in HEAD but deleted from disk → error unless --force."""
292 root = _init_repo(tmp_path)
293 _commit_files(root, {"missing.py": b"# m\n"})
294 (root / "missing.py").unlink()
295
296 result = _invoke(root, "missing.py", "dest.py")
297 assert result.exit_code != 0
298
299
300 def test_mv_dest_already_tracked_exits_nonzero(tmp_path: pathlib.Path) -> None:
301 root = _init_repo(tmp_path)
302 _commit_files(root, {"src.py": b"# src\n", "dst.py": b"# dst\n"})
303
304 result = _invoke(root, "src.py", "dst.py")
305 assert result.exit_code != 0
306
307
308 def test_mv_dest_exists_on_disk_exits_nonzero(tmp_path: pathlib.Path) -> None:
309 root = _init_repo(tmp_path)
310 _commit_files(root, {"src.py": b"# src\n"})
311 (root / "dst.py").write_text("# untracked but on disk\n")
312
313 result = _invoke(root, "src.py", "dst.py")
314 assert result.exit_code != 0
315
316
317 # ---------------------------------------------------------------------------
318 # Integration — --force
319 # ---------------------------------------------------------------------------
320
321
322 def test_mv_force_allows_tracked_dest(tmp_path: pathlib.Path) -> None:
323 root = _init_repo(tmp_path)
324 _commit_files(root, {"src.py": b"# src\n", "dst.py": b"# dst\n"})
325
326 result = _invoke(root, "--force", "src.py", "dst.py")
327 assert result.exit_code == 0
328 stage = read_stage(root)
329 assert "src.py" in stage and stage["src.py"]["mode"] == "D"
330 assert "dst.py" in stage and stage["dst.py"]["mode"] in ("A", "M")
331
332
333 def test_mv_force_allows_untracked_dest_on_disk(tmp_path: pathlib.Path) -> None:
334 root = _init_repo(tmp_path)
335 _commit_files(root, {"src.py": b"# src\n"})
336 (root / "dst.py").write_text("# untracked\n")
337
338 result = _invoke(root, "--force", "src.py", "dst.py")
339 assert result.exit_code == 0
340 assert not (root / "src.py").exists()
341 assert (root / "dst.py").read_bytes() == b"# src\n"
342
343
344 # ---------------------------------------------------------------------------
345 # Integration — move into directory
346 # ---------------------------------------------------------------------------
347
348
349 def test_mv_into_existing_directory(tmp_path: pathlib.Path) -> None:
350 """mv file.py dir/ moves to dir/file.py when dir/ exists."""
351 root = _init_repo(tmp_path)
352 _commit_files(root, {"alpha.py": b"# a\n"})
353 (root / "subdir").mkdir()
354
355 result = _invoke(root, "alpha.py", "subdir/")
356 assert result.exit_code == 0
357 assert (root / "subdir" / "alpha.py").exists()
358 stage = read_stage(root)
359 assert "subdir/alpha.py" in stage
360 assert stage["subdir/alpha.py"]["mode"] == "A"
361 assert stage["alpha.py"]["mode"] == "D"
362
363
364 def test_mv_into_directory_preserves_object_id(tmp_path: pathlib.Path) -> None:
365 root = _init_repo(tmp_path)
366 content = b"# content\n"
367 _commit_files(root, {"f.py": content})
368 (root / "pkg").mkdir()
369
370 _invoke(root, "f.py", "pkg/")
371 stage = read_stage(root)
372 assert stage["pkg/f.py"]["object_id"] == _sha(content)
373
374
375 # ---------------------------------------------------------------------------
376 # Integration — staged-only file (never committed)
377 # ---------------------------------------------------------------------------
378
379
380 def test_mv_staged_only_source_updates_stage(tmp_path: pathlib.Path) -> None:
381 """A file staged as 'A' (never committed) is moved: stage entry replaced."""
382 root = _init_repo(tmp_path)
383 # Repo has at least one commit so HEAD exists
384 _commit_files(root, {"anchor.py": b"# anchor\n"})
385 # Stage a new file that has never been committed
386 content = b"# new file\n"
387 obj_id = _sha(content)
388 write_object(root, obj_id, content)
389 (root / "new.py").write_bytes(content)
390 stage = read_stage(root)
391 stage["new.py"] = make_entry(obj_id, "A")
392 write_stage(root, stage)
393
394 result = _invoke(root, "new.py", "renamed.py")
395 assert result.exit_code == 0
396 stage_after = read_stage(root)
397 # "new.py" must be gone from stage (was never committed → no "D" entry)
398 assert "new.py" not in stage_after
399 assert "renamed.py" in stage_after
400 assert stage_after["renamed.py"]["mode"] == "A"
401 assert stage_after["renamed.py"]["object_id"] == obj_id
402
403
404 # ---------------------------------------------------------------------------
405 # Integration — subdirectory paths
406 # ---------------------------------------------------------------------------
407
408
409 def test_mv_across_subdirectories(tmp_path: pathlib.Path) -> None:
410 root = _init_repo(tmp_path)
411 (root / "src").mkdir()
412 (root / "lib").mkdir()
413 _commit_files(root, {"src/module.py": b"# m\n"})
414
415 result = _invoke(root, "src/module.py", "lib/module.py")
416 assert result.exit_code == 0
417 assert not (root / "src" / "module.py").exists()
418 assert (root / "lib" / "module.py").exists()
419 stage = read_stage(root)
420 assert stage["src/module.py"]["mode"] == "D"
421 assert stage["lib/module.py"]["mode"] == "A"
422
423
424 # ---------------------------------------------------------------------------
425 # Security
426 # ---------------------------------------------------------------------------
427
428
429 def test_mv_source_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
430 root = _init_repo(tmp_path)
431 _commit_files(root, {"anchor.py": b"# a\n"})
432
433 result = _invoke(root, "../../../etc/passwd", "dest.py")
434 assert result.exit_code != 0
435
436
437 def test_mv_dest_path_traversal_rejected(tmp_path: pathlib.Path) -> None:
438 root = _init_repo(tmp_path)
439 _commit_files(root, {"src.py": b"# s\n"})
440
441 result = _invoke(root, "src.py", "../../../tmp/evil.py")
442 assert result.exit_code != 0
443
444
445 def test_mv_outside_repo_source_rejected(tmp_path: pathlib.Path) -> None:
446 root = _init_repo(tmp_path / "repo")
447 _commit_files(root, {"anchor.py": b"# a\n"})
448 outside = tmp_path / "outside.py"
449 outside.write_text("# outside\n")
450
451 result = _invoke(root, str(outside), "dest.py")
452 assert result.exit_code != 0
453
454
455 # ---------------------------------------------------------------------------
456 # Stress
457 # ---------------------------------------------------------------------------
458
459
460 def test_mv_stress_200_files_move_half(tmp_path: pathlib.Path) -> None:
461 """Move 100 of 200 tracked files; all stage entries must be consistent."""
462 root = _init_repo(tmp_path)
463 files = {f"file_{i}.py": f"# {i}\n".encode() for i in range(200)}
464 _commit_files(root, files)
465
466 (root / "dest").mkdir()
467 for i in range(0, 200, 2): # move even-numbered files
468 result = _invoke(root, f"file_{i}.py", f"dest/file_{i}.py")
469 assert result.exit_code == 0, f"Move failed for file_{i}.py: {result.output}"
470
471 stage = read_stage(root)
472 for i in range(0, 200, 2):
473 assert stage[f"file_{i}.py"]["mode"] == "D"
474 assert stage[f"dest/file_{i}.py"]["mode"] == "A"
475 # Odd-numbered files untouched
476 for i in range(1, 200, 2):
477 assert f"file_{i}.py" not in stage
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago