"""Tests for ``muse remote`` subcommands. Covers acceptance criteria from issues #38: - ``muse remote add origin `` writes to ``.muse/config.toml``. - ``muse remote -v`` prints all remotes with their URLs. - ``muse remote remove `` removes config entry and tracking refs. - ``muse remote rename `` renames config entry and tracking ref paths. - ``muse remote set-url `` updates URL without touching refs. - URL validation: non-http(s) URLs are rejected with exit 1. - Duplicate ``add`` overwrites the existing URL. - ``muse remote`` outside a repo exits 2. - All three new subcommands error clearly if the remote doesn't exist. """ from __future__ import annotations import os import pathlib import pytest from typer.testing import CliRunner from maestro.muse_cli.app import cli from maestro.muse_cli.config import get_remote, list_remotes, set_remote, set_remote_head runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path: """Create a minimal .muse/ repo structure under tmp_path.""" import json muse_dir = tmp_path / ".muse" muse_dir.mkdir() (muse_dir / "repo.json").write_text( json.dumps({"repo_id": "test-repo-id"}), encoding="utf-8" ) (muse_dir / "HEAD").write_text("refs/heads/main", encoding="utf-8") return tmp_path # --------------------------------------------------------------------------- # test_remote_add_writes_config # --------------------------------------------------------------------------- def test_remote_add_writes_config(tmp_path: pathlib.Path) -> None: """muse remote add writes [remotes.] url to .muse/config.toml.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "add", "origin", "https://hub.example.com/musehub/repos/my-repo"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0, result.output url = get_remote("origin", root) assert url == "https://hub.example.com/musehub/repos/my-repo" def test_remote_add_multiple_remotes(tmp_path: pathlib.Path) -> None: """Adding two remotes stores both independently in config.toml.""" root = _init_repo(tmp_path) runner.invoke( cli, ["remote", "add", "origin", "https://hub.example.com/musehub/repos/repo-a"], env={"MUSE_REPO_ROOT": str(root)}, ) runner.invoke( cli, ["remote", "add", "staging", "https://staging.example.com/musehub/repos/repo-a"], env={"MUSE_REPO_ROOT": str(root)}, ) remotes = list_remotes(root) names = [r["name"] for r in remotes] assert "origin" in names assert "staging" in names def test_remote_add_overwrites_existing(tmp_path: pathlib.Path) -> None: """muse remote add with an existing name updates the URL.""" root = _init_repo(tmp_path) set_remote("origin", "https://old.example.com", root) runner.invoke( cli, ["remote", "add", "origin", "https://new.example.com/musehub/repos/x"], env={"MUSE_REPO_ROOT": str(root)}, ) url = get_remote("origin", root) assert url == "https://new.example.com/musehub/repos/x" def test_remote_add_invalid_url_exits_1(tmp_path: pathlib.Path) -> None: """muse remote add rejects non-http(s) URLs with exit code 1.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "add", "origin", "ftp://bad-url.example.com"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 1 assert "http" in result.output.lower() # --------------------------------------------------------------------------- # test_remote_v_shows_remotes # --------------------------------------------------------------------------- def test_remote_v_shows_remotes(tmp_path: pathlib.Path) -> None: """muse remote -v prints name and URL for each configured remote.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/my-repo", root) result = runner.invoke( cli, ["remote", "-v"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0, result.output assert "origin" in result.output assert "https://hub.example.com/musehub/repos/my-repo" in result.output def test_remote_v_no_remotes_shows_hint(tmp_path: pathlib.Path) -> None: """muse remote -v with no remotes configured prints a helpful hint.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "-v"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0 assert "no remotes" in result.output.lower() def test_remote_outside_repo_exits_2(tmp_path: pathlib.Path) -> None: """muse remote outside a repo exits with code 2.""" result = runner.invoke( cli, ["remote", "-v"], env={"MUSE_REPO_ROOT": str(tmp_path)}, ) assert result.exit_code == 2 # --------------------------------------------------------------------------- # test_remote_add_output_confirms_success # --------------------------------------------------------------------------- def test_remote_add_output_confirms_success(tmp_path: pathlib.Path) -> None: """muse remote add echoes a confirmation including the remote name.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "add", "origin", "https://hub.example.com/musehub/repos/r"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0 assert "origin" in result.output # --------------------------------------------------------------------------- # muse remote remove # --------------------------------------------------------------------------- def test_remote_remove_cleans_config_and_refs(tmp_path: pathlib.Path) -> None: """Regression: muse remote remove deletes config entry and tracking refs dir.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/r", root) set_remote_head("origin", "main", "abc123", root) refs_dir = root / ".muse" / "remotes" / "origin" assert refs_dir.is_dir(), "tracking refs dir should exist after set_remote_head" result = runner.invoke( cli, ["remote", "remove", "origin"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0, result.output assert "origin" in result.output assert get_remote("origin", root) is None assert not refs_dir.exists(), "tracking refs dir should be removed" def test_remote_remove_leaves_other_remotes(tmp_path: pathlib.Path) -> None: """muse remote remove only removes the named remote, leaving others intact.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/r", root) set_remote("staging", "https://staging.example.com/musehub/repos/r", root) runner.invoke( cli, ["remote", "remove", "origin"], env={"MUSE_REPO_ROOT": str(root)}, ) assert get_remote("origin", root) is None assert get_remote("staging", root) == "https://staging.example.com/musehub/repos/r" def test_remote_remove_nonexistent_errors(tmp_path: pathlib.Path) -> None: """muse remote remove errors with exit 1 when the remote does not exist.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "remove", "nonexistent"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 1 assert "does not exist" in result.output.lower() def test_remote_remove_no_refs_dir_succeeds(tmp_path: pathlib.Path) -> None: """muse remote remove succeeds even when no tracking refs dir exists.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/r", root) result = runner.invoke( cli, ["remote", "remove", "origin"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0, result.output assert get_remote("origin", root) is None # --------------------------------------------------------------------------- # muse remote rename # --------------------------------------------------------------------------- def test_remote_rename_updates_config_and_ref_paths(tmp_path: pathlib.Path) -> None: """muse remote rename moves config entry and tracking refs from old to new name.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/r", root) set_remote_head("origin", "main", "abc123", root) old_refs_dir = root / ".muse" / "remotes" / "origin" assert old_refs_dir.is_dir() result = runner.invoke( cli, ["remote", "rename", "origin", "upstream"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0, result.output assert "upstream" in result.output assert get_remote("origin", root) is None assert get_remote("upstream", root) == "https://hub.example.com/musehub/repos/r" new_refs_dir = root / ".muse" / "remotes" / "upstream" assert new_refs_dir.is_dir(), "tracking refs dir should be renamed" assert not old_refs_dir.exists(), "old tracking refs dir should be gone" def test_remote_rename_nonexistent_errors(tmp_path: pathlib.Path) -> None: """muse remote rename errors with exit 1 when old remote does not exist.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "rename", "ghost", "upstream"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 1 assert "does not exist" in result.output.lower() def test_remote_rename_conflict_errors(tmp_path: pathlib.Path) -> None: """muse remote rename errors with exit 1 when the new name already exists.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/r", root) set_remote("upstream", "https://other.example.com/musehub/repos/r", root) result = runner.invoke( cli, ["remote", "rename", "origin", "upstream"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 1 assert "already exists" in result.output.lower() # --------------------------------------------------------------------------- # muse remote set-url # --------------------------------------------------------------------------- def test_remote_set_url_updates_config_only(tmp_path: pathlib.Path) -> None: """muse remote set-url updates the URL in config without touching refs.""" root = _init_repo(tmp_path) set_remote("origin", "https://old.example.com/musehub/repos/r", root) set_remote_head("origin", "main", "abc123", root) result = runner.invoke( cli, ["remote", "set-url", "origin", "https://new.example.com/musehub/repos/r"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 0, result.output assert "new.example.com" in result.output assert get_remote("origin", root) == "https://new.example.com/musehub/repos/r" # Tracking refs must be untouched refs_dir = root / ".muse" / "remotes" / "origin" assert refs_dir.is_dir(), "tracking refs dir should still exist after set-url" def test_remote_set_url_nonexistent_errors(tmp_path: pathlib.Path) -> None: """muse remote set-url errors with exit 1 when the remote does not exist.""" root = _init_repo(tmp_path) result = runner.invoke( cli, ["remote", "set-url", "ghost", "https://new.example.com/musehub/repos/r"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 1 assert "does not exist" in result.output.lower() def test_remote_set_url_invalid_scheme_errors(tmp_path: pathlib.Path) -> None: """muse remote set-url rejects non-http(s) URLs with exit 1.""" root = _init_repo(tmp_path) set_remote("origin", "https://hub.example.com/musehub/repos/r", root) result = runner.invoke( cli, ["remote", "set-url", "origin", "ftp://bad.example.com"], env={"MUSE_REPO_ROOT": str(root)}, ) assert result.exit_code == 1 assert "http" in result.output.lower()