gabriel / muse public
test_branch_supercharge.py python
789 lines 31.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Supercharge tests for ``muse branch``.
2
3 Gaps filled versus the baseline test_cmd_branch.py
4 ---------------------------------------------------
5 1. ``committed_at`` field present in list --json schema (baseline missed it)
6 2. ``--sort committeddate`` actual ordering (baseline only checked exit 0)
7 3. ``-r`` remote-tracking listing E2E
8 4. ``-a`` combined local + remote listing E2E
9 5. ``-dr`` remote-tracking ref deletion — success path
10 6. ``-vv`` upstream shown in text output
11 7. Diamond-merge DAG correctness for ``--merged``
12 8. Data integrity: empty parent dirs cleaned after nested branch delete
13 9. Rename into nested path creates parent dirs
14 10. Force-rename/copy leaves destination at correct tip
15 11. JSON error schemas for delete/rename/copy operations
16 12. Performance: ``--sort committeddate`` with 50 branches under 3 s
17 13. Security: ANSI injection in ``--merged`` / ``--no-merged`` / ``--contains``
18 14. Docstring coverage for all public helpers
19
20 Test categories
21 ---------------
22 - unit : _cleanup_empty_dirs, _ref_file, _list_remotes
23 - integration : remote ops (-r/-a/-dr), committeddate ordering, DAG, -vv
24 - e2e : full CLI round-trips for new scenarios
25 - security : ANSI in filter flags, error output sanitisation
26 - data_integrity: empty-dir cleanup, atomic rename into deep paths, force overwrites
27 - performance : --sort committeddate with 50 branches
28 - docstrings : public helper docstring coverage
29 """
30
31 from __future__ import annotations
32 from collections.abc import Mapping
33
34 import json
35 import os
36 import pathlib
37 import time
38
39 import pytest
40
41 from tests.cli_test_helper import CliRunner, InvokeResult
42 from muse.core.store import get_head_commit_id, read_current_branch
43 from muse.core.types import long_id
44 from muse.core.paths import config_toml_path, heads_dir, muse_dir, remotes_dir
45
46 runner = CliRunner()
47
48 type _AnyObj = object
49
50
51 # ---------------------------------------------------------------------------
52 # Helpers
53 # ---------------------------------------------------------------------------
54
55
56 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
57 saved = os.getcwd()
58 try:
59 os.chdir(repo)
60 return runner.invoke(None, args)
61 finally:
62 os.chdir(saved)
63
64
65 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
66 return _invoke(repo, ["branch", *extra])
67
68
69 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
70 return _invoke(repo, ["commit", *extra])
71
72
73 @pytest.fixture()
74 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
75 """Initialised repo with one commit on ``main``."""
76 saved = os.getcwd()
77 try:
78 os.chdir(tmp_path)
79 runner.invoke(None, ["init"])
80 finally:
81 os.chdir(saved)
82 (tmp_path / "a.py").write_text("x = 1\n")
83 _commit(tmp_path, "-m", "initial")
84 return tmp_path
85
86
87 @pytest.fixture()
88 def two_commit_repo(repo: pathlib.Path) -> pathlib.Path:
89 (repo / "b.py").write_text("y = 2\n")
90 _commit(repo, "-m", "second")
91 return repo
92
93
94 def _first_json(result: InvokeResult) -> Mapping[str, object]:
95 """Extract the first JSON object from mixed stdout+stderr output."""
96 for line in result.output.splitlines():
97 stripped = line.strip()
98 if stripped.startswith("{"):
99 return json.loads(stripped)
100 raise ValueError(f"No JSON object in output:\n{result.output!r}")
101
102
103 def _make_remote_ref(
104 repo: pathlib.Path, remote: str, branch: str, commit_id: str
105 ) -> None:
106 """Write a local remote-tracking ref to simulate a previous push."""
107 ref_dir = remotes_dir(repo) / remote
108 ref_file = ref_dir / branch
109 ref_file.parent.mkdir(parents=True, exist_ok=True)
110 ref_file.write_text(commit_id + "\n", encoding="utf-8")
111
112
113 # ---------------------------------------------------------------------------
114 # Unit: _ref_file
115 # ---------------------------------------------------------------------------
116
117
118 class TestRefFile:
119 def test_simple_branch(self, tmp_path: pathlib.Path) -> None:
120 from muse.cli.commands.branch import _ref_file
121 p = _ref_file(tmp_path, "main")
122 assert p == heads_dir(tmp_path) / "main"
123
124 def test_nested_branch(self, tmp_path: pathlib.Path) -> None:
125 from muse.cli.commands.branch import _ref_file
126 p = _ref_file(tmp_path, "feat/sub/task")
127 assert p == heads_dir(tmp_path) / "feat" / "sub" / "task"
128
129 def test_returns_pathlib_path(self, tmp_path: pathlib.Path) -> None:
130 from muse.cli.commands.branch import _ref_file
131 p = _ref_file(tmp_path, "dev")
132 assert isinstance(p, pathlib.Path)
133
134
135 # ---------------------------------------------------------------------------
136 # Unit: _cleanup_empty_dirs
137 # ---------------------------------------------------------------------------
138
139
140 class TestCleanupEmptyDirs:
141 def test_removes_empty_parent_dir(self, tmp_path: pathlib.Path) -> None:
142 from muse.cli.commands.branch import _cleanup_empty_dirs
143 heads = tmp_path / "heads"
144 ref = heads / "feat" / "task"
145 ref.parent.mkdir(parents=True)
146 ref.write_text("")
147 ref.unlink()
148 _cleanup_empty_dirs(ref, heads)
149 assert not (heads / "feat").exists()
150
151 def test_stops_at_heads_dir(self, tmp_path: pathlib.Path) -> None:
152 from muse.cli.commands.branch import _cleanup_empty_dirs
153 heads = tmp_path / "heads"
154 heads.mkdir()
155 ref = heads / "main"
156 ref.write_text("")
157 ref.unlink()
158 _cleanup_empty_dirs(ref, heads)
159 assert heads.exists()
160
161 def test_leaves_non_empty_parent(self, tmp_path: pathlib.Path) -> None:
162 from muse.cli.commands.branch import _cleanup_empty_dirs
163 heads = tmp_path / "heads"
164 ref_a = heads / "feat" / "a"
165 ref_b = heads / "feat" / "b"
166 ref_a.parent.mkdir(parents=True)
167 ref_a.write_text("")
168 ref_b.write_text("")
169 ref_a.unlink()
170 _cleanup_empty_dirs(ref_a, heads)
171 # feat/ still has b, so it must not be removed
172 assert (heads / "feat").exists()
173 assert (heads / "feat" / "b").exists()
174
175 def test_removes_multiple_levels(self, tmp_path: pathlib.Path) -> None:
176 from muse.cli.commands.branch import _cleanup_empty_dirs
177 heads = tmp_path / "heads"
178 ref = heads / "a" / "b" / "c"
179 ref.parent.mkdir(parents=True)
180 ref.write_text("")
181 ref.unlink()
182 _cleanup_empty_dirs(ref, heads)
183 assert not (heads / "a").exists()
184
185
186 # ---------------------------------------------------------------------------
187 # Unit: _list_remotes
188 # ---------------------------------------------------------------------------
189
190
191 class TestListRemotes:
192 def test_empty_when_no_remotes_dir(self, tmp_path: pathlib.Path) -> None:
193 from muse.cli.commands.branch import _list_remotes
194 muse_dir(tmp_path).mkdir()
195 assert _list_remotes(tmp_path) == []
196
197 def test_lists_single_remote(self, tmp_path: pathlib.Path) -> None:
198 from muse.cli.commands.branch import _list_remotes
199 ref = remotes_dir(tmp_path) / "origin" / "main"
200 ref.parent.mkdir(parents=True)
201 ref.write_text(long_id("a" * 64))
202 remotes = _list_remotes(tmp_path)
203 assert "origin/main" in remotes
204
205 def test_lists_nested_remote_branch(self, tmp_path: pathlib.Path) -> None:
206 from muse.cli.commands.branch import _list_remotes
207 ref = remotes_dir(tmp_path) / "origin" / "feat" / "task"
208 ref.parent.mkdir(parents=True)
209 ref.write_text(long_id("b" * 64))
210 remotes = _list_remotes(tmp_path)
211 assert "origin/feat/task" in remotes
212
213 def test_skips_hidden_files(self, tmp_path: pathlib.Path) -> None:
214 from muse.cli.commands.branch import _list_remotes
215 ref_dir = remotes_dir(tmp_path) / "origin"
216 ref_dir.mkdir(parents=True)
217 (ref_dir / ".hidden").write_text("ignored")
218 (ref_dir / "main").write_text(long_id("c" * 64))
219 remotes = _list_remotes(tmp_path)
220 assert all(not r.endswith(".hidden") for r in remotes)
221
222 def test_sorted_output(self, tmp_path: pathlib.Path) -> None:
223 from muse.cli.commands.branch import _list_remotes
224 for name in ("z-branch", "a-branch", "m-branch"):
225 ref = remotes_dir(tmp_path) / "origin" / name
226 ref.parent.mkdir(parents=True, exist_ok=True)
227 ref.write_text(long_id("d" * 64))
228 remotes = _list_remotes(tmp_path)
229 assert remotes == sorted(remotes)
230
231
232 # ---------------------------------------------------------------------------
233 # Integration: JSON schema — committed_at field (gap in baseline)
234 # ---------------------------------------------------------------------------
235
236
237 class TestListJsonCommittedAt:
238 """committed_at must be present in the listing JSON schema."""
239
240 def test_committed_at_present_in_schema(self, repo: pathlib.Path) -> None:
241 result = _branch(repo, "--json")
242 data = json.loads(result.output)
243 assert len(data) >= 1
244 assert "committed_at" in data[0], (
245 "committed_at missing from branch --json output"
246 )
247
248 def test_committed_at_is_iso8601(self, repo: pathlib.Path) -> None:
249 import datetime
250 result = _branch(repo, "--json")
251 data = json.loads(result.output)
252 main = next(b for b in data if b["name"] == "main")
253 ts = main["committed_at"]
254 assert ts is not None
255 # Must parse as ISO 8601
256 datetime.datetime.fromisoformat(ts)
257
258 def test_committed_at_null_for_empty_branch(self, repo: pathlib.Path) -> None:
259 """Branches pointing at no commit (empty branch) get null committed_at."""
260 # Force an empty branch by writing an empty ref file directly
261 ref = heads_dir(repo) / "empty-branch"
262 ref.write_text("")
263 result = _branch(repo, "--json")
264 data = json.loads(result.output)
265 eb = next((b for b in data if b["name"] == "empty-branch"), None)
266 assert eb is not None
267 assert eb["committed_at"] is None
268
269 def test_committed_at_schema_complete(self, repo: pathlib.Path) -> None:
270 result = _branch(repo, "--json")
271 data = json.loads(result.output)
272 required = {"name", "current", "commit_id", "committed_at",
273 "last_message", "upstream"}
274 missing = required - set(data[0].keys())
275 assert not missing, f"branch --json missing fields: {missing}"
276
277
278 # ---------------------------------------------------------------------------
279 # Integration: --sort committeddate actual ordering
280 # ---------------------------------------------------------------------------
281
282
283 class TestSortCommittedDateOrdering:
284 """--sort committeddate must emit branches newest-first."""
285
286 def test_newer_branch_first(self, repo: pathlib.Path) -> None:
287 # Create a branch at initial commit, then add another commit on main
288 _branch(repo, "older-branch")
289 (repo / "c.py").write_text("c=3\n")
290 _commit(repo, "-m", "newer commit")
291 _branch(repo, "newer-branch")
292
293 result = _branch(repo, "--sort", "committeddate", "--json")
294 assert result.exit_code == 0
295 data = json.loads(result.output)
296 names = [b["name"] for b in data]
297 assert names.index("newer-branch") < names.index("older-branch"), (
298 f"newer-branch should sort before older-branch; got order: {names}"
299 )
300
301 def test_timestamp_order_consistent_on_ties(self, repo: pathlib.Path) -> None:
302 """Branches at the same commit produce a stable (name-secondary) order."""
303 for name in ("z-same", "a-same", "m-same"):
304 _branch(repo, name)
305 result = _branch(repo, "--sort", "committeddate", "--json")
306 data = json.loads(result.output)
307 assert result.exit_code == 0
308 assert len(data) >= 4
309
310 def test_empty_branches_sorted_last(self, repo: pathlib.Path) -> None:
311 """Branches with no commit (committed_at=null) come after dated branches."""
312 _branch(repo, "real-commit-branch")
313 # Write an empty ref manually
314 (heads_dir(repo) / "no-commit").write_text("")
315 result = _branch(repo, "--sort", "committeddate", "--json")
316 data = json.loads(result.output)
317 names_with_ts = [b["name"] for b in data if b.get("committed_at")]
318 names_without_ts = [b["name"] for b in data if not b.get("committed_at")]
319 if names_with_ts and names_without_ts:
320 last_with_ts = data.index(next(b for b in data if b["name"] == names_with_ts[-1]))
321 first_without_ts = data.index(next(b for b in data if b["name"] == names_without_ts[0]))
322 assert last_with_ts < first_without_ts, (
323 "Branches with no commit should sort after dated branches"
324 )
325
326
327 # ---------------------------------------------------------------------------
328 # Integration: remote-tracking branches (-r / -a / -dr)
329 # ---------------------------------------------------------------------------
330
331
332 class TestRemoteTrackingBranches:
333 def test_list_r_shows_only_remotes(self, repo: pathlib.Path) -> None:
334 cid = get_head_commit_id(repo, "main")
335 _make_remote_ref(repo, "origin", "main", cid)
336 result = _branch(repo, "-r", "--json")
337 assert result.exit_code == 0
338 data = json.loads(result.output)
339 names = [b["name"] for b in data]
340 # Remote entries are prefixed with remotes/
341 assert all(n.startswith("remotes/") for n in names), (
342 f"-r listing must only contain remote entries; got: {names}"
343 )
344 assert "remotes/origin/main" in names
345
346 def test_list_r_excludes_local_branches(self, repo: pathlib.Path) -> None:
347 cid = get_head_commit_id(repo, "main")
348 _make_remote_ref(repo, "origin", "main", cid)
349 _branch(repo, "local-only")
350 result = _branch(repo, "-r", "--json")
351 data = json.loads(result.output)
352 names = [b["name"] for b in data]
353 assert "local-only" not in names
354
355 def test_list_a_includes_both(self, repo: pathlib.Path) -> None:
356 cid = get_head_commit_id(repo, "main")
357 _make_remote_ref(repo, "origin", "dev", cid)
358 _branch(repo, "local-feat")
359 result = _branch(repo, "-a", "--json")
360 assert result.exit_code == 0
361 data = json.loads(result.output)
362 names = [b["name"] for b in data]
363 assert "main" in names
364 assert "local-feat" in names
365 assert "remotes/origin/dev" in names
366
367 def test_list_r_empty_when_no_remotes(self, repo: pathlib.Path) -> None:
368 result = _branch(repo, "-r", "--json")
369 assert result.exit_code == 0
370 assert json.loads(result.output) == []
371
372 def test_list_r_nested_remote_branch(self, repo: pathlib.Path) -> None:
373 cid = get_head_commit_id(repo, "main")
374 _make_remote_ref(repo, "origin", "feat/task", cid)
375 result = _branch(repo, "-r", "--json")
376 data = json.loads(result.output)
377 names = [b["name"] for b in data]
378 assert "remotes/origin/feat/task" in names
379
380 def test_list_a_sorted_by_name(self, repo: pathlib.Path) -> None:
381 cid = get_head_commit_id(repo, "main")
382 _make_remote_ref(repo, "origin", "z-remote", cid)
383 _make_remote_ref(repo, "origin", "a-remote", cid)
384 _branch(repo, "m-local")
385 result = _branch(repo, "-a", "--json")
386 data = json.loads(result.output)
387 names = [b["name"] for b in data]
388 # Local branches come first (alphabetically), then remotes/
389 local_names = [n for n in names if not n.startswith("remotes/")]
390 assert local_names == sorted(local_names)
391
392 def test_dr_deletes_remote_tracking_ref(self, repo: pathlib.Path) -> None:
393 cid = get_head_commit_id(repo, "main")
394 _make_remote_ref(repo, "origin", "stale", cid)
395 # Verify it's visible
396 before = json.loads(_branch(repo, "-r", "--json").output)
397 assert any(b["name"] == "remotes/origin/stale" for b in before)
398 # Delete it
399 result = _branch(repo, "-d", "-r", "origin/stale")
400 assert result.exit_code == 0
401 # Gone now
402 after = json.loads(_branch(repo, "-r", "--json").output)
403 assert not any(b["name"] == "remotes/origin/stale" for b in after)
404
405 def test_dr_json_schema(self, repo: pathlib.Path) -> None:
406 cid = get_head_commit_id(repo, "main")
407 _make_remote_ref(repo, "origin", "old", cid)
408 result = _branch(repo, "-d", "-r", "origin/old", "--json")
409 assert result.exit_code == 0
410 data = json.loads(result.output)
411 assert data["action"] == "deleted_remote_tracking"
412 assert data["remote"] == "origin"
413 assert data["branch"] == "old"
414
415 def test_dr_remotes_prefix_accepted(self, repo: pathlib.Path) -> None:
416 """remotes/origin/branch spelling accepted in addition to origin/branch."""
417 cid = get_head_commit_id(repo, "main")
418 _make_remote_ref(repo, "origin", "with-prefix", cid)
419 result = _branch(repo, "-d", "-r", "remotes/origin/with-prefix")
420 assert result.exit_code == 0
421
422 def test_dr_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
423 result = _branch(repo, "-d", "-r", "origin/ghost")
424 assert result.exit_code == 1
425
426 def test_dr_no_slash_exits_1(self, repo: pathlib.Path) -> None:
427 result = _branch(repo, "-d", "-r", "justaname")
428 assert result.exit_code == 1
429
430
431 # ---------------------------------------------------------------------------
432 # Integration: -vv upstream display
433 # ---------------------------------------------------------------------------
434
435
436 class TestVerboseUpstream:
437 def _set_upstream(self, repo: pathlib.Path, branch: str,
438 remote: str, remote_branch: str) -> None:
439 config_path = config_toml_path(repo)
440 existing = config_path.read_text() if config_path.exists() else ""
441 existing += (
442 f'\n[branch."{branch}"]\n'
443 f'remote = "{remote}"\n'
444 f'merge = "refs/heads/{remote_branch}"\n'
445 )
446 config_path.write_text(existing)
447
448 def test_vv_shows_upstream(self, repo: pathlib.Path) -> None:
449 self._set_upstream(repo, "main", "origin", "main")
450 result = _branch(repo, "-vv")
451 assert result.exit_code == 0
452 assert "origin/main" in result.output
453
454 def test_vv_upstream_in_brackets(self, repo: pathlib.Path) -> None:
455 self._set_upstream(repo, "main", "origin", "main")
456 result = _branch(repo, "-vv")
457 assert "[origin/main]" in result.output
458
459 def test_v_does_not_show_upstream(self, repo: pathlib.Path) -> None:
460 self._set_upstream(repo, "main", "origin", "main")
461 result = _branch(repo, "-v")
462 # -v shows commit SHA + message but NOT upstream brackets
463 assert "[origin/main]" not in result.output
464
465 def test_vv_no_upstream_no_brackets(self, repo: pathlib.Path) -> None:
466 result = _branch(repo, "-vv")
467 assert "[" not in result.output
468
469
470 # ---------------------------------------------------------------------------
471 # Integration: Diamond-merge DAG correctness for --merged
472 # ---------------------------------------------------------------------------
473
474
475 class TestDiamondMergeDag:
476 """--merged must handle merge commits with two parents (parent2_commit_id)."""
477
478 def test_merged_branch_included_after_diamond_merge(
479 self, repo: pathlib.Path
480 ) -> None:
481 """
482 Build diamond: main ← feat-a, main ← feat-b, then merge feat-a into feat-b.
483 After merging feat-a into main via feat-b, --merged should include feat-a.
484
485 main ── C1
486 \\
487 feat-a ── C2
488 \\
489 main (merged feat-a) ── C3
490 """
491 # Create and diverge feat-a
492 _branch(repo, "feat-a")
493 _invoke(repo, ["checkout", "feat-a"])
494 (repo / "fa.py").write_text("fa=1\n")
495 _commit(repo, "-m", "feat-a commit")
496 _invoke(repo, ["checkout", "main"])
497 # Merge feat-a into main
498 _invoke(repo, ["merge", "feat-a"])
499 # Now --merged on main should include feat-a
500 result = _branch(repo, "--merged", "--json")
501 assert result.exit_code == 0
502 data = json.loads(result.output)
503 names = [b["name"] for b in data]
504 assert "feat-a" in names, f"feat-a should be merged into main; got: {names}"
505
506 def test_unmerged_sibling_excluded_from_diamond(
507 self, repo: pathlib.Path
508 ) -> None:
509 """Two branches from same point; merging one doesn't include the other."""
510 _branch(repo, "merged-branch")
511 _branch(repo, "unmerged-branch")
512
513 _invoke(repo, ["checkout", "merged-branch"])
514 (repo / "mb.py").write_text("mb=1\n")
515 _commit(repo, "-m", "merged commit")
516
517 _invoke(repo, ["checkout", "unmerged-branch"])
518 (repo / "ub.py").write_text("ub=1\n")
519 _commit(repo, "-m", "unmerged commit")
520
521 _invoke(repo, ["checkout", "main"])
522 _invoke(repo, ["merge", "merged-branch"])
523
524 result = _branch(repo, "--merged", "--json")
525 data = json.loads(result.output)
526 names = [b["name"] for b in data]
527 assert "merged-branch" in names
528 assert "unmerged-branch" not in names
529
530
531 # ---------------------------------------------------------------------------
532 # Data integrity: nested branch cleanup + deep rename
533 # ---------------------------------------------------------------------------
534
535
536 class TestDataIntegrityNested:
537 def test_delete_nested_cleans_parent_dirs(self, repo: pathlib.Path) -> None:
538 """Deleting feat/sub/task must remove the now-empty feat/sub/ and feat/ dirs."""
539 _branch(repo, "feat/sub/task")
540 result = _branch(repo, "-D", "feat/sub/task")
541 assert result.exit_code == 0
542 heads = heads_dir(repo)
543 assert not (heads / "feat").exists(), (
544 "feat/ directory should be removed after deleting feat/sub/task"
545 )
546
547 def test_delete_nested_keeps_sibling_dir(self, repo: pathlib.Path) -> None:
548 """Deleting one nested branch must not remove a sibling."""
549 _branch(repo, "feat/sub/a")
550 _branch(repo, "feat/sub/b")
551 _branch(repo, "-D", "feat/sub/a")
552 heads = heads_dir(repo)
553 assert (heads / "feat" / "sub" / "b").exists()
554
555 def test_rename_into_nested_path_creates_dirs(self, repo: pathlib.Path) -> None:
556 """Renaming a flat branch to a nested path must create intermediate dirs."""
557 _branch(repo, "flat-branch")
558 result = _branch(repo, "-m", "flat-branch", "deep/nested/branch")
559 assert result.exit_code == 0
560 heads = heads_dir(repo)
561 assert (heads / "deep" / "nested" / "branch").is_file()
562
563 def test_force_rename_preserves_tip(self, repo: pathlib.Path) -> None:
564 """Force-rename must not lose the commit pointer."""
565 cid_before = get_head_commit_id(repo, "main")
566 _branch(repo, "original")
567 _branch(repo, "destination")
568 _branch(repo, "-M", "original", "destination")
569 cid_after = get_head_commit_id(repo, "destination")
570 assert cid_after == cid_before
571
572 def test_force_copy_preserves_src_tip(self, repo: pathlib.Path) -> None:
573 """Force-copy must not modify the source branch."""
574 cid_src = get_head_commit_id(repo, "main")
575 _branch(repo, "src-branch")
576 (repo / "x.py").write_text("x=99\n")
577 _commit(repo, "-m", "extra commit")
578 _branch(repo, "dst-branch")
579 # Force-copy src-branch (old tip) onto dst-branch
580 _branch(repo, "-C", "src-branch", "dst-branch")
581 assert get_head_commit_id(repo, "src-branch") == cid_src
582
583 def test_rename_current_branch_updates_head(self, repo: pathlib.Path) -> None:
584 """Renaming the currently checked-out branch must update HEAD."""
585 _invoke(repo, ["checkout", "-b", "temp-branch"])
586 _branch(repo, "-m", "temp-branch", "renamed-branch")
587 assert read_current_branch(repo) == "renamed-branch"
588
589
590 # ---------------------------------------------------------------------------
591 # Data integrity: JSON error schemas
592 # ---------------------------------------------------------------------------
593
594
595 class TestJsonErrorSchemas:
596 """Mutation errors must emit structured JSON with error + message keys."""
597
598 def test_delete_not_found_json_schema(self, repo: pathlib.Path) -> None:
599 result = _branch(repo, "-d", "ghost", "--json")
600 assert result.exit_code == 1
601 data = _first_json(result)
602 assert "error" in data
603 assert "message" in data
604
605 def test_delete_not_merged_json_schema(self, repo: pathlib.Path) -> None:
606 _branch(repo, "unmerged")
607 _invoke(repo, ["checkout", "unmerged"])
608 (repo / "z.py").write_text("z=1\n")
609 _commit(repo, "-m", "diverge")
610 _invoke(repo, ["checkout", "main"])
611 result = _branch(repo, "-d", "unmerged", "--json")
612 assert result.exit_code == 1
613 data = _first_json(result)
614 assert data.get("error") == "not_merged"
615 assert "hint" in data # must tell user about -D
616
617 def test_delete_current_branch_json_schema(self, repo: pathlib.Path) -> None:
618 result = _branch(repo, "-d", "main", "--json")
619 assert result.exit_code == 1
620 data = _first_json(result)
621 assert data.get("error") == "current_branch"
622
623 def test_rename_not_found_json_schema(self, repo: pathlib.Path) -> None:
624 result = _branch(repo, "-m", "ghost", "new", "--json")
625 assert result.exit_code == 1
626 data = _first_json(result)
627 assert data.get("error") == "not_found"
628
629 def test_rename_already_exists_json_schema(self, repo: pathlib.Path) -> None:
630 _branch(repo, "a")
631 _branch(repo, "b")
632 result = _branch(repo, "-m", "a", "b", "--json")
633 assert result.exit_code == 1
634 data = _first_json(result)
635 assert data.get("error") == "already_exists"
636 assert "hint" in data # must tell user about -M
637
638 def test_copy_not_found_json_schema(self, repo: pathlib.Path) -> None:
639 result = _branch(repo, "-c", "ghost", "copy", "--json")
640 assert result.exit_code == 1
641 data = _first_json(result)
642 assert data.get("error") == "not_found"
643
644 def test_create_already_exists_json_schema(self, repo: pathlib.Path) -> None:
645 result = _branch(repo, "main", "--json")
646 assert result.exit_code == 1
647 data = _first_json(result)
648 assert data.get("error") == "already_exists"
649
650
651 # ---------------------------------------------------------------------------
652 # Integration: create JSON schema
653 # ---------------------------------------------------------------------------
654
655
656 class TestCreateJsonSchema:
657 def test_create_json_schema_complete(self, repo: pathlib.Path) -> None:
658 result = _branch(repo, "new-branch", "--json")
659 assert result.exit_code == 0
660 data = json.loads(result.output)
661 assert data["action"] == "created"
662 assert "branch" in data
663 assert "commit_id" in data
664 assert "from" in data
665
666 def test_create_commit_id_is_sha256_prefixed(self, repo: pathlib.Path) -> None:
667 result = _branch(repo, "sha-check", "--json")
668 data = json.loads(result.output)
669 cid = data.get("commit_id")
670 assert cid is not None
671 assert cid.startswith("sha256:"), f"commit_id should have sha256: prefix; got {cid!r}"
672
673 def test_create_from_is_null_when_no_start_point(self, repo: pathlib.Path) -> None:
674 result = _branch(repo, "no-sp", "--json")
675 data = json.loads(result.output)
676 assert data.get("from") is None
677
678 def test_create_from_set_when_start_point_given(self, repo: pathlib.Path) -> None:
679 cid = get_head_commit_id(repo, "main")
680 result = _branch(repo, "from-sp", cid, "--json")
681 data = json.loads(result.output)
682 assert data.get("from") == cid
683
684
685 # ---------------------------------------------------------------------------
686 # Security: ANSI in filter flags
687 # ---------------------------------------------------------------------------
688
689
690 class TestSecurityFilterFlags:
691 def _has_ansi(self, s: str) -> bool:
692 return "\x1b[" in s
693
694 def test_ansi_in_merged_ref_rejected_or_sanitized(self, repo: pathlib.Path) -> None:
695 result = _branch(repo, "--merged", "\x1b[31mmalicious\x1b[0m")
696 assert not self._has_ansi(result.output)
697
698 def test_ansi_in_no_merged_ref(self, repo: pathlib.Path) -> None:
699 result = _branch(repo, "--no-merged", "\x1b[31mmalicious\x1b[0m")
700 assert not self._has_ansi(result.output)
701
702 def test_ansi_in_contains_ref(self, repo: pathlib.Path) -> None:
703 result = _branch(repo, "--contains", "\x1b[31mmalicious\x1b[0m")
704 assert not self._has_ansi(result.output)
705
706 def test_newline_in_branch_name_rejected(self, repo: pathlib.Path) -> None:
707 result = _branch(repo, "branch\nmalicious")
708 assert result.exit_code == 1
709
710 def test_ansi_in_delete_json_error_sanitized(self, repo: pathlib.Path) -> None:
711 result = _branch(repo, "-d", "\x1b[31mmalicious\x1b[0m", "--json")
712 assert result.exit_code == 1
713 assert not self._has_ansi(result.output)
714
715
716 # ---------------------------------------------------------------------------
717 # Performance: --sort committeddate with 50 branches
718 # ---------------------------------------------------------------------------
719
720
721 class TestSortCommittedDatePerformance:
722 def test_50_branches_committeddate_under_3s(self, repo: pathlib.Path) -> None:
723 for i in range(50):
724 _branch(repo, f"perf/branch-{i:03d}")
725
726 start = time.monotonic()
727 result = _branch(repo, "--sort", "committeddate", "--json")
728 elapsed = time.monotonic() - start
729
730 assert result.exit_code == 0
731 data = json.loads(result.output)
732 assert len(data) == 51 # main + 50
733 assert elapsed < 3.0, f"--sort committeddate with 51 branches took {elapsed:.2f}s"
734
735
736 # ---------------------------------------------------------------------------
737 # Docstrings
738 # ---------------------------------------------------------------------------
739
740
741 class TestDocstrings:
742 def _has_doc(self, obj: _AnyObj) -> bool:
743 import inspect
744 doc = inspect.getdoc(obj)
745 return bool(doc and len(doc.strip()) > 10)
746
747 def test_module_docstring(self) -> None:
748 import muse.cli.commands.branch as m
749 assert self._has_doc(m)
750
751 def test_ref_file_docstring(self) -> None:
752 from muse.cli.commands.branch import _ref_file
753 assert self._has_doc(_ref_file)
754
755 def test_list_local_branches_docstring(self) -> None:
756 from muse.cli.commands.branch import _list_local_branches
757 assert self._has_doc(_list_local_branches)
758
759 def test_list_remotes_docstring(self) -> None:
760 from muse.cli.commands.branch import _list_remotes
761 assert self._has_doc(_list_remotes)
762
763 def test_upstream_for_docstring(self) -> None:
764 from muse.cli.commands.branch import _upstream_for
765 assert self._has_doc(_upstream_for)
766
767 def test_commit_ancestors_docstring(self) -> None:
768 from muse.cli.commands.branch import _commit_ancestors
769 assert self._has_doc(_commit_ancestors)
770
771 def test_is_merged_docstring(self) -> None:
772 from muse.cli.commands.branch import _is_merged
773 assert self._has_doc(_is_merged)
774
775 def test_contains_commit_docstring(self) -> None:
776 from muse.cli.commands.branch import _contains_commit
777 assert self._has_doc(_contains_commit)
778
779 def test_cleanup_empty_dirs_docstring(self) -> None:
780 from muse.cli.commands.branch import _cleanup_empty_dirs
781 assert self._has_doc(_cleanup_empty_dirs)
782
783 def test_resolve_start_point_docstring(self) -> None:
784 from muse.cli.commands.branch import _resolve_start_point
785 assert self._has_doc(_resolve_start_point)
786
787 def test_run_docstring(self) -> None:
788 from muse.cli.commands.branch import run
789 assert self._has_doc(run)
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago