gabriel / muse public
test_cmd_mv.py python
506 lines 17.3 KB
Raw
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor ⚠ breaking 29 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 from collections.abc import Mapping
15
16 import datetime
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.ids import hash_commit, hash_snapshot
25 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
26 from muse.core.types import Manifest, blob_id
27 from muse.plugins.code.stage import StagedFileMap, make_entry, read_stage, write_stage
28 from muse.core.paths import heads_dir, muse_dir
29
30 runner = CliRunner()
31
32 _REPO_ID = "mv-test"
33
34
35 # ---------------------------------------------------------------------------
36 # Bootstrap helpers (same pattern as test_cmd_rm)
37 # ---------------------------------------------------------------------------
38
39
40
41
42 _counter = 0
43
44
45 def _init_repo(path: pathlib.Path) -> pathlib.Path:
46 dot_muse = muse_dir(path)
47 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
48 (dot_muse / d).mkdir(parents=True, exist_ok=True)
49 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
50 (dot_muse / "repo.json").write_text(
51 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
52 )
53 return path
54
55
56 def _env(repo: pathlib.Path) -> Mapping[str, str]:
57 return {"MUSE_REPO_ROOT": str(repo)}
58
59
60 def _commit_files(root: pathlib.Path, files: Mapping[str, bytes]) -> str:
61 """Write *files* to disk + object store; create and record a commit."""
62 global _counter
63 _counter += 1
64 manifest: Manifest = {}
65 for rel_path, content in files.items():
66 obj_id = blob_id(content)
67 write_object(root, obj_id, content)
68 manifest[rel_path] = obj_id
69 abs_path = root / rel_path
70 abs_path.parent.mkdir(parents=True, exist_ok=True)
71 abs_path.write_bytes(content)
72 snap_id = hash_snapshot(manifest)
73 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
74 committed_at = datetime.datetime.now(datetime.timezone.utc)
75 commit_id = hash_commit( parent_ids=[],
76 snapshot_id=snap_id,
77 message=f"commit {_counter}",
78 committed_at_iso=committed_at.isoformat(),
79 )
80 write_commit(
81 root,
82 CommitRecord(
83 commit_id=commit_id,
84 branch="main",
85 snapshot_id=snap_id,
86 message=f"commit {_counter}",
87 committed_at=committed_at,
88 ),
89 )
90 (heads_dir(root) / "main").write_text(commit_id, encoding="utf-8")
91 return commit_id
92
93
94 def _invoke(repo: pathlib.Path, *args: str) -> "InvokeResult":
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": blob_id(content)}
140 obj_id = _get_source_object_id("src.py", head_manifest, stage)
141 assert obj_id == blob_id(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": blob_id(old_content)}
151 stage: StagedFileMap = {"src.py": make_entry(blob_id(new_content), "M")}
152 obj_id = _get_source_object_id("src.py", head_manifest, stage)
153 assert obj_id == blob_id(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 = blob_id(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"] == blob_id(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"] == blob_id(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 = blob_id(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/malicious.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
478
479
480 class TestRegisterFlags:
481 def test_default_json_out_is_false(self) -> None:
482 import argparse
483 from muse.cli.commands.mv import register
484 p = argparse.ArgumentParser()
485 subs = p.add_subparsers()
486 register(subs)
487 args = p.parse_args(["mv", "a.py", "b.py"])
488 assert args.json_out is False
489
490 def test_json_flag_sets_json_out(self) -> None:
491 import argparse
492 from muse.cli.commands.mv import register
493 p = argparse.ArgumentParser()
494 subs = p.add_subparsers()
495 register(subs)
496 args = p.parse_args(["mv", "a.py", "b.py", "--json"])
497 assert args.json_out is True
498
499 def test_j_shorthand_sets_json_out(self) -> None:
500 import argparse
501 from muse.cli.commands.mv import register
502 p = argparse.ArgumentParser()
503 subs = p.add_subparsers()
504 register(subs)
505 args = p.parse_args(["mv", "a.py", "b.py", "-j"])
506 assert args.json_out is True
File History 1 commit
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago