cgcardona / muse public
test_remote.py python
346 lines 12.1 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse remote`` subcommands.
2
3 Covers acceptance criteria from issues #38:
4 - ``muse remote add origin <url>`` writes to ``.muse/config.toml``.
5 - ``muse remote -v`` prints all remotes with their URLs.
6 - ``muse remote remove <name>`` removes config entry and tracking refs.
7 - ``muse remote rename <old> <new>`` renames config entry and tracking ref paths.
8 - ``muse remote set-url <name> <url>`` updates URL without touching refs.
9 - URL validation: non-http(s) URLs are rejected with exit 1.
10 - Duplicate ``add`` overwrites the existing URL.
11 - ``muse remote`` outside a repo exits 2.
12 - All three new subcommands error clearly if the remote doesn't exist.
13 """
14 from __future__ import annotations
15
16 import os
17 import pathlib
18
19 import pytest
20 from typer.testing import CliRunner
21
22 from maestro.muse_cli.app import cli
23 from maestro.muse_cli.config import get_remote, list_remotes, set_remote, set_remote_head
24
25
26 runner = CliRunner()
27
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33
34 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
35 """Create a minimal .muse/ repo structure under tmp_path."""
36 import json
37 muse_dir = tmp_path / ".muse"
38 muse_dir.mkdir()
39 (muse_dir / "repo.json").write_text(
40 json.dumps({"repo_id": "test-repo-id"}), encoding="utf-8"
41 )
42 (muse_dir / "HEAD").write_text("refs/heads/main", encoding="utf-8")
43 return tmp_path
44
45
46 # ---------------------------------------------------------------------------
47 # test_remote_add_writes_config
48 # ---------------------------------------------------------------------------
49
50
51 def test_remote_add_writes_config(tmp_path: pathlib.Path) -> None:
52 """muse remote add writes [remotes.<name>] url to .muse/config.toml."""
53 root = _init_repo(tmp_path)
54 result = runner.invoke(
55 cli,
56 ["remote", "add", "origin", "https://hub.example.com/musehub/repos/my-repo"],
57 env={"MUSE_REPO_ROOT": str(root)},
58 )
59 assert result.exit_code == 0, result.output
60
61 url = get_remote("origin", root)
62 assert url == "https://hub.example.com/musehub/repos/my-repo"
63
64
65 def test_remote_add_multiple_remotes(tmp_path: pathlib.Path) -> None:
66 """Adding two remotes stores both independently in config.toml."""
67 root = _init_repo(tmp_path)
68
69 runner.invoke(
70 cli,
71 ["remote", "add", "origin", "https://hub.example.com/musehub/repos/repo-a"],
72 env={"MUSE_REPO_ROOT": str(root)},
73 )
74 runner.invoke(
75 cli,
76 ["remote", "add", "staging", "https://staging.example.com/musehub/repos/repo-a"],
77 env={"MUSE_REPO_ROOT": str(root)},
78 )
79
80 remotes = list_remotes(root)
81 names = [r["name"] for r in remotes]
82 assert "origin" in names
83 assert "staging" in names
84
85
86 def test_remote_add_overwrites_existing(tmp_path: pathlib.Path) -> None:
87 """muse remote add with an existing name updates the URL."""
88 root = _init_repo(tmp_path)
89 set_remote("origin", "https://old.example.com", root)
90
91 runner.invoke(
92 cli,
93 ["remote", "add", "origin", "https://new.example.com/musehub/repos/x"],
94 env={"MUSE_REPO_ROOT": str(root)},
95 )
96
97 url = get_remote("origin", root)
98 assert url == "https://new.example.com/musehub/repos/x"
99
100
101 def test_remote_add_invalid_url_exits_1(tmp_path: pathlib.Path) -> None:
102 """muse remote add rejects non-http(s) URLs with exit code 1."""
103 root = _init_repo(tmp_path)
104 result = runner.invoke(
105 cli,
106 ["remote", "add", "origin", "ftp://bad-url.example.com"],
107 env={"MUSE_REPO_ROOT": str(root)},
108 )
109 assert result.exit_code == 1
110 assert "http" in result.output.lower()
111
112
113 # ---------------------------------------------------------------------------
114 # test_remote_v_shows_remotes
115 # ---------------------------------------------------------------------------
116
117
118 def test_remote_v_shows_remotes(tmp_path: pathlib.Path) -> None:
119 """muse remote -v prints name and URL for each configured remote."""
120 root = _init_repo(tmp_path)
121 set_remote("origin", "https://hub.example.com/musehub/repos/my-repo", root)
122
123 result = runner.invoke(
124 cli,
125 ["remote", "-v"],
126 env={"MUSE_REPO_ROOT": str(root)},
127 )
128 assert result.exit_code == 0, result.output
129 assert "origin" in result.output
130 assert "https://hub.example.com/musehub/repos/my-repo" in result.output
131
132
133 def test_remote_v_no_remotes_shows_hint(tmp_path: pathlib.Path) -> None:
134 """muse remote -v with no remotes configured prints a helpful hint."""
135 root = _init_repo(tmp_path)
136 result = runner.invoke(
137 cli,
138 ["remote", "-v"],
139 env={"MUSE_REPO_ROOT": str(root)},
140 )
141 assert result.exit_code == 0
142 assert "no remotes" in result.output.lower()
143
144
145 def test_remote_outside_repo_exits_2(tmp_path: pathlib.Path) -> None:
146 """muse remote outside a repo exits with code 2."""
147 result = runner.invoke(
148 cli,
149 ["remote", "-v"],
150 env={"MUSE_REPO_ROOT": str(tmp_path)},
151 )
152 assert result.exit_code == 2
153
154
155 # ---------------------------------------------------------------------------
156 # test_remote_add_output_confirms_success
157 # ---------------------------------------------------------------------------
158
159
160 def test_remote_add_output_confirms_success(tmp_path: pathlib.Path) -> None:
161 """muse remote add echoes a confirmation including the remote name."""
162 root = _init_repo(tmp_path)
163 result = runner.invoke(
164 cli,
165 ["remote", "add", "origin", "https://hub.example.com/musehub/repos/r"],
166 env={"MUSE_REPO_ROOT": str(root)},
167 )
168 assert result.exit_code == 0
169 assert "origin" in result.output
170
171
172 # ---------------------------------------------------------------------------
173 # muse remote remove
174 # ---------------------------------------------------------------------------
175
176
177 def test_remote_remove_cleans_config_and_refs(tmp_path: pathlib.Path) -> None:
178 """Regression: muse remote remove deletes config entry and tracking refs dir."""
179 root = _init_repo(tmp_path)
180 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
181 set_remote_head("origin", "main", "abc123", root)
182
183 refs_dir = root / ".muse" / "remotes" / "origin"
184 assert refs_dir.is_dir(), "tracking refs dir should exist after set_remote_head"
185
186 result = runner.invoke(
187 cli,
188 ["remote", "remove", "origin"],
189 env={"MUSE_REPO_ROOT": str(root)},
190 )
191 assert result.exit_code == 0, result.output
192 assert "origin" in result.output
193
194 assert get_remote("origin", root) is None
195 assert not refs_dir.exists(), "tracking refs dir should be removed"
196
197
198 def test_remote_remove_leaves_other_remotes(tmp_path: pathlib.Path) -> None:
199 """muse remote remove only removes the named remote, leaving others intact."""
200 root = _init_repo(tmp_path)
201 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
202 set_remote("staging", "https://staging.example.com/musehub/repos/r", root)
203
204 runner.invoke(
205 cli,
206 ["remote", "remove", "origin"],
207 env={"MUSE_REPO_ROOT": str(root)},
208 )
209
210 assert get_remote("origin", root) is None
211 assert get_remote("staging", root) == "https://staging.example.com/musehub/repos/r"
212
213
214 def test_remote_remove_nonexistent_errors(tmp_path: pathlib.Path) -> None:
215 """muse remote remove errors with exit 1 when the remote does not exist."""
216 root = _init_repo(tmp_path)
217 result = runner.invoke(
218 cli,
219 ["remote", "remove", "nonexistent"],
220 env={"MUSE_REPO_ROOT": str(root)},
221 )
222 assert result.exit_code == 1
223 assert "does not exist" in result.output.lower()
224
225
226 def test_remote_remove_no_refs_dir_succeeds(tmp_path: pathlib.Path) -> None:
227 """muse remote remove succeeds even when no tracking refs dir exists."""
228 root = _init_repo(tmp_path)
229 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
230
231 result = runner.invoke(
232 cli,
233 ["remote", "remove", "origin"],
234 env={"MUSE_REPO_ROOT": str(root)},
235 )
236 assert result.exit_code == 0, result.output
237 assert get_remote("origin", root) is None
238
239
240 # ---------------------------------------------------------------------------
241 # muse remote rename
242 # ---------------------------------------------------------------------------
243
244
245 def test_remote_rename_updates_config_and_ref_paths(tmp_path: pathlib.Path) -> None:
246 """muse remote rename moves config entry and tracking refs from old to new name."""
247 root = _init_repo(tmp_path)
248 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
249 set_remote_head("origin", "main", "abc123", root)
250
251 old_refs_dir = root / ".muse" / "remotes" / "origin"
252 assert old_refs_dir.is_dir()
253
254 result = runner.invoke(
255 cli,
256 ["remote", "rename", "origin", "upstream"],
257 env={"MUSE_REPO_ROOT": str(root)},
258 )
259 assert result.exit_code == 0, result.output
260 assert "upstream" in result.output
261
262 assert get_remote("origin", root) is None
263 assert get_remote("upstream", root) == "https://hub.example.com/musehub/repos/r"
264
265 new_refs_dir = root / ".muse" / "remotes" / "upstream"
266 assert new_refs_dir.is_dir(), "tracking refs dir should be renamed"
267 assert not old_refs_dir.exists(), "old tracking refs dir should be gone"
268
269
270 def test_remote_rename_nonexistent_errors(tmp_path: pathlib.Path) -> None:
271 """muse remote rename errors with exit 1 when old remote does not exist."""
272 root = _init_repo(tmp_path)
273 result = runner.invoke(
274 cli,
275 ["remote", "rename", "ghost", "upstream"],
276 env={"MUSE_REPO_ROOT": str(root)},
277 )
278 assert result.exit_code == 1
279 assert "does not exist" in result.output.lower()
280
281
282 def test_remote_rename_conflict_errors(tmp_path: pathlib.Path) -> None:
283 """muse remote rename errors with exit 1 when the new name already exists."""
284 root = _init_repo(tmp_path)
285 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
286 set_remote("upstream", "https://other.example.com/musehub/repos/r", root)
287
288 result = runner.invoke(
289 cli,
290 ["remote", "rename", "origin", "upstream"],
291 env={"MUSE_REPO_ROOT": str(root)},
292 )
293 assert result.exit_code == 1
294 assert "already exists" in result.output.lower()
295
296
297 # ---------------------------------------------------------------------------
298 # muse remote set-url
299 # ---------------------------------------------------------------------------
300
301
302 def test_remote_set_url_updates_config_only(tmp_path: pathlib.Path) -> None:
303 """muse remote set-url updates the URL in config without touching refs."""
304 root = _init_repo(tmp_path)
305 set_remote("origin", "https://old.example.com/musehub/repos/r", root)
306 set_remote_head("origin", "main", "abc123", root)
307
308 result = runner.invoke(
309 cli,
310 ["remote", "set-url", "origin", "https://new.example.com/musehub/repos/r"],
311 env={"MUSE_REPO_ROOT": str(root)},
312 )
313 assert result.exit_code == 0, result.output
314 assert "new.example.com" in result.output
315
316 assert get_remote("origin", root) == "https://new.example.com/musehub/repos/r"
317
318 # Tracking refs must be untouched
319 refs_dir = root / ".muse" / "remotes" / "origin"
320 assert refs_dir.is_dir(), "tracking refs dir should still exist after set-url"
321
322
323 def test_remote_set_url_nonexistent_errors(tmp_path: pathlib.Path) -> None:
324 """muse remote set-url errors with exit 1 when the remote does not exist."""
325 root = _init_repo(tmp_path)
326 result = runner.invoke(
327 cli,
328 ["remote", "set-url", "ghost", "https://new.example.com/musehub/repos/r"],
329 env={"MUSE_REPO_ROOT": str(root)},
330 )
331 assert result.exit_code == 1
332 assert "does not exist" in result.output.lower()
333
334
335 def test_remote_set_url_invalid_scheme_errors(tmp_path: pathlib.Path) -> None:
336 """muse remote set-url rejects non-http(s) URLs with exit 1."""
337 root = _init_repo(tmp_path)
338 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
339
340 result = runner.invoke(
341 cli,
342 ["remote", "set-url", "origin", "ftp://bad.example.com"],
343 env={"MUSE_REPO_ROOT": str(root)},
344 )
345 assert result.exit_code == 1
346 assert "http" in result.output.lower()