cgcardona / muse public
_repo.py python
68 lines 2.2 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d 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 from __future__ import annotations
9
10 import logging
11 import os
12 import pathlib
13
14 import typer
15
16 from maestro.muse_cli.errors import ExitCode
17
18 logger = logging.getLogger(__name__)
19
20
21 def find_repo_root(start: pathlib.Path | None = None) -> pathlib.Path | None:
22 """Walk up from *start* (default ``Path.cwd()``) looking for ``.muse/``.
23
24 Returns the first directory that contains ``.muse/``, or ``None`` if no
25 such ancestor exists. Never raises — callers decide what to do on miss.
26
27 The ``MUSE_REPO_ROOT`` environment variable overrides discovery entirely;
28 set it in tests to avoid ``os.chdir`` calls.
29 """
30 # Env-var override — useful for tests and tooling wrappers.
31 if env_root := os.environ.get("MUSE_REPO_ROOT"):
32 p = pathlib.Path(env_root).resolve()
33 logger.debug("⚠️ MUSE_REPO_ROOT override active: %s", p)
34 return p if (p / ".muse").is_dir() else None
35
36 current = (start or pathlib.Path.cwd()).resolve()
37 while True:
38 if (current / ".muse").is_dir():
39 return current
40 parent = current.parent
41 if parent == current:
42 return None
43 current = parent
44
45
46 _NOT_A_REPO_MSG = (
47 'fatal: not a muse repository (or any parent up to mount point /)\n'
48 'Run "muse init" to initialize a new repository.'
49 )
50
51
52 def require_repo(start: pathlib.Path | None = None) -> pathlib.Path:
53 """Return the repo root or exit 2 with a clear error message.
54
55 Wraps ``find_repo_root()`` for command callbacks that must be inside a
56 Muse repository. The error text intentionally echoes to stdout so that
57 ``typer.testing.CliRunner`` captures it in ``result.output`` without
58 needing ``mix_stderr=True``.
59 """
60 root = find_repo_root(start)
61 if root is None:
62 typer.echo(_NOT_A_REPO_MSG)
63 raise typer.Exit(code=ExitCode.REPO_NOT_FOUND)
64 return root
65
66
67 #: Public alias matching the function name specified.
68 require_repo_root = require_repo