config.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
| 1 | """Muse CLI configuration helpers. |
| 2 | |
| 3 | Reads and writes ``.muse/config.toml`` — the per-repository configuration |
| 4 | file. Credentials (signing identities) are **not** stored here; they live in |
| 5 | ``~/.muse/identity.toml`` managed by :mod:`muse.core.identity`. |
| 6 | |
| 7 | Config schema |
| 8 | ------------- |
| 9 | :: |
| 10 | |
| 11 | [user] |
| 12 | name = "Alice" # display name (human or agent handle) |
| 13 | email = "[email protected]" |
| 14 | type = "human" # "human" | "agent" |
| 15 | |
| 16 | [hub] |
| 17 | url = "https://musehub.ai" # MuseHub fabric endpoint for this repo |
| 18 | |
| 19 | [remotes.origin] |
| 20 | url = "https://hub.muse.io/repos/my-repo" |
| 21 | branch = "main" |
| 22 | |
| 23 | [domain] |
| 24 | # Domain-specific key/value pairs; read by the active domain plugin. |
| 25 | # ticks_per_beat = "480" |
| 26 | |
| 27 | Settable via ``muse config set`` |
| 28 | --------------------------------- |
| 29 | - ``user.name``, ``user.email``, ``user.type`` |
| 30 | - ``hub.url`` (alias: ``muse hub connect <url>``) |
| 31 | - ``domain.*`` |
| 32 | |
| 33 | Not settable via ``muse config set`` |
| 34 | -------------------------------------- |
| 35 | - ``remotes.*`` — use ``muse remote add/remove`` |
| 36 | - credentials — use ``muse auth register`` |
| 37 | |
| 38 | Token resolution |
| 39 | ---------------- |
| 40 | :func:`get_signing_identity` reads the hub URL from this file, then resolves the |
| 41 | signing identity from ``~/.muse/identity.toml`` via |
| 42 | :func:`muse.core.identity.resolve_token`. The token is **never** logged. |
| 43 | """ |
| 44 | |
| 45 | from __future__ import annotations |
| 46 | |
| 47 | import logging |
| 48 | import pathlib |
| 49 | import shutil |
| 50 | import subprocess |
| 51 | import tomllib |
| 52 | from typing import TypedDict |
| 53 | |
| 54 | from muse.core.store import write_text_atomic |
| 55 | |
| 56 | logger = logging.getLogger(__name__) |
| 57 | |
| 58 | type RemotesMap = dict[str, RemoteEntry] # remote_name → remote entry |
| 59 | type DomainConfig = dict[str, str] # domain key → value |
| 60 | type ConfigSection = dict[str, str] # generic flattened section dict |
| 61 | type ConfigTree = dict[str, dict[str, str]] # section → key → value (for JSON output) |
| 62 | type DefaultsMap = dict[str, int] # config key → default int value |
| 63 | |
| 64 | _CONFIG_FILENAME = "config.toml" |
| 65 | _MUSE_DIR = ".muse" |
| 66 | |
| 67 | |
| 68 | # --------------------------------------------------------------------------- |
| 69 | # Named configuration types |
| 70 | # --------------------------------------------------------------------------- |
| 71 | |
| 72 | |
| 73 | class UserConfig(TypedDict, total=False): |
| 74 | """``[user]`` section in ``.muse/config.toml``.""" |
| 75 | |
| 76 | name: str |
| 77 | email: str |
| 78 | type: str # "human" | "agent" |
| 79 | |
| 80 | |
| 81 | class HubConfig(TypedDict, total=False): |
| 82 | """``[hub]`` section in ``.muse/config.toml``.""" |
| 83 | |
| 84 | url: str |
| 85 | |
| 86 | |
| 87 | class RemoteEntry(TypedDict, total=False): |
| 88 | """``[remotes.<name>]`` section in ``.muse/config.toml``.""" |
| 89 | |
| 90 | url: str |
| 91 | branch: str |
| 92 | pack_origin: str |
| 93 | |
| 94 | |
| 95 | class LimitsConfig(TypedDict, total=False): |
| 96 | """``[limits]`` section in ``.muse/config.toml``. |
| 97 | |
| 98 | All values are optional — defaults are used when absent. Keys map to |
| 99 | the ``[limits]`` TOML table:: |
| 100 | |
| 101 | [limits] |
| 102 | max_walk_commits = 10000 # cap for walk_commits_between / muse log |
| 103 | max_ancestors = 50000 # cap for find_merge_base BFS |
| 104 | max_graph_commits = 50000 # cap for _collect_all_commits (--graph --all) |
| 105 | shard_prefix_length = 2 # object store shard depth: 2 (256 shards) |
| 106 | # or 4 (65536 shards) for very large repos |
| 107 | """ |
| 108 | |
| 109 | max_walk_commits: int |
| 110 | max_ancestors: int |
| 111 | max_graph_commits: int |
| 112 | shard_prefix_length: int |
| 113 | |
| 114 | |
| 115 | class MuseConfig(TypedDict, total=False): |
| 116 | """Structured view of the entire ``.muse/config.toml`` file.""" |
| 117 | |
| 118 | user: UserConfig |
| 119 | hub: HubConfig |
| 120 | remotes: RemotesMap |
| 121 | domain: DomainConfig |
| 122 | limits: LimitsConfig |
| 123 | |
| 124 | |
| 125 | class RemoteConfig(TypedDict, total=False): |
| 126 | """Public-facing remote descriptor returned by :func:`list_remotes`. |
| 127 | |
| 128 | ``pack_origin`` is optional. When set, pack uploads are sent to |
| 129 | ``{pack_origin}/{owner}/{slug}/push/object-pack`` instead of the default |
| 130 | ``{url}/push/object-pack``. Use this to route small-object packs through |
| 131 | a Cloudflare Worker (or equivalent edge cache) without changing the primary |
| 132 | remote URL. |
| 133 | """ |
| 134 | |
| 135 | name: str # always present |
| 136 | url: str # always present |
| 137 | pack_origin: str # optional — overrides the origin for pack uploads |
| 138 | |
| 139 | |
| 140 | # --------------------------------------------------------------------------- |
| 141 | # Internal helpers |
| 142 | # --------------------------------------------------------------------------- |
| 143 | |
| 144 | |
| 145 | def _config_path(repo_root: pathlib.Path | None) -> pathlib.Path: |
| 146 | """Return the path to .muse/config.toml for the given (or cwd) root.""" |
| 147 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 148 | return root / _MUSE_DIR / _CONFIG_FILENAME |
| 149 | |
| 150 | |
| 151 | def _load_config(config_path: pathlib.Path) -> MuseConfig: |
| 152 | """Load and parse config.toml; return an empty MuseConfig if absent.""" |
| 153 | if not config_path.is_file(): |
| 154 | return {} |
| 155 | |
| 156 | try: |
| 157 | with config_path.open("rb") as fh: |
| 158 | raw = tomllib.load(fh) |
| 159 | except Exception as exc: # noqa: BLE001 |
| 160 | logger.warning("⚠️ Failed to parse %s: %s", config_path, exc) |
| 161 | return {} |
| 162 | |
| 163 | config: MuseConfig = {} |
| 164 | |
| 165 | user_raw = raw.get("user") |
| 166 | if isinstance(user_raw, dict): |
| 167 | user: UserConfig = {} |
| 168 | name_val = user_raw.get("name") |
| 169 | if isinstance(name_val, str): |
| 170 | user["name"] = name_val |
| 171 | email_val = user_raw.get("email") |
| 172 | if isinstance(email_val, str): |
| 173 | user["email"] = email_val |
| 174 | type_val = user_raw.get("type") |
| 175 | if isinstance(type_val, str): |
| 176 | user["type"] = type_val |
| 177 | config["user"] = user |
| 178 | |
| 179 | hub_raw = raw.get("hub") |
| 180 | if isinstance(hub_raw, dict): |
| 181 | hub: HubConfig = {} |
| 182 | url_val = hub_raw.get("url") |
| 183 | if isinstance(url_val, str): |
| 184 | hub["url"] = url_val |
| 185 | config["hub"] = hub |
| 186 | |
| 187 | remotes_raw = raw.get("remotes") |
| 188 | if isinstance(remotes_raw, dict): |
| 189 | remotes: RemotesMap = {} |
| 190 | for name, remote_raw in remotes_raw.items(): |
| 191 | if isinstance(remote_raw, dict): |
| 192 | entry: RemoteEntry = {} |
| 193 | rurl = remote_raw.get("url") |
| 194 | if isinstance(rurl, str): |
| 195 | entry["url"] = rurl |
| 196 | branch_val = remote_raw.get("branch") |
| 197 | if isinstance(branch_val, str): |
| 198 | entry["branch"] = branch_val |
| 199 | pack_origin_val = remote_raw.get("pack_origin") |
| 200 | if isinstance(pack_origin_val, str): |
| 201 | entry["pack_origin"] = pack_origin_val |
| 202 | remotes[name] = entry |
| 203 | config["remotes"] = remotes |
| 204 | |
| 205 | domain_raw = raw.get("domain") |
| 206 | if isinstance(domain_raw, dict): |
| 207 | domain: DomainConfig = {} |
| 208 | for key, val in domain_raw.items(): |
| 209 | if isinstance(val, str): |
| 210 | domain[key] = val |
| 211 | config["domain"] = domain |
| 212 | |
| 213 | limits_raw = raw.get("limits") |
| 214 | if isinstance(limits_raw, dict): |
| 215 | limits: LimitsConfig = {} |
| 216 | mwc = limits_raw.get("max_walk_commits") |
| 217 | if isinstance(mwc, int) and mwc > 0: |
| 218 | limits["max_walk_commits"] = mwc |
| 219 | ma = limits_raw.get("max_ancestors") |
| 220 | if isinstance(ma, int) and ma > 0: |
| 221 | limits["max_ancestors"] = ma |
| 222 | mgc = limits_raw.get("max_graph_commits") |
| 223 | if isinstance(mgc, int) and mgc > 0: |
| 224 | limits["max_graph_commits"] = mgc |
| 225 | spl = limits_raw.get("shard_prefix_length") |
| 226 | if isinstance(spl, int) and spl in (2, 4): |
| 227 | limits["shard_prefix_length"] = spl |
| 228 | config["limits"] = limits |
| 229 | |
| 230 | return config |
| 231 | |
| 232 | |
| 233 | def _escape(value: str) -> str: |
| 234 | """Escape a TOML basic string value (backslash and double-quote only). |
| 235 | |
| 236 | TOML basic strings allow control characters escaped as ``\\n``, ``\\t``, |
| 237 | etc., but we store only printable content — control characters in values |
| 238 | are also stripped here so the resulting TOML file remains parseable. |
| 239 | """ |
| 240 | return ( |
| 241 | value.replace("\\", "\\\\") |
| 242 | .replace('"', '\\"') |
| 243 | .replace("\n", "\\n") |
| 244 | .replace("\r", "\\r") |
| 245 | .replace("\0", "") |
| 246 | ) |
| 247 | |
| 248 | |
| 249 | # Characters that are structurally significant in unquoted TOML keys and |
| 250 | # table headers. Any of these in a key name would allow injection of |
| 251 | # arbitrary TOML sections or key-value pairs. |
| 252 | _TOML_KEY_UNSAFE: frozenset[str] = frozenset('\n\r\0][="') |
| 253 | |
| 254 | |
| 255 | def _validate_toml_key(key: str, context: str = "key") -> None: |
| 256 | """Raise ``ValueError`` if *key* contains TOML-structurally unsafe characters. |
| 257 | |
| 258 | Prevents injection attacks where a crafted key like ``x]\\n[evil`` would |
| 259 | break the TOML section structure and allow writing arbitrary sections. |
| 260 | |
| 261 | Args: |
| 262 | key: Key string to validate. |
| 263 | context: Human-readable label used in the error message (e.g. ``"domain key"``). |
| 264 | |
| 265 | Raises: |
| 266 | ValueError: If any character in *key* is in ``_TOML_KEY_UNSAFE``. |
| 267 | """ |
| 268 | bad = _TOML_KEY_UNSAFE & set(key) |
| 269 | if bad: |
| 270 | chars = ", ".join(sorted(repr(c) for c in bad)) |
| 271 | raise ValueError( |
| 272 | f"Config {context} {key!r} contains characters not allowed in TOML keys: {chars}" |
| 273 | ) |
| 274 | |
| 275 | |
| 276 | def _dump_toml(config: MuseConfig) -> str: |
| 277 | """Serialise a MuseConfig to TOML text. |
| 278 | |
| 279 | Section order: ``[user]``, ``[hub]``, ``[remotes.*]``, ``[domain]``, ``[limits]``. |
| 280 | |
| 281 | All key names are validated against ``_TOML_KEY_UNSAFE`` before being |
| 282 | written, preventing TOML injection via crafted domain keys or remote names. |
| 283 | """ |
| 284 | lines: list[str] = [] |
| 285 | |
| 286 | user = config.get("user") |
| 287 | if user: |
| 288 | lines.append("[user]") |
| 289 | name = user.get("name", "") |
| 290 | if name: |
| 291 | lines.append(f'name = "{_escape(name)}"') |
| 292 | email = user.get("email", "") |
| 293 | if email: |
| 294 | lines.append(f'email = "{_escape(email)}"') |
| 295 | utype = user.get("type", "") |
| 296 | if utype: |
| 297 | lines.append(f'type = "{_escape(utype)}"') |
| 298 | lines.append("") |
| 299 | |
| 300 | hub = config.get("hub") |
| 301 | if hub: |
| 302 | lines.append("[hub]") |
| 303 | url = hub.get("url", "") |
| 304 | if url: |
| 305 | lines.append(f'url = "{_escape(url)}"') |
| 306 | lines.append("") |
| 307 | |
| 308 | remotes = config.get("remotes") or {} |
| 309 | for remote_name in sorted(remotes): |
| 310 | # Remote names come from _load_config which parses TOML, so they are |
| 311 | # safe at read time. Validate defensively before writing. |
| 312 | _validate_toml_key(remote_name, "remote name") |
| 313 | entry = remotes[remote_name] |
| 314 | lines.append(f"[remotes.{remote_name}]") |
| 315 | rurl = entry.get("url", "") |
| 316 | if rurl: |
| 317 | lines.append(f'url = "{_escape(rurl)}"') |
| 318 | branch = entry.get("branch", "") |
| 319 | if branch: |
| 320 | lines.append(f'branch = "{_escape(branch)}"') |
| 321 | lines.append("") |
| 322 | |
| 323 | domain = config.get("domain") or {} |
| 324 | if domain: |
| 325 | lines.append("[domain]") |
| 326 | for key, val in sorted(domain.items()): |
| 327 | _validate_toml_key(key, "domain key") |
| 328 | lines.append(f'{key} = "{_escape(val)}"') |
| 329 | lines.append("") |
| 330 | |
| 331 | limits = config.get("limits") or {} |
| 332 | if limits: |
| 333 | lines.append("[limits]") |
| 334 | mwc = limits.get("max_walk_commits") |
| 335 | if mwc is not None: |
| 336 | lines.append(f"max_walk_commits = {mwc}") |
| 337 | ma = limits.get("max_ancestors") |
| 338 | if ma is not None: |
| 339 | lines.append(f"max_ancestors = {ma}") |
| 340 | mgc = limits.get("max_graph_commits") |
| 341 | if mgc is not None: |
| 342 | lines.append(f"max_graph_commits = {mgc}") |
| 343 | spl = limits.get("shard_prefix_length") |
| 344 | if spl is not None: |
| 345 | lines.append(f"shard_prefix_length = {spl}") |
| 346 | lines.append("") |
| 347 | |
| 348 | return "\n".join(lines) |
| 349 | |
| 350 | |
| 351 | # --------------------------------------------------------------------------- |
| 352 | # Auth token resolution (via identity store) |
| 353 | # --------------------------------------------------------------------------- |
| 354 | |
| 355 | |
| 356 | def get_signing_identity( |
| 357 | repo_root: pathlib.Path | None = None, |
| 358 | remote_url: str | None = None, |
| 359 | agent_id: str | None = None, |
| 360 | ) -> "object | None": |
| 361 | """Return a :class:`~muse.core.transport.SigningIdentity` for a hub, or ``None``. |
| 362 | |
| 363 | Resolution order: |
| 364 | 1. ``MUSE_AGENT_KEY`` environment variable — PEM private key bytes. When |
| 365 | set the key is used directly, bypassing the identity store entirely. |
| 366 | The handle is taken from ``MUSE_AGENT_HANDLE`` (defaults to *agent_id* |
| 367 | if that is also set, otherwise ``"agent"``). This is the injection |
| 368 | mechanism for agent subprocesses spawned by agentception. |
| 369 | 2. Agent-specific entry in ``~/.muse/identity.toml`` keyed by |
| 370 | ``"hostname#agent_id"`` — when *agent_id* is provided. |
| 371 | 3. Human entry in ``~/.muse/identity.toml`` keyed by bare hostname. |
| 372 | 4. Hub URL from ``[hub] url`` in ``.muse/config.toml`` (fallback lookup |
| 373 | URL when *remote_url* is not supplied). |
| 374 | |
| 375 | The private key is **never** logged. |
| 376 | |
| 377 | Args: |
| 378 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 379 | remote_url: URL of the specific remote being contacted. |
| 380 | agent_id: Agent handle, e.g. ``"agentception-abc123"``. Used to |
| 381 | try an agent-specific key before falling back to the |
| 382 | human key. |
| 383 | |
| 384 | Returns: |
| 385 | :class:`~muse.core.transport.SigningIdentity` or ``None``. |
| 386 | """ |
| 387 | import os as _os |
| 388 | from muse.core.identity import resolve_signing_identity # avoid circular import |
| 389 | from muse.core.transport import SigningIdentity |
| 390 | |
| 391 | # 1. MUSE_AGENT_KEY env var — injected by agentception at spawn time. |
| 392 | agent_pem = _os.environ.get("MUSE_AGENT_KEY", "").strip() |
| 393 | if agent_pem: |
| 394 | from muse.core.keypair import load_private_key_from_pem |
| 395 | private_key = load_private_key_from_pem(agent_pem.encode()) |
| 396 | if private_key is not None: |
| 397 | handle = ( |
| 398 | _os.environ.get("MUSE_AGENT_HANDLE", "").strip() |
| 399 | or agent_id |
| 400 | or "agent" |
| 401 | ) |
| 402 | logger.debug("✅ Signing identity from MUSE_AGENT_KEY (handle=%s)", handle) |
| 403 | return SigningIdentity(handle=handle, private_key=private_key) |
| 404 | logger.warning("⚠️ MUSE_AGENT_KEY is set but could not be decoded — ignoring") |
| 405 | |
| 406 | # 2 & 3. Identity store lookup (agent key → human key fallback). |
| 407 | lookup_url: str | None = remote_url or get_hub_url(repo_root) |
| 408 | if lookup_url is None: |
| 409 | logger.debug("⚠️ No hub configured — skipping signing identity lookup") |
| 410 | return None |
| 411 | |
| 412 | result = resolve_signing_identity(lookup_url, agent_id=agent_id) |
| 413 | if result is None: |
| 414 | logger.debug( |
| 415 | "⚠️ No signing identity for hub %s — run `muse auth keygen && muse auth register`", |
| 416 | lookup_url, |
| 417 | ) |
| 418 | return None |
| 419 | |
| 420 | handle, private_key = result |
| 421 | logger.debug("✅ Signing identity resolved for hub %s (handle=%s)", lookup_url, handle) |
| 422 | return SigningIdentity(handle=handle, private_key=private_key) |
| 423 | |
| 424 | |
| 425 | |
| 426 | |
| 427 | # --------------------------------------------------------------------------- |
| 428 | # Hub helpers |
| 429 | # --------------------------------------------------------------------------- |
| 430 | |
| 431 | |
| 432 | def get_hub_url(repo_root: pathlib.Path | None = None) -> str | None: |
| 433 | """Return the hub URL from ``[hub] url``, or ``None`` if not configured. |
| 434 | |
| 435 | Resolution order: |
| 436 | 1. ``<repo>/.muse/config.toml`` — repo-local config (highest priority). |
| 437 | 2. ``~/.muse/config.toml`` — global user config (fallback). |
| 438 | |
| 439 | This fallback ensures ``muse auth whoami`` and other hub-aware commands |
| 440 | work without ``--hub`` even when invoked outside a repository, as long as |
| 441 | the user has set a default hub in their global config. |
| 442 | |
| 443 | Args: |
| 444 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 445 | |
| 446 | Returns: |
| 447 | URL string, or ``None``. |
| 448 | """ |
| 449 | config = _load_config(_config_path(repo_root)) |
| 450 | hub = config.get("hub") |
| 451 | if hub is not None: |
| 452 | url = hub.get("url", "") |
| 453 | if url.strip(): |
| 454 | return url.strip() |
| 455 | |
| 456 | # Fall back to ~/.muse/config.toml so hub-aware commands (e.g. `muse auth |
| 457 | # whoami`) work without --hub when invoked outside a repository. |
| 458 | global_config = _load_config(_GLOBAL_CONFIG_FILE) |
| 459 | global_hub = global_config.get("hub") |
| 460 | if global_hub is not None: |
| 461 | url = global_hub.get("url", "") |
| 462 | if url.strip(): |
| 463 | return url.strip() |
| 464 | |
| 465 | return None |
| 466 | |
| 467 | |
| 468 | def set_hub_url(url: str, repo_root: pathlib.Path | None = None) -> None: |
| 469 | """Write ``[hub] url`` to ``.muse/config.toml``. |
| 470 | |
| 471 | Preserves all other sections. Creates the config file if absent. |
| 472 | Rejects ``http://`` URLs — Muse never contacts a hub over cleartext HTTP. |
| 473 | |
| 474 | Args: |
| 475 | url: Hub URL (must be ``https://``). |
| 476 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 477 | |
| 478 | Raises: |
| 479 | ValueError: If *url* does not use the ``https://`` scheme. |
| 480 | """ |
| 481 | _is_loopback = url.startswith("http://localhost") or url.startswith("http://127.0.0.1") or url.startswith("http://[::1]") |
| 482 | if not url.startswith("https://") and not _is_loopback: |
| 483 | raise ValueError( |
| 484 | f"Hub URL must use HTTPS. Got: {url!r}\n" |
| 485 | "Muse never connects to a hub over cleartext HTTP.\n" |
| 486 | "(Exception: http://localhost and http://127.0.0.1 are allowed for local development.)" |
| 487 | ) |
| 488 | cp = _config_path(repo_root) |
| 489 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 490 | config = _load_config(cp) |
| 491 | config["hub"] = HubConfig(url=url) |
| 492 | write_text_atomic(cp, _dump_toml(config)) |
| 493 | logger.info("✅ Hub URL set to %s", url) |
| 494 | |
| 495 | |
| 496 | def clear_hub_url(repo_root: pathlib.Path | None = None) -> None: |
| 497 | """Remove the ``[hub]`` section from ``.muse/config.toml``. |
| 498 | |
| 499 | Args: |
| 500 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 501 | """ |
| 502 | cp = _config_path(repo_root) |
| 503 | config = _load_config(cp) |
| 504 | if "hub" in config: |
| 505 | del config["hub"] |
| 506 | write_text_atomic(cp, _dump_toml(config)) |
| 507 | logger.info("✅ Hub disconnected") |
| 508 | |
| 509 | |
| 510 | # --------------------------------------------------------------------------- |
| 511 | # User config helpers |
| 512 | # --------------------------------------------------------------------------- |
| 513 | |
| 514 | |
| 515 | |
| 516 | def set_user_field(key: str, value: str, repo_root: pathlib.Path | None = None) -> None: |
| 517 | """Set a single ``[user]`` field by name. |
| 518 | |
| 519 | Allowed keys: ``name``, ``email``, ``type``. |
| 520 | |
| 521 | Args: |
| 522 | key: Field name within ``[user]``. |
| 523 | value: New value. |
| 524 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 525 | |
| 526 | Raises: |
| 527 | ValueError: If *key* is not a recognised user config field. |
| 528 | """ |
| 529 | if key not in {"name", "email", "type"}: |
| 530 | raise ValueError(f"Unknown [user] config key: {key!r}. Valid keys: name, email, type") |
| 531 | cp = _config_path(repo_root) |
| 532 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 533 | config = _load_config(cp) |
| 534 | user: UserConfig = config.get("user") or {} |
| 535 | if key == "name": |
| 536 | user["name"] = value |
| 537 | elif key == "email": |
| 538 | user["email"] = value |
| 539 | elif key == "type": |
| 540 | user["type"] = value |
| 541 | config["user"] = user |
| 542 | write_text_atomic(cp, _dump_toml(config)) |
| 543 | logger.info("✅ user.%s = %r", key, value) |
| 544 | |
| 545 | |
| 546 | # --------------------------------------------------------------------------- |
| 547 | # Generic dotted-key helpers |
| 548 | # --------------------------------------------------------------------------- |
| 549 | |
| 550 | _BlockedNS = dict[str, str] |
| 551 | _BLOCKED_NAMESPACES: _BlockedNS = { |
| 552 | "auth": "Use `muse auth keygen` and `muse auth register` to manage credentials.", |
| 553 | "remotes": "Use `muse remote add/remove/rename` to manage remotes.", |
| 554 | } |
| 555 | |
| 556 | _SETTABLE_NAMESPACES = {"user", "hub", "domain", "limits"} |
| 557 | |
| 558 | # Default cap values — used when the [limits] section is absent or the key |
| 559 | # is not set. These are the same values that were previously hardcoded inside |
| 560 | # the individual functions. |
| 561 | _DEFAULT_MAX_WALK_COMMITS: int = 10_000 |
| 562 | _DEFAULT_MAX_ANCESTORS: int = 50_000 |
| 563 | _DEFAULT_MAX_GRAPH_COMMITS: int = 50_000 |
| 564 | _DEFAULT_SHARD_PREFIX_LENGTH: int = 2 |
| 565 | |
| 566 | |
| 567 | def get_limit(key: str, repo_root: pathlib.Path | None = None) -> int: |
| 568 | """Return a ``[limits]`` integer cap from config, or its default. |
| 569 | |
| 570 | Args: |
| 571 | key: Limit key — one of ``max_walk_commits``, ``max_ancestors``, |
| 572 | ``max_graph_commits``. |
| 573 | repo_root: Repository root; ``None`` falls back to ``Path.cwd()``. |
| 574 | |
| 575 | Returns: |
| 576 | Configured integer value, or the built-in default if not set. |
| 577 | """ |
| 578 | defaults: DefaultsMap = { |
| 579 | "max_walk_commits": _DEFAULT_MAX_WALK_COMMITS, |
| 580 | "max_ancestors": _DEFAULT_MAX_ANCESTORS, |
| 581 | "max_graph_commits": _DEFAULT_MAX_GRAPH_COMMITS, |
| 582 | "shard_prefix_length": _DEFAULT_SHARD_PREFIX_LENGTH, |
| 583 | } |
| 584 | default = defaults.get(key, 10_000) |
| 585 | config = _load_config(_config_path(repo_root)) |
| 586 | limits = config.get("limits") or {} |
| 587 | # Explicit key dispatch keeps mypy happy on TypedDict literal-required keys. |
| 588 | if key == "max_walk_commits": |
| 589 | val: int | None = limits.get("max_walk_commits") |
| 590 | elif key == "max_ancestors": |
| 591 | val = limits.get("max_ancestors") |
| 592 | elif key == "max_graph_commits": |
| 593 | val = limits.get("max_graph_commits") |
| 594 | elif key == "shard_prefix_length": |
| 595 | val = limits.get("shard_prefix_length") |
| 596 | else: |
| 597 | val = None |
| 598 | if isinstance(val, int) and val > 0: |
| 599 | return val |
| 600 | return default |
| 601 | |
| 602 | |
| 603 | def get_config_value(key: str, repo_root: pathlib.Path | None = None) -> str | None: |
| 604 | """Get a config value by dotted key (e.g. ``user.name``, ``hub.url``). |
| 605 | |
| 606 | Returns ``None`` when the key is not set or the namespace is unknown. |
| 607 | |
| 608 | Args: |
| 609 | key: Dotted key in ``<namespace>.<subkey>`` form. |
| 610 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 611 | |
| 612 | Returns: |
| 613 | String value, or ``None``. |
| 614 | """ |
| 615 | parts = key.split(".", 1) |
| 616 | if len(parts) != 2: |
| 617 | return None |
| 618 | namespace, subkey = parts |
| 619 | config = _load_config(_config_path(repo_root)) |
| 620 | |
| 621 | if namespace == "user": |
| 622 | user = config.get("user") or {} |
| 623 | if subkey == "name": |
| 624 | return user.get("name") |
| 625 | if subkey == "email": |
| 626 | return user.get("email") |
| 627 | if subkey == "type": |
| 628 | return user.get("type") |
| 629 | return None |
| 630 | |
| 631 | if namespace == "hub": |
| 632 | hub = config.get("hub") or {} |
| 633 | if subkey == "url": |
| 634 | return hub.get("url") |
| 635 | return None |
| 636 | |
| 637 | if namespace == "domain": |
| 638 | domain = config.get("domain") or {} |
| 639 | return domain.get(subkey) |
| 640 | |
| 641 | if namespace == "limits": |
| 642 | limits = config.get("limits") or {} |
| 643 | if subkey == "max_walk_commits": |
| 644 | v = limits.get("max_walk_commits") |
| 645 | return str(v) if isinstance(v, int) else None |
| 646 | if subkey == "max_ancestors": |
| 647 | v = limits.get("max_ancestors") |
| 648 | return str(v) if isinstance(v, int) else None |
| 649 | if subkey == "max_graph_commits": |
| 650 | v = limits.get("max_graph_commits") |
| 651 | return str(v) if isinstance(v, int) else None |
| 652 | if subkey == "shard_prefix_length": |
| 653 | v = limits.get("shard_prefix_length") |
| 654 | return str(v) if isinstance(v, int) else None |
| 655 | return None |
| 656 | |
| 657 | return None |
| 658 | |
| 659 | |
| 660 | def set_config_value(key: str, value: str, repo_root: pathlib.Path | None = None) -> None: |
| 661 | """Set a config value by dotted key (e.g. ``user.name``, ``domain.ticks_per_beat``). |
| 662 | |
| 663 | Args: |
| 664 | key: Dotted key in ``<namespace>.<subkey>`` form. |
| 665 | value: New string value. |
| 666 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 667 | |
| 668 | Raises: |
| 669 | ValueError: If the namespace is blocked, unknown, or the subkey is invalid. |
| 670 | """ |
| 671 | parts = key.split(".", 1) |
| 672 | if len(parts) != 2: |
| 673 | raise ValueError(f"Key must be in 'namespace.subkey' form, got: {key!r}") |
| 674 | namespace, subkey = parts |
| 675 | |
| 676 | if namespace in _BLOCKED_NAMESPACES: |
| 677 | raise ValueError(_BLOCKED_NAMESPACES[namespace]) |
| 678 | |
| 679 | if namespace not in _SETTABLE_NAMESPACES: |
| 680 | raise ValueError( |
| 681 | f"Unknown config namespace {namespace!r}. " |
| 682 | f"Settable namespaces: {', '.join(sorted(_SETTABLE_NAMESPACES))}" |
| 683 | ) |
| 684 | |
| 685 | cp = _config_path(repo_root) |
| 686 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 687 | config = _load_config(cp) |
| 688 | |
| 689 | if namespace == "user": |
| 690 | set_user_field(subkey, value, repo_root) |
| 691 | return |
| 692 | |
| 693 | if namespace == "hub": |
| 694 | if subkey != "url": |
| 695 | raise ValueError(f"Unknown [hub] config key: {subkey!r}. Valid keys: url") |
| 696 | # Route through set_hub_url — it enforces the HTTPS requirement. |
| 697 | set_hub_url(value, repo_root) |
| 698 | return |
| 699 | |
| 700 | if namespace == "limits": |
| 701 | _LIMITS_KEYS = frozenset({ |
| 702 | "max_walk_commits", "max_ancestors", "max_graph_commits", "shard_prefix_length", |
| 703 | }) |
| 704 | if subkey not in _LIMITS_KEYS: |
| 705 | raise ValueError( |
| 706 | f"Unknown [limits] config key: {subkey!r}. " |
| 707 | f"Valid keys: {', '.join(sorted(_LIMITS_KEYS))}" |
| 708 | ) |
| 709 | try: |
| 710 | int_value = int(value) |
| 711 | except ValueError as exc: |
| 712 | raise ValueError( |
| 713 | f"[limits] {subkey} must be an integer, got: {value!r}" |
| 714 | ) from exc |
| 715 | if int_value <= 0: |
| 716 | raise ValueError(f"[limits] {subkey} must be a positive integer, got: {int_value}") |
| 717 | if subkey == "shard_prefix_length" and int_value not in (2, 4): |
| 718 | raise ValueError("shard_prefix_length must be 2 or 4") |
| 719 | limits_section: LimitsConfig = config.get("limits") or {} |
| 720 | if subkey == "max_walk_commits": |
| 721 | limits_section["max_walk_commits"] = int_value |
| 722 | elif subkey == "max_ancestors": |
| 723 | limits_section["max_ancestors"] = int_value |
| 724 | elif subkey == "max_graph_commits": |
| 725 | limits_section["max_graph_commits"] = int_value |
| 726 | elif subkey == "shard_prefix_length": |
| 727 | limits_section["shard_prefix_length"] = int_value |
| 728 | config["limits"] = limits_section |
| 729 | write_text_atomic(cp, _dump_toml(config)) |
| 730 | logger.info("✅ limits.%s = %d", subkey, int_value) |
| 731 | return |
| 732 | |
| 733 | # namespace == "domain" |
| 734 | _validate_toml_key(subkey, "domain key") |
| 735 | domain: DomainConfig = config.get("domain") or {} |
| 736 | domain[subkey] = value |
| 737 | config["domain"] = domain |
| 738 | write_text_atomic(cp, _dump_toml(config)) |
| 739 | logger.info("✅ domain.%s = %r", subkey, value) |
| 740 | |
| 741 | |
| 742 | def config_as_dict(repo_root: pathlib.Path | None = None) -> ConfigTree: |
| 743 | """Return the full config as a plain ``dict[str, dict[str, str]]`` for JSON output. |
| 744 | |
| 745 | Credentials are never included — the hub section only contains the URL. |
| 746 | |
| 747 | Args: |
| 748 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 749 | |
| 750 | Returns: |
| 751 | Nested dict suitable for ``json.dumps``. |
| 752 | """ |
| 753 | config = _load_config(_config_path(repo_root)) |
| 754 | result: ConfigTree = {} |
| 755 | |
| 756 | user = config.get("user") |
| 757 | if user: |
| 758 | user_dict: ConfigSection = {} |
| 759 | uname = user.get("name") |
| 760 | if uname: |
| 761 | user_dict["name"] = uname |
| 762 | uemail = user.get("email") |
| 763 | if uemail: |
| 764 | user_dict["email"] = uemail |
| 765 | utype = user.get("type") |
| 766 | if utype: |
| 767 | user_dict["type"] = utype |
| 768 | if user_dict: |
| 769 | result["user"] = user_dict |
| 770 | |
| 771 | hub = config.get("hub") |
| 772 | if hub: |
| 773 | hub_url = hub.get("url", "") |
| 774 | if hub_url: |
| 775 | result["hub"] = {"url": hub_url} |
| 776 | |
| 777 | remotes = config.get("remotes") or {} |
| 778 | if remotes: |
| 779 | remotes_dict: ConfigSection = {} |
| 780 | for rname, entry in sorted(remotes.items()): |
| 781 | url = entry.get("url", "") |
| 782 | if url: |
| 783 | remotes_dict[rname] = url |
| 784 | if remotes_dict: |
| 785 | result["remotes"] = remotes_dict |
| 786 | |
| 787 | domain = config.get("domain") or {} |
| 788 | if domain: |
| 789 | result["domain"] = dict(sorted(domain.items())) |
| 790 | |
| 791 | limits = config.get("limits") or {} |
| 792 | if limits: |
| 793 | limits_dict: ConfigSection = {} |
| 794 | for lk in ("max_walk_commits", "max_ancestors", "max_graph_commits", "shard_prefix_length"): |
| 795 | lv = limits.get(lk) |
| 796 | if lv is not None: |
| 797 | limits_dict[lk] = str(lv) |
| 798 | if limits_dict: |
| 799 | result["limits"] = limits_dict |
| 800 | |
| 801 | return result |
| 802 | |
| 803 | |
| 804 | def config_path_for_editor(repo_root: pathlib.Path | None = None) -> pathlib.Path: |
| 805 | """Return the config path for the ``config edit`` command.""" |
| 806 | return _config_path(repo_root) |
| 807 | |
| 808 | |
| 809 | # --------------------------------------------------------------------------- |
| 810 | # Remote helpers |
| 811 | # --------------------------------------------------------------------------- |
| 812 | |
| 813 | |
| 814 | def get_remote(name: str, repo_root: pathlib.Path | None = None) -> str | None: |
| 815 | """Return the URL for remote *name*, or ``None`` when not configured. |
| 816 | |
| 817 | Args: |
| 818 | name: Remote name (e.g. ``"origin"``). |
| 819 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 820 | |
| 821 | Returns: |
| 822 | URL string, or ``None``. |
| 823 | """ |
| 824 | config = _load_config(_config_path(repo_root)) |
| 825 | remotes = config.get("remotes") |
| 826 | if remotes is None: |
| 827 | return None |
| 828 | entry = remotes.get(name) |
| 829 | if entry is None: |
| 830 | return None |
| 831 | url = entry.get("url", "") |
| 832 | return url.strip() if url.strip() else None |
| 833 | |
| 834 | |
| 835 | def get_remote_pack_origin( |
| 836 | name: str, |
| 837 | repo_root: pathlib.Path | None = None, |
| 838 | ) -> str | None: |
| 839 | """Return the ``pack_origin`` override for remote *name*, or ``None``. |
| 840 | |
| 841 | When set, pack uploads (``POST /push/object-pack``) are directed to this |
| 842 | origin instead of the primary remote URL. The path component (owner/slug) |
| 843 | is preserved from the primary URL so the same MWP path structure applies. |
| 844 | |
| 845 | Args: |
| 846 | name: Remote name (e.g. ``"local"``). |
| 847 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 848 | |
| 849 | Returns: |
| 850 | ``pack_origin`` string, or ``None`` when not configured. |
| 851 | """ |
| 852 | config = _load_config(_config_path(repo_root)) |
| 853 | remotes = config.get("remotes") |
| 854 | if remotes is None: |
| 855 | return None |
| 856 | entry = remotes.get(name) |
| 857 | if entry is None: |
| 858 | return None |
| 859 | origin = entry.get("pack_origin", "") |
| 860 | return origin.strip() if origin.strip() else None |
| 861 | |
| 862 | |
| 863 | def set_remote( |
| 864 | name: str, |
| 865 | url: str, |
| 866 | repo_root: pathlib.Path | None = None, |
| 867 | ) -> None: |
| 868 | """Write ``[remotes.<name>] url`` to ``.muse/config.toml``. |
| 869 | |
| 870 | Preserves all other sections. Creates the file if absent. |
| 871 | |
| 872 | Args: |
| 873 | name: Remote name (e.g. ``"origin"``). |
| 874 | url: Remote URL. |
| 875 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 876 | """ |
| 877 | cp = _config_path(repo_root) |
| 878 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 879 | config = _load_config(cp) |
| 880 | existing_remotes = config.get("remotes") |
| 881 | remotes: RemotesMap = {} |
| 882 | if existing_remotes: |
| 883 | remotes.update(existing_remotes) |
| 884 | existing_entry = remotes.get(name) |
| 885 | entry: RemoteEntry = {} |
| 886 | if existing_entry is not None: |
| 887 | if "url" in existing_entry: |
| 888 | entry["url"] = existing_entry["url"] |
| 889 | if "branch" in existing_entry: |
| 890 | entry["branch"] = existing_entry["branch"] |
| 891 | entry["url"] = url |
| 892 | remotes[name] = entry |
| 893 | config["remotes"] = remotes |
| 894 | write_text_atomic(cp, _dump_toml(config)) |
| 895 | logger.info("✅ Remote %r set to %s", name, url) |
| 896 | |
| 897 | |
| 898 | def remove_remote( |
| 899 | name: str, |
| 900 | repo_root: pathlib.Path | None = None, |
| 901 | ) -> None: |
| 902 | """Remove a named remote and its tracking refs. |
| 903 | |
| 904 | Args: |
| 905 | name: Remote name to remove. |
| 906 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 907 | |
| 908 | Raises: |
| 909 | KeyError: If *name* is not a configured remote. |
| 910 | """ |
| 911 | cp = _config_path(repo_root) |
| 912 | config = _load_config(cp) |
| 913 | remotes = config.get("remotes") |
| 914 | if remotes is None or name not in remotes: |
| 915 | raise KeyError(name) |
| 916 | del remotes[name] |
| 917 | config["remotes"] = remotes |
| 918 | write_text_atomic(cp, _dump_toml(config)) |
| 919 | logger.info("✅ Remote %r removed from config", name) |
| 920 | |
| 921 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 922 | refs_dir = root / _MUSE_DIR / "remotes" / name |
| 923 | if refs_dir.is_symlink(): |
| 924 | # Refuse to rmtree a symlink — following a symlink placed by an |
| 925 | # attacker could delete files outside the repository tree. |
| 926 | logger.warning("⚠️ Skipping rmtree: remotes dir %s is a symlink", refs_dir) |
| 927 | elif refs_dir.is_dir(): |
| 928 | shutil.rmtree(refs_dir) |
| 929 | logger.debug("✅ Removed tracking refs dir %s", refs_dir) |
| 930 | |
| 931 | |
| 932 | def rename_remote( |
| 933 | old_name: str, |
| 934 | new_name: str, |
| 935 | repo_root: pathlib.Path | None = None, |
| 936 | ) -> None: |
| 937 | """Rename a remote and move its tracking refs. |
| 938 | |
| 939 | Args: |
| 940 | old_name: Current remote name. |
| 941 | new_name: Desired new remote name. |
| 942 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 943 | |
| 944 | Raises: |
| 945 | KeyError: If *old_name* is not a configured remote. |
| 946 | ValueError: If *new_name* is already configured. |
| 947 | """ |
| 948 | cp = _config_path(repo_root) |
| 949 | config = _load_config(cp) |
| 950 | remotes = config.get("remotes") |
| 951 | if remotes is None or old_name not in remotes: |
| 952 | raise KeyError(old_name) |
| 953 | if new_name in remotes: |
| 954 | raise ValueError(new_name) |
| 955 | remotes[new_name] = remotes.pop(old_name) |
| 956 | config["remotes"] = remotes |
| 957 | write_text_atomic(cp, _dump_toml(config)) |
| 958 | logger.info("✅ Remote %r renamed to %r", old_name, new_name) |
| 959 | |
| 960 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 961 | old_refs_dir = root / _MUSE_DIR / "remotes" / old_name |
| 962 | new_refs_dir = root / _MUSE_DIR / "remotes" / new_name |
| 963 | if old_refs_dir.is_dir(): |
| 964 | old_refs_dir.rename(new_refs_dir) |
| 965 | logger.debug("✅ Moved tracking refs dir %s → %s", old_refs_dir, new_refs_dir) |
| 966 | |
| 967 | |
| 968 | def list_remotes(repo_root: pathlib.Path | None = None) -> list[RemoteConfig]: |
| 969 | """Return all configured remotes sorted alphabetically by name. |
| 970 | |
| 971 | Args: |
| 972 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 973 | |
| 974 | Returns: |
| 975 | List of ``{"name": str, "url": str}`` dicts. |
| 976 | """ |
| 977 | config = _load_config(_config_path(repo_root)) |
| 978 | remotes = config.get("remotes") |
| 979 | if remotes is None: |
| 980 | return [] |
| 981 | result: list[RemoteConfig] = [] |
| 982 | for remote_name in sorted(remotes): |
| 983 | entry = remotes[remote_name] |
| 984 | url = entry.get("url", "") |
| 985 | if not url.strip(): |
| 986 | continue |
| 987 | rc = RemoteConfig(name=remote_name, url=url.strip()) |
| 988 | pack_origin = entry.get("pack_origin", "") |
| 989 | if pack_origin.strip(): |
| 990 | rc["pack_origin"] = pack_origin.strip() |
| 991 | result.append(rc) |
| 992 | return result |
| 993 | |
| 994 | |
| 995 | # --------------------------------------------------------------------------- |
| 996 | # Remote tracking-head helpers |
| 997 | # --------------------------------------------------------------------------- |
| 998 | |
| 999 | |
| 1000 | def _remote_head_path( |
| 1001 | remote_name: str, |
| 1002 | branch: str, |
| 1003 | repo_root: pathlib.Path | None = None, |
| 1004 | ) -> pathlib.Path: |
| 1005 | """Return the path to the remote tracking pointer file.""" |
| 1006 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 1007 | return root / _MUSE_DIR / "remotes" / remote_name / branch |
| 1008 | |
| 1009 | |
| 1010 | def get_remote_head( |
| 1011 | remote_name: str, |
| 1012 | branch: str, |
| 1013 | repo_root: pathlib.Path | None = None, |
| 1014 | ) -> str | None: |
| 1015 | """Return the last-known remote commit ID for *remote_name*/*branch*. |
| 1016 | |
| 1017 | Returns ``None`` when the tracking pointer does not exist. |
| 1018 | |
| 1019 | Args: |
| 1020 | remote_name: Remote name (e.g. ``"origin"``). |
| 1021 | branch: Branch name (e.g. ``"main"``). |
| 1022 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1023 | |
| 1024 | Returns: |
| 1025 | Commit ID string, or ``None``. |
| 1026 | """ |
| 1027 | pointer = _remote_head_path(remote_name, branch, repo_root) |
| 1028 | if not pointer.is_file(): |
| 1029 | return None |
| 1030 | raw = pointer.read_text(encoding="utf-8").strip() |
| 1031 | return raw if raw else None |
| 1032 | |
| 1033 | |
| 1034 | def set_remote_head( |
| 1035 | remote_name: str, |
| 1036 | branch: str, |
| 1037 | commit_id: str, |
| 1038 | repo_root: pathlib.Path | None = None, |
| 1039 | ) -> None: |
| 1040 | """Write the remote tracking pointer for *remote_name*/*branch*. |
| 1041 | |
| 1042 | Args: |
| 1043 | remote_name: Remote name (e.g. ``"origin"``). |
| 1044 | branch: Branch name. |
| 1045 | commit_id: Commit ID to record as the known remote HEAD. |
| 1046 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1047 | """ |
| 1048 | pointer = _remote_head_path(remote_name, branch, repo_root) |
| 1049 | write_text_atomic(pointer, commit_id) |
| 1050 | logger.debug("✅ Remote head %s/%s → %s", remote_name, branch, commit_id[:8]) |
| 1051 | |
| 1052 | |
| 1053 | def delete_remote_head( |
| 1054 | remote_name: str, |
| 1055 | branch: str, |
| 1056 | repo_root: pathlib.Path | None = None, |
| 1057 | ) -> bool: |
| 1058 | """Remove the local remote-tracking pointer for *remote_name*/*branch*. |
| 1059 | |
| 1060 | Used after ``muse push --delete`` deletes the branch on the server, or when |
| 1061 | pruning stale tracking refs with ``muse branch -dr``. |
| 1062 | |
| 1063 | Args: |
| 1064 | remote_name: Remote name (e.g. ``"origin"``). |
| 1065 | branch: Branch name (e.g. ``"feat/my-thing"``). |
| 1066 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1067 | |
| 1068 | Returns: |
| 1069 | ``True`` if the pointer file existed and was removed, ``False`` if it |
| 1070 | was already absent (idempotent). |
| 1071 | """ |
| 1072 | pointer = _remote_head_path(remote_name, branch, repo_root) |
| 1073 | if not pointer.is_file(): |
| 1074 | return False |
| 1075 | pointer.unlink() |
| 1076 | # Remove now-empty parent directories (mirrors _cleanup_empty_dirs in branch.py). |
| 1077 | remotes_dir = pointer.parent |
| 1078 | while remotes_dir.name != remote_name: |
| 1079 | try: |
| 1080 | remotes_dir.rmdir() |
| 1081 | except OSError: |
| 1082 | break |
| 1083 | remotes_dir = remotes_dir.parent |
| 1084 | logger.debug("🗑 Remote tracking ref %s/%s removed", remote_name, branch) |
| 1085 | return True |
| 1086 | |
| 1087 | |
| 1088 | # --------------------------------------------------------------------------- |
| 1089 | # Upstream tracking helpers |
| 1090 | # --------------------------------------------------------------------------- |
| 1091 | |
| 1092 | |
| 1093 | def set_upstream( |
| 1094 | branch: str, |
| 1095 | remote_name: str, |
| 1096 | repo_root: pathlib.Path | None = None, |
| 1097 | ) -> None: |
| 1098 | """Record *remote_name* as the upstream remote for *branch*. |
| 1099 | |
| 1100 | Args: |
| 1101 | branch: Local (and remote) branch name. |
| 1102 | remote_name: Remote name. |
| 1103 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1104 | """ |
| 1105 | cp = _config_path(repo_root) |
| 1106 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 1107 | config = _load_config(cp) |
| 1108 | existing_remotes = config.get("remotes") |
| 1109 | remotes: RemotesMap = {} |
| 1110 | if existing_remotes: |
| 1111 | remotes.update(existing_remotes) |
| 1112 | existing_entry = remotes.get(remote_name) |
| 1113 | entry: RemoteEntry = {} |
| 1114 | if existing_entry is not None: |
| 1115 | if "url" in existing_entry: |
| 1116 | entry["url"] = existing_entry["url"] |
| 1117 | if "branch" in existing_entry: |
| 1118 | entry["branch"] = existing_entry["branch"] |
| 1119 | entry["branch"] = branch |
| 1120 | remotes[remote_name] = entry |
| 1121 | config["remotes"] = remotes |
| 1122 | write_text_atomic(cp, _dump_toml(config)) |
| 1123 | logger.info("✅ Upstream for branch %r set to %s/%r", branch, remote_name, branch) |
| 1124 | |
| 1125 | |
| 1126 | def get_upstream( |
| 1127 | branch: str, |
| 1128 | repo_root: pathlib.Path | None = None, |
| 1129 | ) -> str | None: |
| 1130 | """Return the configured upstream remote name for *branch*, or ``None``. |
| 1131 | |
| 1132 | Args: |
| 1133 | branch: Local branch name. |
| 1134 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1135 | |
| 1136 | Returns: |
| 1137 | Remote name string, or ``None``. |
| 1138 | """ |
| 1139 | config = _load_config(_config_path(repo_root)) |
| 1140 | remotes = config.get("remotes") |
| 1141 | if remotes is None: |
| 1142 | return None |
| 1143 | for rname, entry in remotes.items(): |
| 1144 | tracked = entry.get("branch", "") |
| 1145 | if tracked.strip() == branch: |
| 1146 | return rname |
| 1147 | return None |
| 1148 | |
| 1149 | |
| 1150 | # --------------------------------------------------------------------------- |
| 1151 | # Global user config — ~/.muse/config.toml (safe_dirs) |
| 1152 | # --------------------------------------------------------------------------- |
| 1153 | |
| 1154 | _GLOBAL_MUSE_DIR = pathlib.Path.home() / ".muse" |
| 1155 | _GLOBAL_CONFIG_FILE = _GLOBAL_MUSE_DIR / "config.toml" |
| 1156 | |
| 1157 | |
| 1158 | def _load_global_config() -> dict[str, list[str]]: |
| 1159 | """Load ``~/.muse/config.toml`` and return the ``[security]`` section. |
| 1160 | |
| 1161 | Returns a dict with key ``safe_dirs`` mapping to a list of path strings. |
| 1162 | Returns ``{"safe_dirs": []}`` when the file is absent or unparseable. |
| 1163 | """ |
| 1164 | if not _GLOBAL_CONFIG_FILE.is_file(): |
| 1165 | return {"safe_dirs": []} |
| 1166 | try: |
| 1167 | with _GLOBAL_CONFIG_FILE.open("rb") as fh: |
| 1168 | raw: dict[str, object] = tomllib.load(fh) |
| 1169 | except Exception as exc: # noqa: BLE001 |
| 1170 | logger.warning("⚠️ Failed to parse %s: %s", _GLOBAL_CONFIG_FILE, exc) |
| 1171 | return {"safe_dirs": []} |
| 1172 | security_raw = raw.get("security") |
| 1173 | if not isinstance(security_raw, dict): |
| 1174 | return {"safe_dirs": []} |
| 1175 | dirs_raw = security_raw.get("safe_dirs") |
| 1176 | if not isinstance(dirs_raw, list): |
| 1177 | return {"safe_dirs": []} |
| 1178 | safe: list[str] = [d for d in dirs_raw if isinstance(d, str) and d.strip()] |
| 1179 | return {"safe_dirs": safe} |
| 1180 | |
| 1181 | |
| 1182 | def _save_global_config(safe_dirs: list[str]) -> None: |
| 1183 | """Write ``[security] safe_dirs`` to ``~/.muse/config.toml``. |
| 1184 | |
| 1185 | Preserves any other sections that may exist in the file. |
| 1186 | """ |
| 1187 | import os |
| 1188 | _GLOBAL_MUSE_DIR.mkdir(parents=True, exist_ok=True) |
| 1189 | |
| 1190 | # Read existing raw content to preserve other sections. |
| 1191 | existing_lines: list[str] = [] |
| 1192 | if _GLOBAL_CONFIG_FILE.is_file(): |
| 1193 | try: |
| 1194 | existing_lines = _GLOBAL_CONFIG_FILE.read_text("utf-8").splitlines() |
| 1195 | except Exception: # noqa: BLE001 |
| 1196 | existing_lines = [] |
| 1197 | |
| 1198 | # Strip any existing [security] section from the file. |
| 1199 | filtered: list[str] = [] |
| 1200 | in_security = False |
| 1201 | for line in existing_lines: |
| 1202 | stripped = line.strip() |
| 1203 | if stripped == "[security]": |
| 1204 | in_security = True |
| 1205 | continue |
| 1206 | if in_security and stripped.startswith("["): |
| 1207 | in_security = False |
| 1208 | if not in_security: |
| 1209 | filtered.append(line) |
| 1210 | |
| 1211 | # Remove trailing blank lines before appending the new section. |
| 1212 | while filtered and not filtered[-1].strip(): |
| 1213 | filtered.pop() |
| 1214 | |
| 1215 | # Append the new [security] section. |
| 1216 | filtered.append("") |
| 1217 | filtered.append("[security]") |
| 1218 | if safe_dirs: |
| 1219 | items = ", ".join(f'"{_escape(d)}"' for d in safe_dirs) |
| 1220 | filtered.append(f"safe_dirs = [{items}]") |
| 1221 | else: |
| 1222 | filtered.append("safe_dirs = []") |
| 1223 | filtered.append("") |
| 1224 | |
| 1225 | content = "\n".join(filtered) |
| 1226 | tmp = _GLOBAL_CONFIG_FILE.with_suffix(".toml.tmp") |
| 1227 | tmp.write_text(content, encoding="utf-8") |
| 1228 | os.replace(tmp, _GLOBAL_CONFIG_FILE) |
| 1229 | |
| 1230 | |
| 1231 | def get_global_safe_dirs() -> list[str]: |
| 1232 | """Return the ``safe_dirs`` list from ``~/.muse/config.toml``. |
| 1233 | |
| 1234 | Returns an empty list when not configured. |
| 1235 | """ |
| 1236 | return _load_global_config().get("safe_dirs", []) |
| 1237 | |
| 1238 | |
| 1239 | def add_global_safe_dir(path: str) -> None: |
| 1240 | """Add *path* to the ``safe_dirs`` list in ``~/.muse/config.toml``. |
| 1241 | |
| 1242 | Normalises the path (``os.path.abspath``) before storing. Idempotent — |
| 1243 | adding the same path twice has no effect. |
| 1244 | |
| 1245 | Args: |
| 1246 | path: Absolute or relative path to trust. |
| 1247 | """ |
| 1248 | import os |
| 1249 | abs_path = os.path.abspath(path) |
| 1250 | current = get_global_safe_dirs() |
| 1251 | if abs_path not in current: |
| 1252 | current.append(abs_path) |
| 1253 | _save_global_config(current) |
| 1254 | logger.info("✅ Trusted path added: %s", abs_path) |
| 1255 | |
| 1256 | |
| 1257 | def remove_global_safe_dir(path: str) -> None: |
| 1258 | """Remove *path* from the ``safe_dirs`` list in ``~/.muse/config.toml``. |
| 1259 | |
| 1260 | Normalises the path before matching. No-op when the path is not present. |
| 1261 | |
| 1262 | Args: |
| 1263 | path: Absolute or relative path to remove from the trust list. |
| 1264 | """ |
| 1265 | import os |
| 1266 | abs_path = os.path.abspath(path) |
| 1267 | current = get_global_safe_dirs() |
| 1268 | updated = [d for d in current if d != abs_path] |
| 1269 | _save_global_config(updated) |
| 1270 | logger.info("✅ Trusted path removed: %s", abs_path) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
32 days ago