repo.py
python
| 1 | """Repository detection utilities for the Muse CLI. |
| 2 | |
| 3 | Walking up the directory tree to locate a ``.muse/`` directory is the |
| 4 | single most-called internal primitive. Every subcommand uses it. Keeping |
| 5 | the semantics clear (``None`` on miss, never raises) makes callers simpler |
| 6 | and test isolation easier (``MUSE_REPO_ROOT`` env-var override). |
| 7 | """ |
| 8 | |
| 9 | import logging |
| 10 | import os |
| 11 | import pathlib |
| 12 | |
| 13 | import typer |
| 14 | |
| 15 | from muse.core.errors import ExitCode |
| 16 | |
| 17 | logger = logging.getLogger(__name__) |
| 18 | |
| 19 | |
| 20 | def find_repo_root(start: pathlib.Path | None = None) -> pathlib.Path | None: |
| 21 | """Walk up from *start* (default ``Path.cwd()``) looking for ``.muse/``. |
| 22 | |
| 23 | Returns the first directory that contains ``.muse/``, or ``None`` if no |
| 24 | such ancestor exists. Never raises — callers decide what to do on miss. |
| 25 | |
| 26 | The ``MUSE_REPO_ROOT`` environment variable overrides discovery entirely; |
| 27 | set it in tests to avoid ``os.chdir`` calls. |
| 28 | """ |
| 29 | if env_root := os.environ.get("MUSE_REPO_ROOT"): |
| 30 | p = pathlib.Path(env_root).resolve() |
| 31 | logger.debug("⚠️ MUSE_REPO_ROOT override active: %s", p) |
| 32 | return p if (p / ".muse").is_dir() else None |
| 33 | |
| 34 | current = (start or pathlib.Path.cwd()).resolve() |
| 35 | while True: |
| 36 | if (current / ".muse").is_dir(): |
| 37 | return current |
| 38 | parent = current.parent |
| 39 | if parent == current: |
| 40 | return None |
| 41 | current = parent |
| 42 | |
| 43 | |
| 44 | _NOT_A_REPO_MSG = ( |
| 45 | 'fatal: not a muse repository (or any parent up to mount point /)\n' |
| 46 | 'Run "muse init" to initialize a new repository.' |
| 47 | ) |
| 48 | |
| 49 | |
| 50 | def require_repo(start: pathlib.Path | None = None) -> pathlib.Path: |
| 51 | """Return the repo root or exit 2 with a clear error message. |
| 52 | |
| 53 | Wraps ``find_repo_root()`` for command callbacks that must be inside a |
| 54 | Muse repository. The error text intentionally echoes to stdout so that |
| 55 | ``typer.testing.CliRunner`` captures it in ``result.output`` without |
| 56 | needing ``mix_stderr=True``. |
| 57 | """ |
| 58 | root = find_repo_root(start) |
| 59 | if root is None: |
| 60 | typer.echo(_NOT_A_REPO_MSG) |
| 61 | raise typer.Exit(code=ExitCode.REPO_NOT_FOUND) |
| 62 | return root |
| 63 | |
| 64 | |
| 65 | #: Public alias. |
| 66 | require_repo_root = require_repo |