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