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