gabriel / muse public
test_cmd_branch.py python
908 lines 38.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Tests for ``muse branch``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, dead-code removal, helpers (_resolve_start_point,
6 _list_local_branches, _list_remotes, _upstream_for,
7 _commit_ancestors, _is_merged, _contains_commit,
8 _cleanup_empty_dirs).
9 Integration — create, delete, force-delete, rename, force-rename, copy,
10 force-copy, listing, filtering, sorting.
11 End-to-end — full CLI invocations: text and JSON output, all operations.
12 Security — ANSI injection in branch names, format flags, messages.
13 Stress — 500 branches, concurrent list, deep ancestry chains.
14 """
15
16 from __future__ import annotations
17
18 import json
19 import os
20 import pathlib
21 import subprocess
22 import threading
23 import time
24 from typing import TYPE_CHECKING
25
26 import pytest
27
28 from tests.cli_test_helper import CliRunner, InvokeResult
29 from muse.core.store import get_head_commit_id, read_current_branch
30 from muse.core._types import short_id
31
32 if TYPE_CHECKING:
33 import argparse
34
35 runner = CliRunner()
36
37 # ──────────────────────────────────────────────────────────────────────────────
38 # Helpers
39 # ──────────────────────────────────────────────────────────────────────────────
40
41
42 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
43 saved = os.getcwd()
44 try:
45 os.chdir(repo)
46 return runner.invoke(None, args)
47 finally:
48 os.chdir(saved)
49
50
51 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
52 return _invoke(repo, ["branch", *extra])
53
54
55 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
56 return _invoke(repo, ["commit", *extra])
57
58
59 @pytest.fixture()
60 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
61 """Initialised repo with one commit on ``main``."""
62 saved = os.getcwd()
63 try:
64 os.chdir(tmp_path)
65 runner.invoke(None, ["init"])
66 finally:
67 os.chdir(saved)
68 (tmp_path / "a.py").write_text("x = 1\n")
69 _commit(tmp_path, "-m", "initial")
70 return tmp_path
71
72
73 @pytest.fixture()
74 def two_commit_repo(repo: pathlib.Path) -> pathlib.Path:
75 """Repo with two commits on ``main``."""
76 (repo / "b.py").write_text("y = 2\n")
77 _commit(repo, "-m", "second")
78 return repo
79
80
81 # ──────────────────────────────────────────────────────────────────────────────
82 # Unit — parser flags
83 # ──────────────────────────────────────────────────────────────────────────────
84
85
86 class TestRegisterFlags:
87 def _parse(self, *args: str) -> "argparse.Namespace":
88 import argparse
89
90 from muse.cli.commands.branch import register
91
92 p = argparse.ArgumentParser()
93 sub = p.add_subparsers()
94 register(sub)
95 return p.parse_args(["branch", *args])
96
97 def test_default_json_out_is_false(self) -> None:
98 ns = self._parse()
99 assert ns.json_out is False
100
101 def test_json_flag_sets_json_out(self) -> None:
102 ns = self._parse("--json")
103 assert ns.json_out is True
104
105 def test_j_shorthand_sets_json_out(self) -> None:
106 ns = self._parse("-j")
107 assert ns.json_out is True
108
109 def test_delete_flag(self) -> None:
110 ns = self._parse("-d", "foo")
111 assert ns.op == "delete"
112
113 def test_force_delete_flag(self) -> None:
114 ns = self._parse("-D", "foo")
115 assert ns.op == "force_delete"
116
117 def test_rename_flag(self) -> None:
118 ns = self._parse("-m", "new")
119 assert ns.op == "rename"
120
121 def test_force_rename_flag(self) -> None:
122 ns = self._parse("-M", "new")
123 assert ns.op == "force_rename"
124
125 def test_copy_flag(self) -> None:
126 ns = self._parse("-c", "copy")
127 assert ns.op == "copy"
128
129 def test_force_copy_flag(self) -> None:
130 ns = self._parse("-C", "copy")
131 assert ns.op == "force_copy"
132
133 def test_verbose_default_0(self) -> None:
134 ns = self._parse()
135 assert ns.verbose == 0
136
137 def test_verbose_v_is_1(self) -> None:
138 ns = self._parse("-v")
139 assert ns.verbose == 1
140
141 def test_verbose_vv_is_2(self) -> None:
142 ns = self._parse("-vv")
143 assert ns.verbose == 2
144
145 def test_remotes_flag(self) -> None:
146 ns = self._parse("-r")
147 assert ns.remotes is True
148
149 def test_all_flag(self) -> None:
150 ns = self._parse("-a")
151 assert ns.all_branches is True
152
153 def test_sort_default_name(self) -> None:
154 ns = self._parse()
155 assert ns.sort == "name"
156
157 def test_sort_committeddate(self) -> None:
158 ns = self._parse("--sort", "committeddate")
159 assert ns.sort == "committeddate"
160
161 def test_sort_invalid_rejected(self) -> None:
162 import argparse
163
164 from muse.cli.commands.branch import register
165
166 p = argparse.ArgumentParser()
167 sub = p.add_subparsers()
168 register(sub)
169 with pytest.raises(SystemExit):
170 p.parse_args(["branch", "--sort", "invalid"])
171
172
173 # ──────────────────────────────────────────────────────────────────────────────
174 # Unit — dead-code removal
175 # ──────────────────────────────────────────────────────────────────────────────
176
177
178 class TestDeadCodeRemoved:
179 def test_op_list_branch_removed(self) -> None:
180 import inspect
181
182 import muse.cli.commands.branch as m
183
184 src = inspect.getsource(m.run)
185 assert 'op == "list"' not in src, (
186 'op == "list" was a dead branch (nothing in register() creates it); must be deleted'
187 )
188
189 def test_inline_tomllib_import_removed(self) -> None:
190 import inspect
191
192 import muse.cli.commands.branch as m
193
194 src = inspect.getsource(m._upstream_for)
195 assert "import tomllib" not in src, (
196 "inline 'import tomllib' inside _upstream_for should be a module-level import"
197 )
198
199 def test_double_sanitize_removed(self) -> None:
200 """The verbose listing previously double-sanitized name_str (stripping ANSI)."""
201 import inspect
202
203 import muse.cli.commands.branch as m
204
205 src = inspect.getsource(m.run)
206 assert "sanitize_display(name_str)" not in src, (
207 "name_str was double-sanitized; the second call stripped ANSI from current branch"
208 )
209
210
211 # ──────────────────────────────────────────────────────────────────────────────
212 # Unit — _resolve_start_point
213 # ──────────────────────────────────────────────────────────────────────────────
214
215
216 class TestResolveStartPoint:
217 def test_resolves_branch_name(self, repo: pathlib.Path) -> None:
218 from muse.cli.commands.branch import _resolve_start_point
219 from muse.core.repo import read_repo_id
220
221 repo_id = read_repo_id(repo)
222 cid = get_head_commit_id(repo, "main")
223 result = _resolve_start_point(repo, repo_id, "main", "main")
224 assert result == cid
225
226 def test_resolves_full_sha(self, repo: pathlib.Path) -> None:
227 from muse.cli.commands.branch import _resolve_start_point
228 from muse.core.repo import read_repo_id
229
230 repo_id = read_repo_id(repo)
231 cid = get_head_commit_id(repo, "main")
232 assert cid is not None
233 result = _resolve_start_point(repo, repo_id, "main", cid)
234 assert result == cid
235
236 def test_resolves_partial_sha(self, two_commit_repo: pathlib.Path) -> None:
237 from muse.cli.commands.branch import _resolve_start_point
238 from muse.core.repo import read_repo_id
239 from muse.core.store import get_commits_for_branch, read_current_branch
240
241 repo = two_commit_repo
242 repo_id = read_repo_id(repo)
243 branch = read_current_branch(repo)
244 commits = get_commits_for_branch(repo, repo_id, branch)
245 first_sha = commits[-1].commit_id # oldest commit
246 # 12-char prefix should resolve
247 result = _resolve_start_point(repo, repo_id, "main", short_id(first_sha))
248 assert result == first_sha
249
250 def test_returns_input_for_unresolvable(self, repo: pathlib.Path) -> None:
251 from muse.cli.commands.branch import _resolve_start_point
252 from muse.core.repo import read_repo_id
253
254 repo_id = read_repo_id(repo)
255 result = _resolve_start_point(repo, repo_id, "main", "nonexistent-ref")
256 assert result == "nonexistent-ref"
257
258
259 # ──────────────────────────────────────────────────────────────────────────────
260 # Unit — _list_local_branches
261 # ──────────────────────────────────────────────────────────────────────────────
262
263
264 class TestListLocalBranches:
265 def test_returns_sorted_list(self, repo: pathlib.Path) -> None:
266 from muse.cli.commands.branch import _list_local_branches
267
268 _branch(repo, "z-last")
269 _branch(repo, "a-first")
270 branches = _list_local_branches(repo)
271 assert branches == sorted(branches)
272
273 def test_skips_hidden_files(self, repo: pathlib.Path) -> None:
274 from muse.cli.commands.branch import _list_local_branches
275
276 # Plant a hidden lock file inside refs/heads/
277 lock = repo / ".muse" / "refs" / "heads" / ".lock"
278 lock.write_text("locked")
279 branches = _list_local_branches(repo)
280 assert ".lock" not in branches
281 assert not any(b.startswith(".") for b in branches)
282
283 def test_empty_repo_returns_empty(self, tmp_path: pathlib.Path) -> None:
284 from muse.cli.commands.branch import _list_local_branches
285
286 assert _list_local_branches(tmp_path) == []
287
288 def test_includes_nested_branches(self, repo: pathlib.Path) -> None:
289 from muse.cli.commands.branch import _list_local_branches
290
291 _branch(repo, "feat/sub/task")
292 branches = _list_local_branches(repo)
293 assert "feat/sub/task" in branches
294
295
296 # ──────────────────────────────────────────────────────────────────────────────
297 # Unit — _commit_ancestors, _is_merged, _contains_commit
298 # ──────────────────────────────────────────────────────────────────────────────
299
300
301 class TestCommitGraph:
302 def test_commit_ancestors_includes_self(self, repo: pathlib.Path) -> None:
303 from muse.cli.commands.branch import _commit_ancestors
304
305 cid = get_head_commit_id(repo, "main")
306 assert cid is not None
307 ancestors = _commit_ancestors(repo, cid)
308 assert cid in ancestors
309
310 def test_is_merged_true_for_same_branch(self, repo: pathlib.Path) -> None:
311 from muse.cli.commands.branch import _is_merged
312
313 assert _is_merged(repo, "main", "main")
314
315 def test_is_merged_false_for_unmerged(self, repo: pathlib.Path) -> None:
316 from muse.cli.commands.branch import _is_merged
317
318 _branch(repo, "feat")
319 _invoke(repo, ["checkout", "feat"])
320 (repo / "c.py").write_text("c=1\n")
321 _commit(repo, "-m", "feat commit")
322 _invoke(repo, ["checkout", "main"])
323 assert not _is_merged(repo, "feat", "main")
324
325 def test_contains_commit_true(self, repo: pathlib.Path) -> None:
326 from muse.cli.commands.branch import _contains_commit
327
328 cid = get_head_commit_id(repo, "main")
329 assert cid is not None
330 assert _contains_commit(repo, "main", cid)
331
332 def test_contains_commit_false_for_unknown(self, repo: pathlib.Path) -> None:
333 from muse.cli.commands.branch import _contains_commit
334
335 assert not _contains_commit(repo, "main", "a" * 64)
336
337
338 # ──────────────────────────────────────────────────────────────────────────────
339 # Integration — CREATE
340 # ──────────────────────────────────────────────────────────────────────────────
341
342
343 class TestCreate:
344 def test_create_basic_exits_0(self, repo: pathlib.Path) -> None:
345 result = _branch(repo, "new-branch")
346 assert result.exit_code == 0
347
348 def test_create_text_output(self, repo: pathlib.Path) -> None:
349 result = _branch(repo, "my-branch")
350 assert "my-branch" in result.output
351
352 def test_create_json_schema(self, repo: pathlib.Path) -> None:
353 result = _branch(repo, "json-branch", "--json")
354 data = json.loads(result.output)
355 assert data["action"] == "created"
356 assert data["branch"] == "json-branch"
357 assert "commit_id" in data
358 assert "from" in data
359
360 def test_create_json_from_is_none_at_head(self, repo: pathlib.Path) -> None:
361 result = _branch(repo, "from-head", "--json")
362 data = json.loads(result.output)
363 assert data["from"] is None
364
365 def test_create_at_full_sha(self, two_commit_repo: pathlib.Path) -> None:
366 repo = two_commit_repo
367 from muse.core.store import get_commits_for_branch, read_current_branch
368 from muse.core.repo import read_repo_id
369
370 repo_id = read_repo_id(repo)
371 branch = read_current_branch(repo)
372 commits = get_commits_for_branch(repo, repo_id, branch)
373 first_sha = commits[-1].commit_id
374
375 result = _branch(repo, "at-sha", first_sha)
376 assert result.exit_code == 0
377 tip = get_head_commit_id(repo, "at-sha")
378 assert tip == first_sha
379
380 def test_create_at_partial_sha(self, two_commit_repo: pathlib.Path) -> None:
381 repo = two_commit_repo
382 from muse.core.store import get_commits_for_branch, read_current_branch
383 from muse.core.repo import read_repo_id
384
385 repo_id = read_repo_id(repo)
386 branch = read_current_branch(repo)
387 commits = get_commits_for_branch(repo, repo_id, branch)
388 first_sha = commits[-1].commit_id
389
390 result = _branch(repo, "at-partial", short_id(first_sha))
391 assert result.exit_code == 0
392 tip = get_head_commit_id(repo, "at-partial")
393 assert tip == first_sha
394
395 def test_create_at_branch_name(self, repo: pathlib.Path) -> None:
396 head_cid = get_head_commit_id(repo, "main")
397 result = _branch(repo, "copy-of-main", "main")
398 assert result.exit_code == 0
399 tip = get_head_commit_id(repo, "copy-of-main")
400 assert tip == head_cid
401
402 def test_create_json_from_field_populated(self, repo: pathlib.Path) -> None:
403 result = _branch(repo, "with-from", "main", "--json")
404 data = json.loads(result.output)
405 assert data["from"] == "main"
406
407 def test_create_duplicate_exits_1(self, repo: pathlib.Path) -> None:
408 _branch(repo, "dup")
409 result = _branch(repo, "dup")
410 assert result.exit_code == 1
411
412 def test_create_invalid_name_exits_1(self, repo: pathlib.Path) -> None:
413 result = _branch(repo, "bad..name")
414 assert result.exit_code == 1
415
416 def test_create_does_not_checkout(self, repo: pathlib.Path) -> None:
417 _branch(repo, "new-but-no-switch")
418 assert read_current_branch(repo) == "main"
419
420
421 # ──────────────────────────────────────────────────────────────────────────────
422 # Integration — DELETE
423 # ──────────────────────────────────────────────────────────────────────────────
424
425
426 class TestDelete:
427 def test_delete_merged_branch_exits_0(self, repo: pathlib.Path) -> None:
428 _branch(repo, "to-delete")
429 # Branch points to same commit as main → considered merged
430 result = _branch(repo, "-d", "to-delete")
431 assert result.exit_code == 0
432
433 def test_delete_json_schema(self, repo: pathlib.Path) -> None:
434 _branch(repo, "del-json")
435 result = _branch(repo, "-d", "del-json", "--json")
436 data = json.loads(result.output)
437 assert data["action"] == "deleted"
438 assert data["branch"] == "del-json"
439 assert "was" in data
440
441 def test_delete_unmerged_exits_1_without_force(self, repo: pathlib.Path) -> None:
442 _branch(repo, "unmerged")
443 _invoke(repo, ["checkout", "unmerged"])
444 (repo / "z.py").write_text("z=1\n")
445 _commit(repo, "-m", "unmerged work")
446 _invoke(repo, ["checkout", "main"])
447 result = _branch(repo, "-d", "unmerged")
448 assert result.exit_code == 1
449
450 def test_force_delete_unmerged_exits_0(self, repo: pathlib.Path) -> None:
451 _branch(repo, "force-del")
452 _invoke(repo, ["checkout", "force-del"])
453 (repo / "x.py").write_text("x=1\n")
454 _commit(repo, "-m", "exclusive work")
455 _invoke(repo, ["checkout", "main"])
456 result = _branch(repo, "-D", "force-del")
457 assert result.exit_code == 0
458
459 def test_delete_current_branch_exits_1(self, repo: pathlib.Path) -> None:
460 result = _branch(repo, "-d", "main")
461 assert result.exit_code == 1
462
463 def test_delete_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
464 result = _branch(repo, "-d", "ghost")
465 assert result.exit_code == 1
466
467 def test_delete_removes_branch_from_list(self, repo: pathlib.Path) -> None:
468 _branch(repo, "temp")
469 _branch(repo, "-d", "temp")
470 result = _branch(repo, "--json")
471 names = [b["name"] for b in json.loads(result.output)]
472 assert "temp" not in names
473
474 def test_delete_nested_branch_cleans_empty_dirs(self, repo: pathlib.Path) -> None:
475 _branch(repo, "feat/sub/task")
476 _branch(repo, "-D", "feat/sub/task")
477 # The feat/ and feat/sub/ dirs should be gone
478 feat_dir = repo / ".muse" / "refs" / "heads" / "feat"
479 assert not feat_dir.exists()
480
481
482 # ──────────────────────────────────────────────────────────────────────────────
483 # Integration — RENAME
484 # ──────────────────────────────────────────────────────────────────────────────
485
486
487 class TestRename:
488 def test_rename_basic(self, repo: pathlib.Path) -> None:
489 _branch(repo, "old-name")
490 result = _branch(repo, "-m", "old-name", "new-name")
491 assert result.exit_code == 0
492 names = [b["name"] for b in json.loads(_branch(repo, "--json").output)]
493 assert "new-name" in names
494 assert "old-name" not in names
495
496 def test_rename_omit_old_uses_current(self, repo: pathlib.Path) -> None:
497 _branch(repo, "temp")
498 _invoke(repo, ["checkout", "temp"])
499 result = _branch(repo, "-m", "renamed")
500 assert result.exit_code == 0
501 assert read_current_branch(repo) == "renamed"
502 _invoke(repo, ["checkout", "main"])
503
504 def test_rename_json_schema(self, repo: pathlib.Path) -> None:
505 _branch(repo, "src")
506 result = _branch(repo, "-m", "src", "dst", "--json")
507 data = json.loads(result.output)
508 assert data["action"] == "renamed"
509 assert data["from"] == "src"
510 assert data["to"] == "dst"
511
512 def test_rename_to_existing_exits_1(self, repo: pathlib.Path) -> None:
513 _branch(repo, "a")
514 _branch(repo, "b")
515 result = _branch(repo, "-m", "a", "b")
516 assert result.exit_code == 1
517
518 def test_force_rename_to_existing_exits_0(self, repo: pathlib.Path) -> None:
519 _branch(repo, "a")
520 _branch(repo, "b")
521 result = _branch(repo, "-M", "a", "b")
522 assert result.exit_code == 0
523
524 def test_rename_updates_head_when_current(self, repo: pathlib.Path) -> None:
525 _branch(repo, "temp2")
526 _invoke(repo, ["checkout", "temp2"])
527 _branch(repo, "-m", "temp2", "newname")
528 assert read_current_branch(repo) == "newname"
529 _invoke(repo, ["checkout", "main"])
530
531 def test_rename_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
532 result = _branch(repo, "-m", "ghost", "newname")
533 assert result.exit_code == 1
534
535
536 # ──────────────────────────────────────────────────────────────────────────────
537 # Integration — COPY
538 # ──────────────────────────────────────────────────────────────────────────────
539
540
541 class TestCopy:
542 def test_copy_basic(self, repo: pathlib.Path) -> None:
543 _branch(repo, "orig")
544 result = _branch(repo, "-c", "orig", "clone")
545 assert result.exit_code == 0
546 names = [b["name"] for b in json.loads(_branch(repo, "--json").output)]
547 assert "orig" in names
548 assert "clone" in names
549
550 def test_copy_same_tip(self, repo: pathlib.Path) -> None:
551 _branch(repo, "src")
552 _branch(repo, "-c", "src", "dst")
553 tip_src = get_head_commit_id(repo, "src")
554 tip_dst = get_head_commit_id(repo, "dst")
555 assert tip_src == tip_dst
556
557 def test_copy_json_schema(self, repo: pathlib.Path) -> None:
558 _branch(repo, "original")
559 result = _branch(repo, "-c", "original", "copy1", "--json")
560 data = json.loads(result.output)
561 assert data["action"] == "copied"
562 assert data["from"] == "original"
563 assert data["to"] == "copy1"
564
565 def test_copy_to_existing_exits_1(self, repo: pathlib.Path) -> None:
566 _branch(repo, "x")
567 _branch(repo, "y")
568 result = _branch(repo, "-c", "x", "y")
569 assert result.exit_code == 1
570
571 def test_force_copy_to_existing_exits_0(self, repo: pathlib.Path) -> None:
572 _branch(repo, "p")
573 _branch(repo, "q")
574 result = _branch(repo, "-C", "p", "q")
575 assert result.exit_code == 0
576
577 def test_copy_omit_src_uses_current(self, repo: pathlib.Path) -> None:
578 head = get_head_commit_id(repo, "main")
579 result = _branch(repo, "-c", "main-copy")
580 assert result.exit_code == 0
581 tip = get_head_commit_id(repo, "main-copy")
582 assert tip == head
583
584
585 # ──────────────────────────────────────────────────────────────────────────────
586 # Integration — LIST
587 # ──────────────────────────────────────────────────────────────────────────────
588
589
590 class TestList:
591 def test_list_text_exits_0(self, repo: pathlib.Path) -> None:
592 result = _branch(repo)
593 assert result.exit_code == 0
594
595 def test_list_contains_main(self, repo: pathlib.Path) -> None:
596 result = _branch(repo)
597 assert "main" in result.output
598
599 def test_list_marks_current_branch(self, repo: pathlib.Path) -> None:
600 result = _branch(repo)
601 # Current branch line must start with "* "
602 current_lines = [l for l in result.output.splitlines() if l.startswith("* ")]
603 assert len(current_lines) == 1
604 assert "main" in current_lines[0]
605
606 def test_list_json_schema(self, repo: pathlib.Path) -> None:
607 result = _branch(repo, "--json")
608 data = json.loads(result.output)
609 assert isinstance(data, list)
610 assert len(data) >= 1
611 keys = set(data[0].keys())
612 assert {"name", "current", "commit_id", "last_message", "upstream"} <= keys
613
614 def test_list_json_current_flag(self, repo: pathlib.Path) -> None:
615 result = _branch(repo, "--json")
616 data = json.loads(result.output)
617 current = [b for b in data if b["current"]]
618 assert len(current) == 1
619 assert current[0]["name"] == "main"
620
621 def test_list_json_last_message_populated(self, repo: pathlib.Path) -> None:
622 result = _branch(repo, "--json")
623 data = json.loads(result.output)
624 main_entry = next(b for b in data if b["name"] == "main")
625 assert main_entry["last_message"] is not None
626 assert "initial" in main_entry["last_message"]
627
628 def test_list_json_upstream_null_by_default(self, repo: pathlib.Path) -> None:
629 result = _branch(repo, "--json")
630 data = json.loads(result.output)
631 main_entry = next(b for b in data if b["name"] == "main")
632 assert main_entry["upstream"] is None
633
634 def test_list_verbose_shows_sha(self, repo: pathlib.Path) -> None:
635 result = _branch(repo, "-v")
636 # Short SHA should appear
637 cid = get_head_commit_id(repo, "main")
638 assert cid is not None
639 assert cid[:8] in result.output
640
641 def test_list_verbose_shows_message(self, repo: pathlib.Path) -> None:
642 result = _branch(repo, "-v")
643 assert "initial" in result.output
644
645 def test_list_multiple_branches(self, repo: pathlib.Path) -> None:
646 _branch(repo, "feat/a")
647 _branch(repo, "feat/b")
648 result = _branch(repo, "--json")
649 data = json.loads(result.output)
650 names = [b["name"] for b in data]
651 assert "feat/a" in names
652 assert "feat/b" in names
653
654 def test_list_sorted_by_name(self, repo: pathlib.Path) -> None:
655 _branch(repo, "z-last")
656 _branch(repo, "a-first")
657 result = _branch(repo, "--json")
658 data = json.loads(result.output)
659 names = [b["name"] for b in data]
660 assert names == sorted(names)
661
662 def test_list_sort_committeddate(self, repo: pathlib.Path) -> None:
663 _branch(repo, "feat-x")
664 result = _branch(repo, "--sort", "committeddate", "--json")
665 assert result.exit_code == 0
666 data = json.loads(result.output)
667 assert isinstance(data, list)
668
669
670 # ──────────────────────────────────────────────────────────────────────────────
671 # Integration — FILTERS
672 # ──────────────────────────────────────────────────────────────────────────────
673
674
675 class TestFilters:
676 def test_merged_filter_includes_self(self, repo: pathlib.Path) -> None:
677 result = _branch(repo, "--merged", "--json")
678 data = json.loads(result.output)
679 names = [b["name"] for b in data]
680 assert "main" in names
681
682 def test_merged_filter_excludes_unmerged(self, repo: pathlib.Path) -> None:
683 _branch(repo, "unmerged-feat")
684 _invoke(repo, ["checkout", "unmerged-feat"])
685 (repo / "u.py").write_text("u=1\n")
686 _commit(repo, "-m", "unmerged")
687 _invoke(repo, ["checkout", "main"])
688 result = _branch(repo, "--merged", "--json")
689 data = json.loads(result.output)
690 names = [b["name"] for b in data]
691 assert "unmerged-feat" not in names
692
693 def test_no_merged_filter_includes_unmerged(self, repo: pathlib.Path) -> None:
694 _branch(repo, "exclusive-feat")
695 _invoke(repo, ["checkout", "exclusive-feat"])
696 (repo / "e.py").write_text("e=1\n")
697 _commit(repo, "-m", "exclusive")
698 _invoke(repo, ["checkout", "main"])
699 result = _branch(repo, "--no-merged", "--json")
700 data = json.loads(result.output)
701 names = [b["name"] for b in data]
702 assert "exclusive-feat" in names
703
704 def test_no_merged_filter_excludes_self(self, repo: pathlib.Path) -> None:
705 result = _branch(repo, "--no-merged", "--json")
706 data = json.loads(result.output)
707 names = [b["name"] for b in data]
708 assert "main" not in names
709
710 def test_contains_commit_filter(self, repo: pathlib.Path) -> None:
711 cid = get_head_commit_id(repo, "main")
712 assert cid is not None
713 result = _branch(repo, "--contains", cid, "--json")
714 data = json.loads(result.output)
715 names = [b["name"] for b in data]
716 assert "main" in names
717
718 def test_contains_unknown_commit_empty(self, repo: pathlib.Path) -> None:
719 result = _branch(repo, "--contains", "a" * 64, "--json")
720 data = json.loads(result.output)
721 assert data == []
722
723
724 # ──────────────────────────────────────────────────────────────────────────────
725 # Integration — validation
726 # ──────────────────────────────────────────────────────────────────────────────
727
728
729 class TestValidation:
730 def test_ansi_in_pattern_arg_sanitized(self, repo: pathlib.Path) -> None:
731 result = _branch(repo, "--pattern", "\x1b[31mxml\x1b[0m")
732 assert "\x1b" not in result.output
733
734 def test_delete_without_name_exits_1(self, repo: pathlib.Path) -> None:
735 result = _branch(repo, "-d")
736 assert result.exit_code == 1
737
738 def test_rename_too_many_args_exits_1(self, repo: pathlib.Path) -> None:
739 result = _branch(repo, "-m", "a", "b", "c")
740 assert result.exit_code == 1
741
742 def test_copy_too_many_args_exits_1(self, repo: pathlib.Path) -> None:
743 result = _branch(repo, "-c", "a", "b", "c")
744 assert result.exit_code == 1
745
746
747 # ──────────────────────────────────────────────────────────────────────────────
748 # Security — ANSI injection
749 # ──────────────────────────────────────────────────────────────────────────────
750
751
752 class TestSecurityAnsi:
753 def _has_ansi(self, s: str) -> bool:
754 return "\x1b[" in s
755
756 def test_ansi_in_branch_name_rejected(self, repo: pathlib.Path) -> None:
757 result = _branch(repo, "\x1b[31mevil\x1b[0m")
758 assert result.exit_code == 1
759 assert not self._has_ansi(result.output)
760
761 def test_ansi_in_delete_name_rejected(self, repo: pathlib.Path) -> None:
762 result = _branch(repo, "-d", "\x1b[31mevil\x1b[0m")
763 assert result.exit_code == 1
764 assert not self._has_ansi(result.output)
765
766 def test_ansi_in_rename_new_name_rejected(self, repo: pathlib.Path) -> None:
767 result = _branch(repo, "-m", "\x1b[31mnew\x1b[0m")
768 assert result.exit_code == 1
769 assert not self._has_ansi(result.output)
770
771 def test_ansi_in_contains_arg_sanitized(self, repo: pathlib.Path) -> None:
772 result = _branch(repo, "--contains", "\x1b[31mxml\x1b[0m")
773 assert not self._has_ansi(result.output)
774
775 def test_ansi_in_contains_commit_id(self, repo: pathlib.Path) -> None:
776 result = _branch(repo, "--contains", "\x1b[31mevil\x1b[0m")
777 # Should exit 0 (no match, empty list) or exit 0 with empty list
778 # Either way, ANSI must not appear in output
779 assert not self._has_ansi(result.output)
780
781 def test_errors_go_to_stderr(self, repo: pathlib.Path) -> None:
782 result = _branch(repo, "-d", "nonexistent")
783 assert result.exit_code == 1
784 # Error should NOT appear in stdout
785 assert "not found" not in result.output.lower() or (result.stderr and "not found" in result.stderr.lower())
786
787
788 # ──────────────────────────────────────────────────────────────────────────────
789 # Stress
790 # ──────────────────────────────────────────────────────────────────────────────
791
792
793 @pytest.mark.slow
794 class TestStress:
795 def test_list_500_branches_fast(self, repo: pathlib.Path) -> None:
796 """Listing 500 branches must complete in under 2 seconds."""
797 for i in range(500):
798 _branch(repo, f"feat/task-{i:04d}")
799 t0 = time.perf_counter()
800 result = _branch(repo, "--json")
801 elapsed = (time.perf_counter() - t0) * 1000
802 data = json.loads(result.output)
803 assert len(data) == 501 # main + 500
804 assert elapsed < 2000, f"list 500 branches took {elapsed:.0f}ms (limit 2000ms)"
805
806 def test_merged_filter_100_branches(self, repo: pathlib.Path) -> None:
807 """--merged filter on 100 branches completes in reasonable time."""
808 for i in range(100):
809 _branch(repo, f"task-{i:03d}")
810 t0 = time.perf_counter()
811 result = _branch(repo, "--merged", "--json")
812 elapsed = (time.perf_counter() - t0) * 1000
813 data = json.loads(result.output)
814 # All branches share the same commit as main → all merged
815 assert len(data) == 101
816 assert elapsed < 3000, f"--merged on 100 branches took {elapsed:.0f}ms"
817
818 def test_sort_committeddate_100_branches(self, repo: pathlib.Path) -> None:
819 for i in range(100):
820 _branch(repo, f"sort-{i:03d}")
821 result = _branch(repo, "--sort", "committeddate", "--json")
822 assert result.exit_code == 0
823 data = json.loads(result.output)
824 assert len(data) == 101
825
826 def test_concurrent_branch_list_separate_repos(self, tmp_path: pathlib.Path) -> None:
827 errors: list[str] = []
828
829 def do_branch(idx: int) -> None:
830 repo_dir = tmp_path / f"repo_{idx}"
831 repo_dir.mkdir()
832 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
833 (repo_dir / "x.py").write_text(f"x={idx}\n")
834 subprocess.run(
835 ["muse", "commit", "-m", f"c{idx}"],
836 cwd=str(repo_dir), capture_output=True,
837 )
838 for j in range(5):
839 subprocess.run(
840 ["muse", "branch", f"b{j}"],
841 cwd=str(repo_dir), capture_output=True,
842 )
843 r = subprocess.run(
844 ["muse", "branch", "--json"],
845 cwd=str(repo_dir), capture_output=True, text=True,
846 )
847 if r.returncode != 0:
848 errors.append(f"repo_{idx}: branch --json failed")
849 return
850 data = json.loads(r.stdout)
851 if len(data) != 6: # main + 5
852 errors.append(f"repo_{idx}: expected 6 branches, got {len(data)}")
853
854 threads = [threading.Thread(target=do_branch, args=(i,)) for i in range(6)]
855 for t in threads:
856 t.start()
857 for t in threads:
858 t.join()
859 assert not errors, "Concurrent branch errors:\n" + "\n".join(errors)
860
861 def test_deep_ancestor_chain_is_merged(self, repo: pathlib.Path) -> None:
862 """A branch with 50 ancestors is correctly detected as merged."""
863 _branch(repo, "long-chain")
864 _invoke(repo, ["checkout", "long-chain"])
865 for i in range(50):
866 (repo / f"step_{i:03d}.py").write_text(f"s={i}\n")
867 _commit(repo, "-m", f"step {i}")
868 _invoke(repo, ["checkout", "main"])
869 _invoke(repo, ["merge", "long-chain"])
870 result = _branch(repo, "--merged", "--json")
871 data = json.loads(result.output)
872 names = [b["name"] for b in data]
873 assert "long-chain" in names
874
875 def test_merged_filter_ancestor_set_computed_once(
876 self, repo: pathlib.Path
877 ) -> None:
878 """--merged must compute the 'into' ancestor set once, not once per branch.
879
880 With N branches, the naive implementation calls _commit_ancestors N times
881 for the same 'into' tip. The fix pre-computes it once and checks each
882 branch tip against the cached set.
883 """
884 from unittest.mock import patch
885 import muse.cli.commands.branch as branch_module
886
887 for i in range(20):
888 _branch(repo, f"feat-{i:02d}")
889
890 with patch.object(
891 branch_module, "_commit_ancestors", wraps=branch_module._commit_ancestors
892 ) as mock_ca:
893 result = _branch(repo, "--merged", "--json")
894
895 assert result.exit_code == 0
896 data = json.loads(result.output)
897 assert len(data) == 21 # main + 20
898
899 # The ancestor set for 'main' (the into branch) must be computed exactly once,
900 # not once per branch being checked.
901 into_calls = [c for c in mock_ca.call_args_list if c.args[1] != ""]
902 # All calls with the same commit_id (main's tip) should collapse to 1.
903 unique_commit_ids = {c.args[1] for c in mock_ca.call_args_list}
904 assert len(mock_ca.call_args_list) <= len(unique_commit_ids) + 1, (
905 f"_commit_ancestors called {len(mock_ca.call_args_list)}× but only "
906 f"{len(unique_commit_ids)} unique commit IDs — ancestor set is being "
907 "recomputed per branch instead of once"
908 )
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago