gabriel / muse public
test_cli_plumbing.py python
746 lines 25.9 KB
dec4604a feat(mwp): replace JSON+base64 wire protocol with MWP binary msgpack Gabriel Cardona <gabriel@tellurstori.com> 13h ago
1 """Tests for all Tier 1 plumbing commands under ``muse plumbing …``.
2
3 Each plumbing command is tested via the Typer CliRunner so tests exercise the
4 full CLI stack including argument parsing, error handling, and JSON output
5 format. All commands are accessed through the ``plumbing`` sub-namespace.
6
7 The ``MUSE_REPO_ROOT`` env-var is used to point repo-discovery at the test
8 fixture without requiring ``os.chdir``.
9 """
10
11 from __future__ import annotations
12
13 import datetime
14 import json
15 import pathlib
16
17 import msgpack
18 import pytest
19 from tests.cli_test_helper import CliRunner
20
21 cli = None # argparse migration — CliRunner ignores this arg
22 from muse.core.errors import ExitCode
23 from muse.core.object_store import write_object
24 from muse.core.pack import build_pack
25 from muse.core.store import (
26 CommitRecord,
27 SnapshotRecord,
28 write_commit,
29 write_snapshot,
30 )
31
32 runner = CliRunner()
33
34 # ---------------------------------------------------------------------------
35 # Helpers
36 # ---------------------------------------------------------------------------
37
38
39 def _init_repo(path: pathlib.Path) -> pathlib.Path:
40 """Create a minimal .muse/ directory structure."""
41 muse = path / ".muse"
42 (muse / "commits").mkdir(parents=True)
43 (muse / "snapshots").mkdir(parents=True)
44 (muse / "objects").mkdir(parents=True)
45 (muse / "refs" / "heads").mkdir(parents=True)
46 muse.joinpath("HEAD").write_text("ref: refs/heads/main")
47 muse.joinpath("repo.json").write_text(
48 json.dumps({"repo_id": "test-repo-id", "domain": "generic"})
49 )
50 return path
51
52
53 def _make_object(repo: pathlib.Path, content: bytes) -> str:
54 import hashlib
55
56 oid = hashlib.sha256(content).hexdigest()
57 write_object(repo, oid, content)
58 return oid
59
60
61 def _make_snapshot(
62 repo: pathlib.Path, snap_id: str, manifest: dict[str, str]
63 ) -> SnapshotRecord:
64 snap = SnapshotRecord(
65 snapshot_id=snap_id,
66 manifest=manifest,
67 created_at=datetime.datetime(2026, 3, 18, tzinfo=datetime.timezone.utc),
68 )
69 write_snapshot(repo, snap)
70 return snap
71
72
73 def _make_commit(
74 repo: pathlib.Path,
75 commit_id: str,
76 snapshot_id: str,
77 *,
78 branch: str = "main",
79 parent_commit_id: str | None = None,
80 message: str = "test commit",
81 ) -> CommitRecord:
82 rec = CommitRecord(
83 commit_id=commit_id,
84 repo_id="test-repo-id",
85 branch=branch,
86 snapshot_id=snapshot_id,
87 message=message,
88 committed_at=datetime.datetime(2026, 3, 18, tzinfo=datetime.timezone.utc),
89 author="tester",
90 parent_commit_id=parent_commit_id,
91 )
92 write_commit(repo, rec)
93 return rec
94
95
96 def _set_head(repo: pathlib.Path, branch: str, commit_id: str) -> None:
97 ref = repo / ".muse" / "refs" / "heads" / branch
98 ref.parent.mkdir(parents=True, exist_ok=True)
99 ref.write_text(commit_id)
100
101
102 def _repo_env(repo: pathlib.Path) -> dict[str, str]:
103 """Return env dict that sets MUSE_REPO_ROOT to the given path."""
104 return {"MUSE_REPO_ROOT": str(repo)}
105
106
107 # ---------------------------------------------------------------------------
108 # hash-object
109 # ---------------------------------------------------------------------------
110
111
112 class TestHashObject:
113 def test_hash_file_json_output(self, tmp_path: pathlib.Path) -> None:
114 f = tmp_path / "test.txt"
115 f.write_bytes(b"hello world")
116 result = runner.invoke(cli, ["plumbing", "hash-object", str(f)])
117 assert result.exit_code == 0, result.output
118 data = json.loads(result.stdout)
119 assert "object_id" in data
120 assert len(data["object_id"]) == 64
121 assert data["stored"] is False
122
123 def test_hash_file_text_format(self, tmp_path: pathlib.Path) -> None:
124 f = tmp_path / "data.bin"
125 f.write_bytes(b"test bytes")
126 result = runner.invoke(
127 cli, ["plumbing", "hash-object", "--format", "text", str(f)]
128 )
129 assert result.exit_code == 0, result.output
130 assert len(result.stdout.strip()) == 64
131
132 def test_hash_and_write(self, tmp_path: pathlib.Path) -> None:
133 repo = _init_repo(tmp_path / "repo")
134 f = repo / "sample.txt"
135 f.write_bytes(b"write me")
136 result = runner.invoke(
137 cli,
138 ["plumbing", "hash-object", "--write", str(f)],
139 env=_repo_env(repo),
140 catch_exceptions=False,
141 )
142 assert result.exit_code == 0, result.output
143 data = json.loads(result.stdout)
144 assert data["stored"] is True
145
146 def test_missing_file_errors(self, tmp_path: pathlib.Path) -> None:
147 result = runner.invoke(cli, ["plumbing", "hash-object", str(tmp_path / "no.txt")])
148 assert result.exit_code == ExitCode.USER_ERROR
149
150 def test_directory_errors(self, tmp_path: pathlib.Path) -> None:
151 result = runner.invoke(cli, ["plumbing", "hash-object", str(tmp_path)])
152 assert result.exit_code == ExitCode.USER_ERROR
153
154
155 # ---------------------------------------------------------------------------
156 # cat-object
157 # ---------------------------------------------------------------------------
158
159
160 class TestCatObject:
161 def test_cat_raw_bytes(self, tmp_path: pathlib.Path) -> None:
162 repo = _init_repo(tmp_path)
163 content = b"raw content data"
164 oid = _make_object(repo, content)
165 result = runner.invoke(
166 cli, ["plumbing", "cat-object", oid],
167 env=_repo_env(repo),
168 catch_exceptions=False,
169 )
170 assert result.exit_code == 0, result.output
171 assert result.stdout_bytes == content
172
173 def test_cat_info_format(self, tmp_path: pathlib.Path) -> None:
174 repo = _init_repo(tmp_path)
175 content = b"info content"
176 oid = _make_object(repo, content)
177 result = runner.invoke(
178 cli, ["plumbing", "cat-object", "--format", "info", oid],
179 env=_repo_env(repo),
180 )
181 assert result.exit_code == 0, result.output
182 data = json.loads(result.stdout)
183 assert data["object_id"] == oid
184 assert data["present"] is True
185 assert data["size_bytes"] == len(content)
186
187 def test_missing_object_errors(self, tmp_path: pathlib.Path) -> None:
188 repo = _init_repo(tmp_path)
189 result = runner.invoke(
190 cli, ["plumbing", "cat-object", "a" * 64],
191 env=_repo_env(repo),
192 )
193 assert result.exit_code == ExitCode.USER_ERROR
194
195 def test_missing_object_info_format(self, tmp_path: pathlib.Path) -> None:
196 repo = _init_repo(tmp_path)
197 oid = "b" * 64
198 result = runner.invoke(
199 cli, ["plumbing", "cat-object", "--format", "info", oid],
200 env=_repo_env(repo),
201 )
202 assert result.exit_code == ExitCode.USER_ERROR
203 data = json.loads(result.stdout)
204 assert data["present"] is False
205
206
207 # ---------------------------------------------------------------------------
208 # rev-parse
209 # ---------------------------------------------------------------------------
210
211
212 class TestRevParse:
213 def test_resolve_branch(self, tmp_path: pathlib.Path) -> None:
214 repo = _init_repo(tmp_path)
215 oid = _make_object(repo, b"data")
216 _make_snapshot(repo, "s" * 64, {"f": oid})
217 _make_commit(repo, "c" * 64, "s" * 64)
218 _set_head(repo, "main", "c" * 64)
219
220 result = runner.invoke(
221 cli, ["plumbing", "rev-parse", "main"],
222 env=_repo_env(repo),
223 )
224 assert result.exit_code == 0, result.output
225 data = json.loads(result.stdout)
226 assert data["commit_id"] == "c" * 64
227 assert data["ref"] == "main"
228
229 def test_resolve_head(self, tmp_path: pathlib.Path) -> None:
230 repo = _init_repo(tmp_path)
231 oid = _make_object(repo, b"data")
232 _make_snapshot(repo, "s" * 64, {"f": oid})
233 _make_commit(repo, "d" * 64, "s" * 64)
234 _set_head(repo, "main", "d" * 64)
235
236 result = runner.invoke(
237 cli, ["plumbing", "rev-parse", "HEAD"],
238 env=_repo_env(repo),
239 )
240 assert result.exit_code == 0, result.output
241 data = json.loads(result.stdout)
242 assert data["commit_id"] == "d" * 64
243
244 def test_resolve_text_format(self, tmp_path: pathlib.Path) -> None:
245 repo = _init_repo(tmp_path)
246 oid = _make_object(repo, b"data")
247 _make_snapshot(repo, "s" * 64, {"f": oid})
248 _make_commit(repo, "e" * 64, "s" * 64)
249 _set_head(repo, "main", "e" * 64)
250
251 result = runner.invoke(
252 cli, ["plumbing", "rev-parse", "--format", "text", "main"],
253 env=_repo_env(repo),
254 )
255 assert result.exit_code == 0, result.output
256 assert result.stdout.strip() == "e" * 64
257
258 def test_unknown_ref_errors(self, tmp_path: pathlib.Path) -> None:
259 repo = _init_repo(tmp_path)
260 result = runner.invoke(
261 cli, ["plumbing", "rev-parse", "nonexistent"],
262 env=_repo_env(repo),
263 )
264 assert result.exit_code == ExitCode.USER_ERROR
265 data = json.loads(result.stdout)
266 assert data["commit_id"] is None
267
268
269 # ---------------------------------------------------------------------------
270 # ls-files
271 # ---------------------------------------------------------------------------
272
273
274 class TestLsFiles:
275 def test_lists_files_json(self, tmp_path: pathlib.Path) -> None:
276 repo = _init_repo(tmp_path)
277 oid = _make_object(repo, b"track data")
278 _make_snapshot(repo, "s" * 64, {"tracks/drums.mid": oid})
279 _make_commit(repo, "f" * 64, "s" * 64)
280 _set_head(repo, "main", "f" * 64)
281
282 result = runner.invoke(
283 cli, ["plumbing", "ls-files"],
284 env=_repo_env(repo),
285 )
286 assert result.exit_code == 0, result.output
287 data = json.loads(result.stdout)
288 assert data["file_count"] == 1
289 assert data["files"][0]["path"] == "tracks/drums.mid"
290 assert data["files"][0]["object_id"] == oid
291
292 def test_lists_files_text(self, tmp_path: pathlib.Path) -> None:
293 repo = _init_repo(tmp_path)
294 oid = _make_object(repo, b"data")
295 _make_snapshot(repo, "s" * 64, {"a.txt": oid})
296 _make_commit(repo, "a" * 64, "s" * 64)
297 _set_head(repo, "main", "a" * 64)
298
299 result = runner.invoke(
300 cli, ["plumbing", "ls-files", "--format", "text"],
301 env=_repo_env(repo),
302 )
303 assert result.exit_code == 0, result.output
304 assert "a.txt" in result.stdout
305
306 def test_with_explicit_commit(self, tmp_path: pathlib.Path) -> None:
307 repo = _init_repo(tmp_path)
308 oid = _make_object(repo, b"data")
309 _make_snapshot(repo, "s" * 64, {"x.mid": oid})
310 _make_commit(repo, "1" * 64, "s" * 64)
311
312 result = runner.invoke(
313 cli, ["plumbing", "ls-files", "--commit", "1" * 64],
314 env=_repo_env(repo),
315 )
316 assert result.exit_code == 0, result.output
317 data = json.loads(result.stdout)
318 assert data["commit_id"] == "1" * 64
319
320 def test_no_commits_errors(self, tmp_path: pathlib.Path) -> None:
321 repo = _init_repo(tmp_path)
322 result = runner.invoke(
323 cli, ["plumbing", "ls-files"],
324 env=_repo_env(repo),
325 )
326 assert result.exit_code == ExitCode.USER_ERROR
327
328
329 # ---------------------------------------------------------------------------
330 # read-commit
331 # ---------------------------------------------------------------------------
332
333
334 class TestReadCommit:
335 def test_reads_commit_json(self, tmp_path: pathlib.Path) -> None:
336 repo = _init_repo(tmp_path)
337 oid = _make_object(repo, b"data")
338 _make_snapshot(repo, "s" * 64, {"f": oid})
339 _make_commit(repo, "2" * 64, "s" * 64, message="my message")
340
341 result = runner.invoke(
342 cli, ["plumbing", "read-commit", "2" * 64],
343 env=_repo_env(repo),
344 catch_exceptions=False,
345 )
346 assert result.exit_code == 0, result.output
347 data = json.loads(result.stdout)
348 assert data["commit_id"] == "2" * 64
349 assert data["message"] == "my message"
350 assert data["snapshot_id"] == "s" * 64
351
352 def test_missing_commit_errors(self, tmp_path: pathlib.Path) -> None:
353 repo = _init_repo(tmp_path)
354 result = runner.invoke(
355 cli, ["plumbing", "read-commit", "z" * 64],
356 env=_repo_env(repo),
357 )
358 assert result.exit_code == ExitCode.USER_ERROR
359 data = json.loads(result.stdout)
360 assert "error" in data
361
362
363 # ---------------------------------------------------------------------------
364 # read-snapshot
365 # ---------------------------------------------------------------------------
366
367
368 class TestReadSnapshot:
369 def test_reads_snapshot_json(self, tmp_path: pathlib.Path) -> None:
370 repo = _init_repo(tmp_path)
371 oid = _make_object(repo, b"snap data")
372 _make_snapshot(repo, "9" * 64, {"track.mid": oid})
373
374 result = runner.invoke(
375 cli, ["plumbing", "read-snapshot", "9" * 64],
376 env=_repo_env(repo),
377 catch_exceptions=False,
378 )
379 assert result.exit_code == 0, result.output
380 data = json.loads(result.stdout)
381 assert data["snapshot_id"] == "9" * 64
382 assert data["file_count"] == 1
383 assert "track.mid" in data["manifest"]
384
385 def test_missing_snapshot_errors(self, tmp_path: pathlib.Path) -> None:
386 repo = _init_repo(tmp_path)
387 result = runner.invoke(
388 cli, ["plumbing", "read-snapshot", "nothere"],
389 env=_repo_env(repo),
390 )
391 assert result.exit_code == ExitCode.USER_ERROR
392
393
394 # ---------------------------------------------------------------------------
395 # commit-tree
396 # ---------------------------------------------------------------------------
397
398
399 class TestCommitTree:
400 def test_creates_commit_from_snapshot(self, tmp_path: pathlib.Path) -> None:
401 import hashlib
402 repo = _init_repo(tmp_path)
403 oid = _make_object(repo, b"content")
404 snap_id = hashlib.sha256(b"snapshot-1").hexdigest()
405 _make_snapshot(repo, snap_id, {"file.txt": oid})
406
407 result = runner.invoke(
408 cli,
409 [
410 "plumbing", "commit-tree",
411 "--snapshot", snap_id,
412 "--message", "plumbing commit",
413 "--author", "bot",
414 ],
415 env=_repo_env(repo),
416 catch_exceptions=False,
417 )
418 assert result.exit_code == 0, result.output
419 data = json.loads(result.stdout)
420 assert "commit_id" in data
421 assert len(data["commit_id"]) == 64
422
423 def test_with_parent(self, tmp_path: pathlib.Path) -> None:
424 import hashlib
425 repo = _init_repo(tmp_path)
426 oid = _make_object(repo, b"data")
427 snap_id_1 = hashlib.sha256(b"snapshot-a").hexdigest()
428 snap_id_2 = hashlib.sha256(b"snapshot-b").hexdigest()
429 parent_id = hashlib.sha256(b"parent-commit").hexdigest()
430 _make_snapshot(repo, snap_id_1, {"a": oid})
431 _make_commit(repo, parent_id, snap_id_1)
432
433 _make_snapshot(repo, snap_id_2, {"b": oid})
434 result = runner.invoke(
435 cli,
436 [
437 "plumbing", "commit-tree",
438 "--snapshot", snap_id_2,
439 "--parent", parent_id,
440 "--message", "child",
441 ],
442 env=_repo_env(repo),
443 catch_exceptions=False,
444 )
445 assert result.exit_code == 0, result.output
446 data = json.loads(result.stdout)
447 assert "commit_id" in data
448
449 def test_missing_snapshot_errors(self, tmp_path: pathlib.Path) -> None:
450 repo = _init_repo(tmp_path)
451 result = runner.invoke(
452 cli,
453 ["plumbing", "commit-tree", "--snapshot", "nosuch"],
454 env=_repo_env(repo),
455 )
456 assert result.exit_code == ExitCode.USER_ERROR
457
458
459 # ---------------------------------------------------------------------------
460 # update-ref
461 # ---------------------------------------------------------------------------
462
463
464 class TestUpdateRef:
465 def test_creates_branch_ref(self, tmp_path: pathlib.Path) -> None:
466 repo = _init_repo(tmp_path)
467 oid = _make_object(repo, b"x")
468 _make_snapshot(repo, "1" * 64, {"x": oid})
469 _make_commit(repo, "3" * 64, "1" * 64)
470
471 result = runner.invoke(
472 cli,
473 ["plumbing", "update-ref", "feature", "3" * 64],
474 env=_repo_env(repo),
475 catch_exceptions=False,
476 )
477 assert result.exit_code == 0, result.output
478 data = json.loads(result.stdout)
479 assert data["branch"] == "feature"
480 assert data["commit_id"] == "3" * 64
481 ref = repo / ".muse" / "refs" / "heads" / "feature"
482 assert ref.read_text() == "3" * 64
483
484 def test_updates_existing_ref(self, tmp_path: pathlib.Path) -> None:
485 repo = _init_repo(tmp_path)
486 oid = _make_object(repo, b"y")
487 _make_snapshot(repo, "1" * 64, {"y": oid})
488 _make_commit(repo, "4" * 64, "1" * 64)
489 _make_commit(repo, "5" * 64, "1" * 64, parent_commit_id="4" * 64)
490 _set_head(repo, "main", "4" * 64)
491
492 result = runner.invoke(
493 cli,
494 ["plumbing", "update-ref", "main", "5" * 64],
495 env=_repo_env(repo),
496 )
497 assert result.exit_code == 0, result.output
498 data = json.loads(result.stdout)
499 assert data["previous"] == "4" * 64
500 assert data["commit_id"] == "5" * 64
501
502 def test_delete_ref(self, tmp_path: pathlib.Path) -> None:
503 repo = _init_repo(tmp_path)
504 _set_head(repo, "todelete", "x" * 64)
505 result = runner.invoke(
506 cli,
507 ["plumbing", "update-ref", "--delete", "todelete"],
508 env=_repo_env(repo),
509 )
510 assert result.exit_code == 0, result.output
511 data = json.loads(result.stdout)
512 assert data["deleted"] is True
513 ref = repo / ".muse" / "refs" / "heads" / "todelete"
514 assert not ref.exists()
515
516 def test_verify_commit_not_found_errors(self, tmp_path: pathlib.Path) -> None:
517 repo = _init_repo(tmp_path)
518 result = runner.invoke(
519 cli,
520 ["plumbing", "update-ref", "main", "0" * 64],
521 env=_repo_env(repo),
522 )
523 assert result.exit_code == ExitCode.USER_ERROR
524
525 def test_no_verify_skips_commit_check(self, tmp_path: pathlib.Path) -> None:
526 repo = _init_repo(tmp_path)
527 result = runner.invoke(
528 cli,
529 ["plumbing", "update-ref", "--no-verify", "feature", "9" * 64],
530 env=_repo_env(repo),
531 )
532 assert result.exit_code == 0, result.output
533 ref = repo / ".muse" / "refs" / "heads" / "feature"
534 assert ref.read_text() == "9" * 64
535
536
537 # ---------------------------------------------------------------------------
538 # commit-graph
539 # ---------------------------------------------------------------------------
540
541
542 class TestCommitGraph:
543 def test_linear_graph(self, tmp_path: pathlib.Path) -> None:
544 repo = _init_repo(tmp_path)
545 oid = _make_object(repo, b"data")
546 _make_snapshot(repo, "1" * 64, {"f": oid})
547 _make_commit(repo, "c1" + "0" * 62, "1" * 64, message="first")
548 _make_commit(
549 repo,
550 "c2" + "0" * 62,
551 "1" * 64,
552 message="second",
553 parent_commit_id="c1" + "0" * 62,
554 )
555 _set_head(repo, "main", "c2" + "0" * 62)
556
557 result = runner.invoke(
558 cli, ["plumbing", "commit-graph"],
559 env=_repo_env(repo),
560 catch_exceptions=False,
561 )
562 assert result.exit_code == 0, result.output
563 data = json.loads(result.stdout)
564 assert data["count"] == 2
565 commit_ids = [c["commit_id"] for c in data["commits"]]
566 assert "c2" + "0" * 62 in commit_ids
567 assert "c1" + "0" * 62 in commit_ids
568
569 def test_text_format(self, tmp_path: pathlib.Path) -> None:
570 repo = _init_repo(tmp_path)
571 oid = _make_object(repo, b"data")
572 _make_snapshot(repo, "1" * 64, {"f": oid})
573 _make_commit(repo, "e1" + "0" * 62, "1" * 64)
574 _set_head(repo, "main", "e1" + "0" * 62)
575
576 result = runner.invoke(
577 cli, ["plumbing", "commit-graph", "--format", "text"],
578 env=_repo_env(repo),
579 )
580 assert result.exit_code == 0, result.output
581 assert "e1" + "0" * 62 in result.stdout
582
583 def test_explicit_tip(self, tmp_path: pathlib.Path) -> None:
584 repo = _init_repo(tmp_path)
585 oid = _make_object(repo, b"data")
586 _make_snapshot(repo, "1" * 64, {"f": oid})
587 _make_commit(repo, "t1" + "0" * 62, "1" * 64)
588
589 result = runner.invoke(
590 cli, ["plumbing", "commit-graph", "--tip", "t1" + "0" * 62],
591 env=_repo_env(repo),
592 )
593 assert result.exit_code == 0, result.output
594 data = json.loads(result.stdout)
595 assert data["tip"] == "t1" + "0" * 62
596
597 def test_no_commits_errors(self, tmp_path: pathlib.Path) -> None:
598 repo = _init_repo(tmp_path)
599 result = runner.invoke(
600 cli, ["plumbing", "commit-graph"],
601 env=_repo_env(repo),
602 )
603 assert result.exit_code == ExitCode.USER_ERROR
604
605
606 # ---------------------------------------------------------------------------
607 # pack-objects
608 # ---------------------------------------------------------------------------
609
610
611 class TestPackObjects:
612 def test_packs_head(self, tmp_path: pathlib.Path) -> None:
613 repo = _init_repo(tmp_path)
614 oid = _make_object(repo, b"pack me")
615 _make_snapshot(repo, "1" * 64, {"f.mid": oid})
616 _make_commit(repo, "p" + "0" * 63, "1" * 64)
617 _set_head(repo, "main", "p" + "0" * 63)
618
619 result = runner.invoke(
620 cli, ["plumbing", "pack-objects", "HEAD"],
621 env=_repo_env(repo),
622 catch_exceptions=False,
623 )
624 assert result.exit_code == 0, result.output
625 data = msgpack.unpackb(result.stdout_bytes, raw=False)
626 assert "commits" in data
627 assert len(data["commits"]) >= 1
628
629 def test_packs_explicit_commit(self, tmp_path: pathlib.Path) -> None:
630 repo = _init_repo(tmp_path)
631 oid = _make_object(repo, b"explicit")
632 _make_snapshot(repo, "1" * 64, {"g": oid})
633 commit_id = "q" + "0" * 63
634 _make_commit(repo, commit_id, "1" * 64)
635
636 result = runner.invoke(
637 cli, ["plumbing", "pack-objects", commit_id],
638 env=_repo_env(repo),
639 catch_exceptions=False,
640 )
641 assert result.exit_code == 0, result.output
642 data = msgpack.unpackb(result.stdout_bytes, raw=False)
643 commit_ids = [c["commit_id"] for c in data["commits"]]
644 assert commit_id in commit_ids
645
646 def test_no_commits_on_head_errors(self, tmp_path: pathlib.Path) -> None:
647 repo = _init_repo(tmp_path)
648 result = runner.invoke(
649 cli, ["plumbing", "pack-objects", "HEAD"],
650 env=_repo_env(repo),
651 )
652 assert result.exit_code == ExitCode.USER_ERROR
653
654
655 # ---------------------------------------------------------------------------
656 # unpack-objects
657 # ---------------------------------------------------------------------------
658
659
660 class TestUnpackObjects:
661 def test_unpacks_valid_bundle(self, tmp_path: pathlib.Path) -> None:
662 source = _init_repo(tmp_path / "src")
663 dest = _init_repo(tmp_path / "dst")
664
665 oid = _make_object(source, b"unpack me")
666 _make_snapshot(source, "1" * 64, {"h.mid": oid})
667 commit_id = "u" + "0" * 63
668 _make_commit(source, commit_id, "1" * 64)
669
670 bundle = build_pack(source, [commit_id])
671 bundle_bytes = msgpack.packb(bundle, use_bin_type=True)
672
673 result = runner.invoke(
674 cli,
675 ["plumbing", "unpack-objects"],
676 input=bundle_bytes,
677 env=_repo_env(dest),
678 catch_exceptions=False,
679 )
680 assert result.exit_code == 0, result.output
681 data = json.loads(result.stdout)
682 assert "objects_written" in data
683 assert data["commits_written"] == 1
684 assert data["objects_written"] == 1
685
686 def test_invalid_msgpack_errors(self, tmp_path: pathlib.Path) -> None:
687 repo = _init_repo(tmp_path)
688 result = runner.invoke(
689 cli,
690 ["plumbing", "unpack-objects"],
691 input=b"\xff\xff NOT VALID MSGPACK",
692 env=_repo_env(repo),
693 )
694 assert result.exit_code == ExitCode.USER_ERROR
695
696 def test_idempotent_unpack(self, tmp_path: pathlib.Path) -> None:
697 repo = _init_repo(tmp_path)
698 oid = _make_object(repo, b"idempotent")
699 _make_snapshot(repo, "1" * 64, {"i.txt": oid})
700 commit_id = "i" + "0" * 63
701 _make_commit(repo, commit_id, "1" * 64)
702
703 bundle = build_pack(repo, [commit_id])
704 bundle_bytes = msgpack.packb(bundle, use_bin_type=True)
705
706 result1 = runner.invoke(
707 cli, ["plumbing", "unpack-objects"],
708 input=bundle_bytes,
709 env=_repo_env(repo),
710 )
711 assert result1.exit_code == 0, result1.output
712
713 result2 = runner.invoke(
714 cli, ["plumbing", "unpack-objects"],
715 input=bundle_bytes,
716 env=_repo_env(repo),
717 )
718 assert result2.exit_code == 0, result2.output
719 data = json.loads(result2.stdout)
720 assert data["objects_written"] == 0
721 assert data["objects_skipped"] == 1
722
723
724 # ---------------------------------------------------------------------------
725 # ls-remote (moved to plumbing namespace)
726 # ---------------------------------------------------------------------------
727
728
729 class TestLsRemote:
730 def test_bare_url_transport_error(self) -> None:
731 """Bare URL to a non-existent server produces exit code INTERNAL_ERROR."""
732 result = runner.invoke(
733 cli,
734 ["plumbing", "ls-remote", "https://localhost:0/no-such-server"],
735 )
736 assert result.exit_code == ExitCode.INTERNAL_ERROR
737
738 def test_non_url_non_remote_errors(self, tmp_path: pathlib.Path) -> None:
739 """A non-URL, non-configured remote name exits with code USER_ERROR."""
740 repo = _init_repo(tmp_path)
741 result = runner.invoke(
742 cli,
743 ["plumbing", "ls-remote", "not-a-url-or-remote"],
744 env=_repo_env(repo),
745 )
746 assert result.exit_code == ExitCode.USER_ERROR