cgcardona / muse public
repo.py python
66 lines 2.1 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 1d ago
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