test_repo.py
python
| 1 | """Tests for ``maestro.muse_cli._repo`` and public ``maestro.muse_cli.repo``. |
| 2 | |
| 3 | Covers every acceptance criterion: |
| 4 | |
| 5 | - Returns current dir if ``.muse/`` is present |
| 6 | - Traverses up and finds a parent ``.muse/`` |
| 7 | - Returns ``None`` (never raises) when no ``.muse/`` ancestor exists |
| 8 | - ``MUSE_REPO_ROOT`` env-var takes precedence over traversal |
| 9 | - ``require_repo_root()`` exits 2 with the standardised git-style error |
| 10 | message when no repo is found |
| 11 | - Public ``repo.py`` module re-exports ``find_repo_root`` and |
| 12 | ``require_repo_root`` identically to the private ``_repo`` module |
| 13 | - ``MuseNotARepoError`` alias exists in ``errors.py`` |
| 14 | |
| 15 | All tests use ``tmp_path`` and ``monkeypatch`` for isolation. |
| 16 | """ |
| 17 | from __future__ import annotations |
| 18 | |
| 19 | import os |
| 20 | import pathlib |
| 21 | |
| 22 | import pytest |
| 23 | from typer.testing import CliRunner |
| 24 | |
| 25 | from maestro.muse_cli._repo import find_repo_root, require_repo, require_repo_root |
| 26 | from maestro.muse_cli.app import cli |
| 27 | from maestro.muse_cli.errors import ExitCode, MuseNotARepoError, RepoNotFoundError |
| 28 | |
| 29 | runner = CliRunner() |
| 30 | |
| 31 | |
| 32 | # --------------------------------------------------------------------------- |
| 33 | # find_repo_root() |
| 34 | # --------------------------------------------------------------------------- |
| 35 | |
| 36 | |
| 37 | def test_find_repo_root_current_dir(tmp_path: pathlib.Path) -> None: |
| 38 | """Returns current directory when ``.muse/`` is present there.""" |
| 39 | (tmp_path / ".muse").mkdir() |
| 40 | root = find_repo_root(tmp_path) |
| 41 | assert root == tmp_path |
| 42 | |
| 43 | |
| 44 | def test_find_repo_root_parent_dir(tmp_path: pathlib.Path) -> None: |
| 45 | """Traverses up and finds a ``.muse/`` in a parent directory.""" |
| 46 | (tmp_path / ".muse").mkdir() |
| 47 | nested = tmp_path / "project" / "subdir" |
| 48 | nested.mkdir(parents=True) |
| 49 | |
| 50 | root = find_repo_root(nested) |
| 51 | assert root == tmp_path |
| 52 | |
| 53 | |
| 54 | def test_find_repo_root_returns_none_outside_repo(tmp_path: pathlib.Path) -> None: |
| 55 | """Returns ``None`` (not an exception) when no ``.muse/`` ancestor exists.""" |
| 56 | # tmp_path has no .muse/ and is an isolated temp dir. |
| 57 | root = find_repo_root(tmp_path) |
| 58 | assert root is None |
| 59 | |
| 60 | |
| 61 | def test_find_repo_root_uses_cwd_when_no_start( |
| 62 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 63 | ) -> None: |
| 64 | """With no ``start`` argument, uses ``Path.cwd()`` as the start.""" |
| 65 | (tmp_path / ".muse").mkdir() |
| 66 | monkeypatch.chdir(tmp_path) |
| 67 | root = find_repo_root() |
| 68 | assert root == tmp_path |
| 69 | |
| 70 | |
| 71 | def test_find_repo_root_env_var_override( |
| 72 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 73 | ) -> None: |
| 74 | """``MUSE_REPO_ROOT`` env var takes precedence over directory traversal.""" |
| 75 | override_dir = tmp_path / "override" |
| 76 | override_dir.mkdir() |
| 77 | (override_dir / ".muse").mkdir() |
| 78 | |
| 79 | monkeypatch.setenv("MUSE_REPO_ROOT", str(override_dir)) |
| 80 | |
| 81 | # Even if there's a different .muse/ higher up, the override wins. |
| 82 | root = find_repo_root(tmp_path) |
| 83 | assert root == override_dir |
| 84 | |
| 85 | |
| 86 | def test_find_repo_root_env_var_override_invalid_returns_none( |
| 87 | tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 88 | ) -> None: |
| 89 | """``MUSE_REPO_ROOT`` pointing to a dir without ``.muse/`` returns ``None``.""" |
| 90 | no_muse_dir = tmp_path / "no_muse" |
| 91 | no_muse_dir.mkdir() |
| 92 | monkeypatch.setenv("MUSE_REPO_ROOT", str(no_muse_dir)) |
| 93 | |
| 94 | root = find_repo_root() |
| 95 | assert root is None |
| 96 | |
| 97 | |
| 98 | def test_find_repo_root_stops_at_filesystem_root(tmp_path: pathlib.Path) -> None: |
| 99 | """Traversal stops at filesystem root and returns ``None``, never loops.""" |
| 100 | # Use a temp dir that has no .muse/ in any ancestor up to its root. |
| 101 | deeply_nested = tmp_path / "a" / "b" / "c" |
| 102 | deeply_nested.mkdir(parents=True) |
| 103 | root = find_repo_root(deeply_nested) |
| 104 | assert root is None |
| 105 | |
| 106 | |
| 107 | # --------------------------------------------------------------------------- |
| 108 | # require_repo() |
| 109 | # --------------------------------------------------------------------------- |
| 110 | |
| 111 | |
| 112 | def test_require_repo_exits_2_when_no_muse(tmp_path: pathlib.Path) -> None: |
| 113 | """``require_repo()`` from the CLI exits 2 when outside a Muse repo.""" |
| 114 | prev = os.getcwd() |
| 115 | try: |
| 116 | os.chdir(tmp_path) |
| 117 | result = runner.invoke(cli, ["status"]) |
| 118 | assert result.exit_code == int(ExitCode.REPO_NOT_FOUND) |
| 119 | finally: |
| 120 | os.chdir(prev) |
| 121 | |
| 122 | |
| 123 | def test_require_repo_error_message_matches_standard(tmp_path: pathlib.Path) -> None: |
| 124 | """The error message matches the git-style format specified.""" |
| 125 | prev = os.getcwd() |
| 126 | try: |
| 127 | os.chdir(tmp_path) |
| 128 | result = runner.invoke(cli, ["status"]) |
| 129 | assert "fatal: not a muse repository" in result.output |
| 130 | assert "muse init" in result.output |
| 131 | finally: |
| 132 | os.chdir(prev) |
| 133 | |
| 134 | |
| 135 | def test_require_repo_returns_root_when_found(tmp_path: pathlib.Path) -> None: |
| 136 | """``require_repo()`` returns the resolved root path when ``.muse/`` exists.""" |
| 137 | (tmp_path / ".muse").mkdir() |
| 138 | root = require_repo(tmp_path) |
| 139 | assert root == tmp_path |
| 140 | |
| 141 | |
| 142 | # --------------------------------------------------------------------------- |
| 143 | # Issue #46 additions — aliases and public module |
| 144 | # --------------------------------------------------------------------------- |
| 145 | |
| 146 | |
| 147 | def test_require_repo_root_alias_is_identical(tmp_path: pathlib.Path) -> None: |
| 148 | """``require_repo_root`` is the same callable as ``require_repo``.""" |
| 149 | assert require_repo_root is require_repo |
| 150 | |
| 151 | |
| 152 | def test_muse_not_a_repo_error_alias(tmp_path: pathlib.Path) -> None: |
| 153 | """``MuseNotARepoError`` is the canonical alias for ``RepoNotFoundError``.""" |
| 154 | assert MuseNotARepoError is RepoNotFoundError |
| 155 | |
| 156 | |
| 157 | def test_public_repo_module_exports_find_repo_root(tmp_path: pathlib.Path) -> None: |
| 158 | """Public ``maestro.muse_cli.repo`` re-exports ``find_repo_root``.""" |
| 159 | from maestro.muse_cli.repo import find_repo_root as public_fn |
| 160 | |
| 161 | (tmp_path / ".muse").mkdir() |
| 162 | assert public_fn(tmp_path) == tmp_path |
| 163 | |
| 164 | |
| 165 | def test_public_repo_module_exports_require_repo_root(tmp_path: pathlib.Path) -> None: |
| 166 | """Public ``maestro.muse_cli.repo`` re-exports ``require_repo_root``.""" |
| 167 | from maestro.muse_cli.repo import require_repo_root as public_fn |
| 168 | from maestro.muse_cli._repo import require_repo_root as private_fn |
| 169 | |
| 170 | assert public_fn is private_fn |