gabriel / muse public
test_cmd_branch.py python
227 lines 9.2 KB
86000da9 fix: replace typer CliRunner with argparse-compatible test helper Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """Comprehensive tests for ``muse branch``.
2
3 Covers:
4 - Unit: _list_branches helper behaviour
5 - Integration: create, delete, list with committed repo
6 - E2E: full CLI round-trips via CliRunner
7 - Security: invalid branch names rejected, no path traversal
8 - Stress: many branches listed and deleted efficiently
9 """
10
11 from __future__ import annotations
12
13 import json
14 import pathlib
15 import uuid
16
17 import pytest
18 from tests.cli_test_helper import CliRunner
19
20 cli = None # argparse migration — CliRunner ignores this arg
21
22 runner = CliRunner()
23
24
25 # ---------------------------------------------------------------------------
26 # Helpers
27 # ---------------------------------------------------------------------------
28
29 def _env(root: pathlib.Path) -> dict[str, str]:
30 return {"MUSE_REPO_ROOT": str(root)}
31
32
33 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
34 muse_dir = tmp_path / ".muse"
35 muse_dir.mkdir()
36 repo_id = str(uuid.uuid4())
37 (muse_dir / "repo.json").write_text(json.dumps({
38 "repo_id": repo_id,
39 "domain": "midi",
40 "default_branch": "main",
41 "created_at": "2025-01-01T00:00:00+00:00",
42 }), encoding="utf-8")
43 (muse_dir / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
44 refs_dir = muse_dir / "refs" / "heads"
45 refs_dir.mkdir(parents=True)
46 (muse_dir / "snapshots").mkdir()
47 (muse_dir / "commits").mkdir()
48 (muse_dir / "objects").mkdir()
49 return tmp_path
50
51
52 def _make_commit(root: pathlib.Path, branch: str = "main", message: str = "init") -> str:
53 import datetime
54 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
55 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
56
57 repo_id = json.loads((root / ".muse" / "repo.json").read_text())["repo_id"]
58 ref_file = root / ".muse" / "refs" / "heads" / branch
59 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
60 manifest: dict[str, str] = {}
61 snap_id = compute_snapshot_id(manifest)
62 committed_at = datetime.datetime.now(datetime.timezone.utc)
63 commit_id = compute_commit_id(
64 parent_ids=[parent_id] if parent_id else [],
65 snapshot_id=snap_id,
66 message=message,
67 committed_at_iso=committed_at.isoformat(),
68 )
69 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
70 write_commit(root, CommitRecord(
71 commit_id=commit_id, repo_id=repo_id, branch=branch,
72 snapshot_id=snap_id, message=message, committed_at=committed_at,
73 parent_commit_id=parent_id,
74 ))
75 ref_file.parent.mkdir(parents=True, exist_ok=True)
76 ref_file.write_text(commit_id, encoding="utf-8")
77 return commit_id
78
79
80 # ---------------------------------------------------------------------------
81 # Unit tests
82 # ---------------------------------------------------------------------------
83
84 class TestBranchUnit:
85 def test_list_branches_empty_repo(self, tmp_path: pathlib.Path) -> None:
86 root = _init_repo(tmp_path)
87 from muse.cli.commands.branch import _list_branches
88 assert _list_branches(root) == []
89
90 def test_list_branches_after_create(self, tmp_path: pathlib.Path) -> None:
91 root = _init_repo(tmp_path)
92 refs_dir = root / ".muse" / "refs" / "heads"
93 (refs_dir / "main").write_text("a" * 64, encoding="utf-8")
94 (refs_dir / "dev").write_text("b" * 64, encoding="utf-8")
95 from muse.cli.commands.branch import _list_branches
96 assert _list_branches(root) == ["dev", "main"]
97
98
99 # ---------------------------------------------------------------------------
100 # Integration tests
101 # ---------------------------------------------------------------------------
102
103 class TestBranchIntegration:
104 def test_create_and_list_branch(self, tmp_path: pathlib.Path) -> None:
105 root = _init_repo(tmp_path)
106 _make_commit(root, "main")
107 result = runner.invoke(cli, ["branch", "feat/test"], env=_env(root), catch_exceptions=False)
108 assert result.exit_code == 0
109 assert "Created branch" in result.output
110
111 result2 = runner.invoke(cli, ["branch"], env=_env(root), catch_exceptions=False)
112 assert "feat/test" in result2.output
113 assert "main" in result2.output
114
115 def test_list_marks_current_branch(self, tmp_path: pathlib.Path) -> None:
116 root = _init_repo(tmp_path)
117 _make_commit(root, "main")
118 result = runner.invoke(cli, ["branch"], env=_env(root), catch_exceptions=False)
119 assert "* main" in result.output
120
121 def test_verbose_shows_commit_sha(self, tmp_path: pathlib.Path) -> None:
122 root = _init_repo(tmp_path)
123 commit_id = _make_commit(root, "main")
124 result = runner.invoke(cli, ["branch", "--verbose"], env=_env(root), catch_exceptions=False)
125 assert commit_id[:8] in result.output
126
127 def test_delete_branch(self, tmp_path: pathlib.Path) -> None:
128 root = _init_repo(tmp_path)
129 _make_commit(root, "main")
130 runner.invoke(cli, ["branch", "to-delete"], env=_env(root), catch_exceptions=False)
131 result = runner.invoke(cli, ["branch", "--delete", "to-delete"], env=_env(root), catch_exceptions=False)
132 assert result.exit_code == 0
133 assert "Deleted" in result.output
134
135 result2 = runner.invoke(cli, ["branch"], env=_env(root), catch_exceptions=False)
136 assert "to-delete" not in result2.output
137
138 def test_delete_current_branch_rejected(self, tmp_path: pathlib.Path) -> None:
139 root = _init_repo(tmp_path)
140 _make_commit(root, "main")
141 result = runner.invoke(cli, ["branch", "--delete", "main"], env=_env(root))
142 assert result.exit_code != 0
143 assert "Cannot delete" in result.output
144
145 def test_duplicate_branch_rejected(self, tmp_path: pathlib.Path) -> None:
146 root = _init_repo(tmp_path)
147 _make_commit(root, "main")
148 runner.invoke(cli, ["branch", "dup"], env=_env(root), catch_exceptions=False)
149 result = runner.invoke(cli, ["branch", "dup"], env=_env(root))
150 assert result.exit_code != 0
151 assert "already exists" in result.output
152
153 def test_short_flag_delete(self, tmp_path: pathlib.Path) -> None:
154 root = _init_repo(tmp_path)
155 _make_commit(root, "main")
156 runner.invoke(cli, ["branch", "shortflag"], env=_env(root), catch_exceptions=False)
157 result = runner.invoke(cli, ["branch", "-d", "shortflag"], env=_env(root), catch_exceptions=False)
158 assert result.exit_code == 0
159
160 def test_short_flag_verbose(self, tmp_path: pathlib.Path) -> None:
161 root = _init_repo(tmp_path)
162 commit_id = _make_commit(root, "main")
163 result = runner.invoke(cli, ["branch", "-v"], env=_env(root), catch_exceptions=False)
164 assert commit_id[:8] in result.output
165
166
167 # ---------------------------------------------------------------------------
168 # Security tests
169 # ---------------------------------------------------------------------------
170
171 class TestBranchSecurity:
172 def test_invalid_branch_name_double_dot(self, tmp_path: pathlib.Path) -> None:
173 root = _init_repo(tmp_path)
174 result = runner.invoke(cli, ["branch", "../evil"], env=_env(root))
175 assert result.exit_code != 0
176
177 def test_invalid_branch_name_slash_prefix(self, tmp_path: pathlib.Path) -> None:
178 root = _init_repo(tmp_path)
179 result = runner.invoke(cli, ["branch", "/etc/passwd"], env=_env(root))
180 assert result.exit_code != 0
181
182 def test_invalid_delete_name(self, tmp_path: pathlib.Path) -> None:
183 root = _init_repo(tmp_path)
184 result = runner.invoke(cli, ["branch", "--delete", "../../../etc"], env=_env(root))
185 assert result.exit_code != 0
186
187 def test_delete_nonexistent_branch(self, tmp_path: pathlib.Path) -> None:
188 root = _init_repo(tmp_path)
189 result = runner.invoke(cli, ["branch", "--delete", "ghost"], env=_env(root))
190 assert result.exit_code != 0
191 assert "not found" in result.output
192
193
194 # ---------------------------------------------------------------------------
195 # Stress tests
196 # ---------------------------------------------------------------------------
197
198 class TestBranchStress:
199 def test_many_branches_list_performance(self, tmp_path: pathlib.Path) -> None:
200 root = _init_repo(tmp_path)
201 _make_commit(root, "main")
202 refs_dir = root / ".muse" / "refs" / "heads"
203 commit_id = (refs_dir / "main").read_text().strip()
204
205 for i in range(100):
206 (refs_dir / f"feat-{i:03d}").write_text(commit_id, encoding="utf-8")
207
208 result = runner.invoke(cli, ["branch"], env=_env(root), catch_exceptions=False)
209 assert result.exit_code == 0
210 assert "feat-000" in result.output
211 assert "feat-099" in result.output
212
213 def test_delete_many_branches(self, tmp_path: pathlib.Path) -> None:
214 root = _init_repo(tmp_path)
215 _make_commit(root, "main")
216 refs_dir = root / ".muse" / "refs" / "heads"
217 commit_id = (refs_dir / "main").read_text().strip()
218
219 for i in range(20):
220 (refs_dir / f"temp-{i}").write_text(commit_id, encoding="utf-8")
221
222 for i in range(20):
223 result = runner.invoke(cli, ["branch", "--delete", f"temp-{i}"], env=_env(root), catch_exceptions=False)
224 assert result.exit_code == 0
225
226 result = runner.invoke(cli, ["branch"], env=_env(root), catch_exceptions=False)
227 assert "temp-" not in result.output