config.py
python
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed
chore: pivot to nightly channel — bump version to 0.2.0.dev…
Sonnet 5
patch
13 days ago
| 1 | """Muse CLI configuration helpers. |
| 2 | |
| 3 | Reads and writes ``.muse/config.toml`` — the per-repository configuration |
| 4 | file. Credentials and user identity 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 | [hub] |
| 12 | url = "https://musehub.ai" # MuseHub fabric endpoint for this repo |
| 13 | |
| 14 | [remotes.origin] |
| 15 | url = "https://hub.muse.io/repos/my-repo" |
| 16 | branch = "main" |
| 17 | |
| 18 | [domain] |
| 19 | # Domain-specific key/value pairs; read by the active domain plugin. |
| 20 | # ticks_per_beat = "480" |
| 21 | |
| 22 | Settable via ``muse config set`` |
| 23 | --------------------------------- |
| 24 | - ``hub.url`` (alias: ``muse hub connect <url>``) |
| 25 | - ``domain.*`` |
| 26 | |
| 27 | Not settable via ``muse config set`` |
| 28 | -------------------------------------- |
| 29 | - ``user.*`` — use ``muse auth register`` / ``muse auth whoami`` |
| 30 | - ``remotes.*`` — use ``muse remote add/remove`` |
| 31 | - credentials — use ``muse auth register`` |
| 32 | |
| 33 | Token resolution |
| 34 | ---------------- |
| 35 | :func:`get_signing_identity` reads the hub URL from this file, then resolves the |
| 36 | signing identity from ``~/.muse/identity.toml`` via |
| 37 | :func:`muse.core.identity.resolve_token`. The token is **never** logged. |
| 38 | """ |
| 39 | |
| 40 | import fnmatch |
| 41 | import logging |
| 42 | import pathlib |
| 43 | import shutil |
| 44 | import subprocess |
| 45 | import tomllib |
| 46 | from typing import TypedDict |
| 47 | |
| 48 | from muse.core.types import short_id |
| 49 | from muse.core.paths import config_toml_path as _config_toml_path, user_muse_dir as _user_muse_dir, user_config_toml_path as _user_config_toml_path, remote_tracking_dir as _remote_tracking_dir, remote_ref_path as _remote_ref_path |
| 50 | from muse.core.refs import read_ref |
| 51 | from muse.core.io import write_text_atomic |
| 52 | |
| 53 | logger = logging.getLogger(__name__) |
| 54 | |
| 55 | type RemotesMap = dict[str, RemoteEntry] # remote_name → remote entry |
| 56 | type DomainConfig = dict[str, str] # domain key → value |
| 57 | type ConfigSection = dict[str, str] # generic flattened section dict |
| 58 | type ConfigTree = dict[str, ConfigSection] # section → key → value (for JSON output) |
| 59 | type DefaultsMap = dict[str, int] # config key → default int value |
| 60 | type _SecurityConfig = dict[str, list[str]] # security section from global config |
| 61 | |
| 62 | # --------------------------------------------------------------------------- |
| 63 | # Named configuration types |
| 64 | # --------------------------------------------------------------------------- |
| 65 | |
| 66 | class HubConfig(TypedDict, total=False): |
| 67 | """``[hub]`` section in ``.muse/config.toml``.""" |
| 68 | |
| 69 | url: str |
| 70 | |
| 71 | class RemoteEntry(TypedDict, total=False): |
| 72 | """``[remotes.<name>]`` section in ``.muse/config.toml``.""" |
| 73 | |
| 74 | url: str |
| 75 | branch: str |
| 76 | promisor: bool # when False, this remote is not used as a promisor for missing objects |
| 77 | |
| 78 | class LimitsConfig(TypedDict, total=False): |
| 79 | """``[limits]`` section in ``.muse/config.toml``. |
| 80 | |
| 81 | All values are optional — defaults are used when absent. Keys map to |
| 82 | the ``[limits]`` TOML table:: |
| 83 | |
| 84 | [limits] |
| 85 | max_walk_commits = 10000 # cap for walk_commits_between / muse log |
| 86 | max_ancestors = 50000 # cap for find_merge_base BFS |
| 87 | max_graph_commits = 50000 # cap for _collect_all_commits (--graph --all) |
| 88 | shard_prefix_length = 2 # object store shard depth: 2 (256 shards) |
| 89 | # or 4 (65536 shards) for very large repos |
| 90 | """ |
| 91 | |
| 92 | max_walk_commits: int |
| 93 | max_ancestors: int |
| 94 | max_graph_commits: int |
| 95 | shard_prefix_length: int |
| 96 | |
| 97 | class CommitConfig(TypedDict, total=False): |
| 98 | """``[commit]`` section in ``.muse/config.toml``.""" |
| 99 | |
| 100 | sign: bool |
| 101 | |
| 102 | |
| 103 | class SymlogConfig(TypedDict, total=False): |
| 104 | """``[symlog]`` section in ``.muse/config.toml``. |
| 105 | |
| 106 | Controls default TTL for per-symbol journal expiry:: |
| 107 | |
| 108 | [symlog] |
| 109 | expire_days = 90 # entries older than this are pruned by muse gc / muse symlog expire |
| 110 | """ |
| 111 | |
| 112 | expire_days: int |
| 113 | |
| 114 | |
| 115 | class PushConfig(TypedDict, total=False): |
| 116 | """``[push]`` section in ``.muse/config.toml``.""" |
| 117 | |
| 118 | tags: bool # when true, version tags are pushed on every muse push |
| 119 | |
| 120 | |
| 121 | class BranchMeta(TypedDict, total=False): |
| 122 | """Per-branch metadata stored under ``[branch."<name>"]`` in config.toml. |
| 123 | |
| 124 | Fields written by ``muse branch --intent / --resumable``:: |
| 125 | |
| 126 | [branch."feat/my-thing"] |
| 127 | intent = "refactor auth layer" |
| 128 | resumable = true |
| 129 | |
| 130 | Fields written by ``muse push`` upstream tracking (preserved on read/write):: |
| 131 | |
| 132 | remote = "origin" |
| 133 | merge = "refs/heads/feat/my-thing" |
| 134 | """ |
| 135 | |
| 136 | intent: str # short description of what this branch is for |
| 137 | resumable: bool # true when this branch is a resumable agent checkpoint |
| 138 | remote: str # upstream remote name (e.g. "origin") |
| 139 | merge: str # upstream merge ref (e.g. "refs/heads/main") |
| 140 | |
| 141 | class MuseConfig(TypedDict, total=False): |
| 142 | """Structured view of the entire ``.muse/config.toml`` file.""" |
| 143 | |
| 144 | hub: HubConfig |
| 145 | remotes: RemotesMap |
| 146 | symlog: SymlogConfig |
| 147 | domain: DomainConfig |
| 148 | limits: LimitsConfig |
| 149 | commit: CommitConfig |
| 150 | push: PushConfig |
| 151 | branch: "dict[str, BranchMeta]" # branch_name → per-branch metadata |
| 152 | protected_branches: "list[str]" # fnmatch patterns from [protected_branches] |
| 153 | |
| 154 | class RemoteConfig(TypedDict, total=False): |
| 155 | """Public-facing remote descriptor returned by :func:`list_remotes`.""" |
| 156 | |
| 157 | name: str # always present |
| 158 | url: str # always present |
| 159 | |
| 160 | # --------------------------------------------------------------------------- |
| 161 | # Internal helpers |
| 162 | # --------------------------------------------------------------------------- |
| 163 | |
| 164 | def _config_path(repo_root: pathlib.Path | None) -> pathlib.Path: |
| 165 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 166 | return _config_toml_path(root) |
| 167 | |
| 168 | def _load_config(config_path: pathlib.Path) -> MuseConfig: |
| 169 | """Load and parse config.toml; return an empty MuseConfig if absent.""" |
| 170 | if not config_path.is_file(): |
| 171 | return {} |
| 172 | |
| 173 | try: |
| 174 | with config_path.open("rb") as fh: |
| 175 | raw = tomllib.load(fh) |
| 176 | except Exception as exc: # noqa: BLE001 |
| 177 | logger.warning("⚠️ Failed to parse %s: %s", config_path, exc) |
| 178 | return {} |
| 179 | |
| 180 | config: MuseConfig = {} |
| 181 | |
| 182 | hub_raw = raw.get("hub") |
| 183 | if isinstance(hub_raw, dict): |
| 184 | hub: HubConfig = {} |
| 185 | url_val = hub_raw.get("url") |
| 186 | if isinstance(url_val, str): |
| 187 | hub["url"] = url_val |
| 188 | config["hub"] = hub |
| 189 | |
| 190 | remotes_raw = raw.get("remotes") |
| 191 | if isinstance(remotes_raw, dict): |
| 192 | remotes: RemotesMap = {} |
| 193 | for name, remote_raw in remotes_raw.items(): |
| 194 | if isinstance(remote_raw, dict): |
| 195 | entry: RemoteEntry = {} |
| 196 | rurl = remote_raw.get("url") |
| 197 | if isinstance(rurl, str): |
| 198 | entry["url"] = rurl |
| 199 | branch_val = remote_raw.get("branch") |
| 200 | if isinstance(branch_val, str): |
| 201 | entry["branch"] = branch_val |
| 202 | promisor_val = remote_raw.get("promisor") |
| 203 | if isinstance(promisor_val, bool): |
| 204 | entry["promisor"] = promisor_val |
| 205 | remotes[name] = entry |
| 206 | config["remotes"] = remotes |
| 207 | |
| 208 | domain_raw = raw.get("domain") |
| 209 | if isinstance(domain_raw, dict): |
| 210 | domain: DomainConfig = {} |
| 211 | for key, val in domain_raw.items(): |
| 212 | if isinstance(val, str): |
| 213 | domain[key] = val |
| 214 | config["domain"] = domain |
| 215 | |
| 216 | limits_raw = raw.get("limits") |
| 217 | if isinstance(limits_raw, dict): |
| 218 | limits: LimitsConfig = {} |
| 219 | mwc = limits_raw.get("max_walk_commits") |
| 220 | if isinstance(mwc, int) and mwc > 0: |
| 221 | limits["max_walk_commits"] = mwc |
| 222 | ma = limits_raw.get("max_ancestors") |
| 223 | if isinstance(ma, int) and ma > 0: |
| 224 | limits["max_ancestors"] = ma |
| 225 | mgc = limits_raw.get("max_graph_commits") |
| 226 | if isinstance(mgc, int) and mgc > 0: |
| 227 | limits["max_graph_commits"] = mgc |
| 228 | spl = limits_raw.get("shard_prefix_length") |
| 229 | if isinstance(spl, int) and spl in (2, 4): |
| 230 | limits["shard_prefix_length"] = spl |
| 231 | config["limits"] = limits |
| 232 | |
| 233 | commit_raw = raw.get("commit") |
| 234 | if isinstance(commit_raw, dict): |
| 235 | commit_cfg: CommitConfig = {} |
| 236 | sign_v = commit_raw.get("sign") |
| 237 | if isinstance(sign_v, bool): |
| 238 | commit_cfg["sign"] = sign_v |
| 239 | if commit_cfg: |
| 240 | config["commit"] = commit_cfg |
| 241 | |
| 242 | reflog_raw = raw.get("reflog") |
| 243 | if isinstance(reflog_raw, dict): |
| 244 | reflog_cfg: dict = {} |
| 245 | ed = reflog_raw.get("expire_days") |
| 246 | if isinstance(ed, int) and ed > 0: |
| 247 | reflog_cfg["expire_days"] = ed |
| 248 | if reflog_cfg: |
| 249 | config["reflog"] = reflog_cfg |
| 250 | |
| 251 | symlog_raw = raw.get("symlog") |
| 252 | if isinstance(symlog_raw, dict): |
| 253 | symlog_cfg: SymlogConfig = {} |
| 254 | sl_ed = symlog_raw.get("expire_days") |
| 255 | if isinstance(sl_ed, int) and sl_ed > 0: |
| 256 | symlog_cfg["expire_days"] = sl_ed |
| 257 | if symlog_cfg: |
| 258 | config["symlog"] = symlog_cfg |
| 259 | |
| 260 | push_raw = raw.get("push") |
| 261 | if isinstance(push_raw, dict): |
| 262 | push_cfg: PushConfig = {} |
| 263 | tags_v = push_raw.get("tags") |
| 264 | if isinstance(tags_v, bool): |
| 265 | push_cfg["tags"] = tags_v |
| 266 | if push_cfg: |
| 267 | config["push"] = push_cfg |
| 268 | |
| 269 | branch_raw = raw.get("branch") |
| 270 | if isinstance(branch_raw, dict): |
| 271 | branch_map: dict[str, BranchMeta] = {} |
| 272 | for bname, bdata in branch_raw.items(): |
| 273 | if not isinstance(bdata, dict): |
| 274 | continue |
| 275 | bmeta: BranchMeta = {} |
| 276 | intent_v = bdata.get("intent") |
| 277 | if isinstance(intent_v, str): |
| 278 | bmeta["intent"] = intent_v |
| 279 | resumable_v = bdata.get("resumable") |
| 280 | if isinstance(resumable_v, bool): |
| 281 | bmeta["resumable"] = resumable_v |
| 282 | remote_v = bdata.get("remote") |
| 283 | if isinstance(remote_v, str): |
| 284 | bmeta["remote"] = remote_v |
| 285 | merge_v = bdata.get("merge") |
| 286 | if isinstance(merge_v, str): |
| 287 | bmeta["merge"] = merge_v |
| 288 | branch_map[bname] = bmeta |
| 289 | if branch_map: |
| 290 | config["branch"] = branch_map |
| 291 | |
| 292 | pb_raw = raw.get("protected_branches") |
| 293 | if isinstance(pb_raw, dict): |
| 294 | branches_val = pb_raw.get("branches") |
| 295 | if isinstance(branches_val, list): |
| 296 | patterns = [p for p in branches_val if isinstance(p, str)] |
| 297 | config["protected_branches"] = patterns |
| 298 | |
| 299 | return config |
| 300 | |
| 301 | def _escape(value: str) -> str: |
| 302 | """Escape a TOML basic string value (backslash and double-quote only). |
| 303 | |
| 304 | TOML basic strings allow control characters escaped as ``\\n``, ``\\t``, |
| 305 | etc., but we store only printable content — control characters in values |
| 306 | are also stripped here so the resulting TOML file remains parseable. |
| 307 | """ |
| 308 | return ( |
| 309 | value.replace("\\", "\\\\") |
| 310 | .replace('"', '\\"') |
| 311 | .replace("\n", "\\n") |
| 312 | .replace("\r", "\\r") |
| 313 | .replace("\0", "") |
| 314 | ) |
| 315 | |
| 316 | # Characters that are structurally significant in unquoted TOML keys and |
| 317 | # table headers. Any of these in a key name would allow injection of |
| 318 | # arbitrary TOML sections or key-value pairs. |
| 319 | _TOML_KEY_UNSAFE: frozenset[str] = frozenset('\n\r\0][="') |
| 320 | |
| 321 | def _validate_toml_key(key: str, context: str = "key") -> None: |
| 322 | """Raise ``ValueError`` if *key* contains TOML-structurally unsafe characters. |
| 323 | |
| 324 | Prevents injection attacks where a crafted key like ``x]\\n[injected`` would |
| 325 | break the TOML section structure and allow writing arbitrary sections. |
| 326 | |
| 327 | Args: |
| 328 | key: Key string to validate. |
| 329 | context: Human-readable label used in the error message (e.g. ``"domain key"``). |
| 330 | |
| 331 | Raises: |
| 332 | ValueError: If any character in *key* is in ``_TOML_KEY_UNSAFE``. |
| 333 | """ |
| 334 | bad = _TOML_KEY_UNSAFE & set(key) |
| 335 | if bad: |
| 336 | chars = ", ".join(sorted(repr(c) for c in bad)) |
| 337 | raise ValueError( |
| 338 | f"Config {context} {key!r} contains characters not allowed in TOML keys: {chars}" |
| 339 | ) |
| 340 | |
| 341 | def _dump_toml(config: MuseConfig) -> str: |
| 342 | """Serialise a MuseConfig to TOML text. |
| 343 | |
| 344 | Section order: ``[hub]``, ``[remotes.*]``, ``[domain]``, ``[limits]``. |
| 345 | |
| 346 | All key names are validated against ``_TOML_KEY_UNSAFE`` before being |
| 347 | written, preventing TOML injection via crafted domain keys or remote names. |
| 348 | """ |
| 349 | lines: list[str] = [] |
| 350 | |
| 351 | hub = config.get("hub") |
| 352 | if hub: |
| 353 | lines.append("[hub]") |
| 354 | url = hub.get("url", "") |
| 355 | if url: |
| 356 | lines.append(f'url = "{_escape(url)}"') |
| 357 | lines.append("") |
| 358 | |
| 359 | remotes = config.get("remotes") or {} |
| 360 | for remote_name in sorted(remotes): |
| 361 | # Remote names come from _load_config which parses TOML, so they are |
| 362 | # safe at read time. Validate defensively before writing. |
| 363 | _validate_toml_key(remote_name, "remote name") |
| 364 | entry = remotes[remote_name] |
| 365 | lines.append(f"[remotes.{remote_name}]") |
| 366 | rurl = entry.get("url", "") |
| 367 | if rurl: |
| 368 | lines.append(f'url = "{_escape(rurl)}"') |
| 369 | branch = entry.get("branch", "") |
| 370 | if branch: |
| 371 | lines.append(f'branch = "{_escape(branch)}"') |
| 372 | if "promisor" in entry: |
| 373 | lines.append(f'promisor = {"true" if entry["promisor"] else "false"}') |
| 374 | lines.append("") |
| 375 | |
| 376 | domain = config.get("domain") or {} |
| 377 | if domain: |
| 378 | lines.append("[domain]") |
| 379 | for key, val in sorted(domain.items()): |
| 380 | _validate_toml_key(key, "domain key") |
| 381 | lines.append(f'{key} = "{_escape(val)}"') |
| 382 | lines.append("") |
| 383 | |
| 384 | limits = config.get("limits") or {} |
| 385 | if limits: |
| 386 | lines.append("[limits]") |
| 387 | mwc = limits.get("max_walk_commits") |
| 388 | if mwc is not None: |
| 389 | lines.append(f"max_walk_commits = {mwc}") |
| 390 | ma = limits.get("max_ancestors") |
| 391 | if ma is not None: |
| 392 | lines.append(f"max_ancestors = {ma}") |
| 393 | mgc = limits.get("max_graph_commits") |
| 394 | if mgc is not None: |
| 395 | lines.append(f"max_graph_commits = {mgc}") |
| 396 | spl = limits.get("shard_prefix_length") |
| 397 | if spl is not None: |
| 398 | lines.append(f"shard_prefix_length = {spl}") |
| 399 | lines.append("") |
| 400 | |
| 401 | commit_cfg = config.get("commit") or {} |
| 402 | if commit_cfg: |
| 403 | lines.append("[commit]") |
| 404 | if "sign" in commit_cfg: |
| 405 | lines.append(f"sign = {'true' if commit_cfg['sign'] else 'false'}") |
| 406 | lines.append("") |
| 407 | |
| 408 | reflog_cfg = config.get("reflog") or {} |
| 409 | if reflog_cfg: |
| 410 | lines.append("[reflog]") |
| 411 | ed = reflog_cfg.get("expire_days") |
| 412 | if ed is not None: |
| 413 | lines.append(f"expire_days = {ed}") |
| 414 | lines.append("") |
| 415 | |
| 416 | symlog_cfg_out = config.get("symlog") or {} |
| 417 | if symlog_cfg_out: |
| 418 | lines.append("[symlog]") |
| 419 | sl_ed = symlog_cfg_out.get("expire_days") |
| 420 | if sl_ed is not None: |
| 421 | lines.append(f"expire_days = {sl_ed}") |
| 422 | lines.append("") |
| 423 | |
| 424 | push_cfg_out = config.get("push") or {} |
| 425 | if push_cfg_out: |
| 426 | lines.append("[push]") |
| 427 | if "tags" in push_cfg_out: |
| 428 | lines.append(f"tags = {'true' if push_cfg_out['tags'] else 'false'}") |
| 429 | lines.append("") |
| 430 | |
| 431 | branch_sections = config.get("branch") or {} |
| 432 | for bname in sorted(branch_sections): |
| 433 | _validate_toml_key(bname, "branch name") |
| 434 | bmeta = branch_sections[bname] |
| 435 | # Skip empty metadata dicts — no section needed. |
| 436 | if not bmeta: |
| 437 | continue |
| 438 | # Branch names require quoted keys (may contain '/' and other chars |
| 439 | # that are not valid in bare TOML keys). |
| 440 | lines.append(f'[branch."{_escape(bname)}"]') |
| 441 | intent = bmeta.get("intent", "") |
| 442 | if intent: |
| 443 | lines.append(f'intent = "{_escape(intent)}"') |
| 444 | if "resumable" in bmeta: |
| 445 | lines.append(f"resumable = {'true' if bmeta['resumable'] else 'false'}") |
| 446 | remote = bmeta.get("remote", "") |
| 447 | if remote: |
| 448 | lines.append(f'remote = "{_escape(remote)}"') |
| 449 | merge = bmeta.get("merge", "") |
| 450 | if merge: |
| 451 | lines.append(f'merge = "{_escape(merge)}"') |
| 452 | lines.append("") |
| 453 | |
| 454 | return "\n".join(lines) |
| 455 | |
| 456 | # --------------------------------------------------------------------------- |
| 457 | # Auth token resolution (via identity store) |
| 458 | # --------------------------------------------------------------------------- |
| 459 | |
| 460 | def get_signing_identity( |
| 461 | repo_root: pathlib.Path | None = None, |
| 462 | remote_url: str | None = None, |
| 463 | agent_id: str | None = None, |
| 464 | ) -> "object | None": |
| 465 | """Return a :class:`~muse.core.transport.SigningIdentity` for a hub, or ``None``. |
| 466 | |
| 467 | Resolution order: |
| 468 | 1. ``MUSE_AGENT_KEY_FD`` environment variable — integer file descriptor |
| 469 | from which exactly 64 bytes of sub-seed are read (then the fd is |
| 470 | closed). The Ed25519 identity key is derived via |
| 471 | :func:`~muse.core.hdkeys.derive_identity_key`. The handle is taken |
| 472 | from ``MUSE_AGENT_HANDLE`` (defaults to *agent_id* if set, else |
| 473 | ``"agent"``). This is the only supported env-based injection mechanism; |
| 474 | the secret travels through the kernel pipe buffer and never appears in |
| 475 | ``/proc/<pid>/environ``. |
| 476 | 2. Agent-specific entry in ``~/.muse/identity.toml`` keyed by |
| 477 | ``"hostname#agent_id"`` — when *agent_id* is provided. |
| 478 | 3. Human entry in ``~/.muse/identity.toml`` keyed by bare hostname. |
| 479 | 4. Hub URL from ``[hub] url`` in ``.muse/config.toml`` (fallback lookup |
| 480 | URL when *remote_url* is not supplied). |
| 481 | |
| 482 | The private key is **never** logged. |
| 483 | |
| 484 | Args: |
| 485 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 486 | remote_url: URL of the specific remote being contacted. |
| 487 | agent_id: Agent handle, e.g. ``"agentception-abc123"``. Used to |
| 488 | try an agent-specific key before falling back to the |
| 489 | human key. |
| 490 | |
| 491 | Returns: |
| 492 | :class:`~muse.core.transport.SigningIdentity` or ``None``. |
| 493 | """ |
| 494 | import os as _os |
| 495 | from muse.core.identity import resolve_signing_identity # avoid circular import |
| 496 | from muse.core.transport import SigningIdentity |
| 497 | |
| 498 | # 1. MUSE_AGENT_KEY_FD — read 64-byte sub-seed from a pipe fd. |
| 499 | # This is the only supported env-var injection mechanism. |
| 500 | # The secret travels through the kernel pipe buffer and never appears |
| 501 | # in /proc/<pid>/environ. |
| 502 | key_fd_str = _os.environ.get("MUSE_AGENT_KEY_FD", "").strip() |
| 503 | if key_fd_str: |
| 504 | try: |
| 505 | key_fd = int(key_fd_str) |
| 506 | import os as _os2 |
| 507 | sub_seed = bytearray(_os2.read(key_fd, 64)) |
| 508 | _os2.close(key_fd) |
| 509 | if len(sub_seed) == 64: |
| 510 | from muse.core.hdkeys import derive_identity_key |
| 511 | dk = derive_identity_key(sub_seed) |
| 512 | from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey |
| 513 | private_key = Ed25519PrivateKey.from_private_bytes(dk.private_bytes) |
| 514 | dk.zero() |
| 515 | sub_seed[:] = b"\x00" * len(sub_seed) |
| 516 | handle = ( |
| 517 | _os.environ.get("MUSE_AGENT_HANDLE", "").strip() |
| 518 | or agent_id |
| 519 | or "agent" |
| 520 | ) |
| 521 | logger.debug("✅ Signing identity from MUSE_AGENT_KEY_FD (handle=%s)", handle) |
| 522 | return SigningIdentity(handle=handle, private_key=private_key) |
| 523 | logger.warning( |
| 524 | "⚠️ MUSE_AGENT_KEY_FD fd=%s yielded %d bytes (expected 64) — falling through", |
| 525 | key_fd_str, len(sub_seed), |
| 526 | ) |
| 527 | except Exception as exc: |
| 528 | logger.warning("⚠️ MUSE_AGENT_KEY_FD could not be read: %s — falling through", exc) |
| 529 | |
| 530 | # 2. Identity store lookup (agent key → human key fallback). |
| 531 | lookup_url: str | None = remote_url or get_hub_url(repo_root) |
| 532 | if lookup_url is None: |
| 533 | logger.debug("⚠️ No hub configured — skipping signing identity lookup") |
| 534 | return None |
| 535 | |
| 536 | result = resolve_signing_identity(lookup_url, agent_id=agent_id) |
| 537 | if result is None: |
| 538 | logger.debug( |
| 539 | "⚠️ No signing identity for hub %s — run `muse auth keygen && muse auth register`", |
| 540 | lookup_url, |
| 541 | ) |
| 542 | return None |
| 543 | |
| 544 | handle, private_key = result |
| 545 | logger.debug("✅ Signing identity resolved for hub %s (handle=%s)", lookup_url, handle) |
| 546 | return SigningIdentity(handle=handle, private_key=private_key) |
| 547 | |
| 548 | # --------------------------------------------------------------------------- |
| 549 | # Hub helpers |
| 550 | # --------------------------------------------------------------------------- |
| 551 | |
| 552 | def get_hub_url(repo_root: pathlib.Path | None = None) -> str | None: |
| 553 | """Return the hub URL from ``[hub] url``, or ``None`` if not configured. |
| 554 | |
| 555 | Resolution order: |
| 556 | 1. ``<repo>/.muse/config.toml`` — repo-local config (highest priority). |
| 557 | 2. ``~/.muse/config.toml`` — global user config (fallback). |
| 558 | |
| 559 | This fallback ensures ``muse auth whoami`` and other hub-aware commands |
| 560 | work without ``--hub`` even when invoked outside a repository, as long as |
| 561 | the user has set a default hub in their global config. |
| 562 | |
| 563 | Args: |
| 564 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 565 | |
| 566 | Returns: |
| 567 | URL string, or ``None``. |
| 568 | """ |
| 569 | config = _load_config(_config_path(repo_root)) |
| 570 | hub = config.get("hub") |
| 571 | if hub is not None: |
| 572 | url = hub.get("url", "") |
| 573 | if url.strip(): |
| 574 | return url.strip() |
| 575 | |
| 576 | # Fall back to ~/.muse/config.toml so hub-aware commands (e.g. `muse auth |
| 577 | # whoami`) work without --hub when invoked outside a repository. |
| 578 | global_config = _load_config(_GLOBAL_CONFIG_FILE) |
| 579 | global_hub = global_config.get("hub") |
| 580 | if global_hub is not None: |
| 581 | url = global_hub.get("url", "") |
| 582 | if url.strip(): |
| 583 | return url.strip() |
| 584 | |
| 585 | return None |
| 586 | |
| 587 | def set_hub_url(url: str, repo_root: pathlib.Path | None = None) -> None: |
| 588 | """Write ``[hub] url`` to ``.muse/config.toml``. |
| 589 | |
| 590 | Preserves all other sections. Creates the config file if absent. |
| 591 | Rejects ``http://`` URLs — Muse never contacts a hub over cleartext HTTP. |
| 592 | |
| 593 | Args: |
| 594 | url: Hub URL (must be ``https://``). |
| 595 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 596 | |
| 597 | Raises: |
| 598 | ValueError: If *url* does not use the ``https://`` scheme. |
| 599 | """ |
| 600 | _is_loopback = url.startswith("http://localhost") or url.startswith("http://127.0.0.1") or url.startswith("http://[::1]") |
| 601 | if not url.startswith("https://") and not _is_loopback: |
| 602 | raise ValueError( |
| 603 | f"Hub URL must use HTTPS. Got: {url!r}\n" |
| 604 | "Muse never connects to a hub over cleartext HTTP.\n" |
| 605 | "(Exception: http://localhost and http://127.0.0.1 are allowed for local development.)" |
| 606 | ) |
| 607 | cp = _config_path(repo_root) |
| 608 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 609 | config = _load_config(cp) |
| 610 | config["hub"] = HubConfig(url=url) |
| 611 | write_text_atomic(cp, _dump_toml(config)) |
| 612 | logger.info("✅ Hub URL set to %s", url) |
| 613 | |
| 614 | def clear_hub_url(repo_root: pathlib.Path | None = None) -> None: |
| 615 | """Remove the ``[hub]`` section from ``.muse/config.toml``. |
| 616 | |
| 617 | Args: |
| 618 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 619 | """ |
| 620 | cp = _config_path(repo_root) |
| 621 | config = _load_config(cp) |
| 622 | if "hub" in config: |
| 623 | del config["hub"] |
| 624 | write_text_atomic(cp, _dump_toml(config)) |
| 625 | logger.info("✅ Hub disconnected") |
| 626 | |
| 627 | # --------------------------------------------------------------------------- |
| 628 | # Generic dotted-key helpers |
| 629 | # --------------------------------------------------------------------------- |
| 630 | |
| 631 | _BlockedNS = dict[str, str] |
| 632 | _BLOCKED_NAMESPACES: _BlockedNS = { |
| 633 | "auth": "Use `muse auth keygen` and `muse auth register` to manage credentials.", |
| 634 | "remotes": "Use `muse remote add/remove/rename` to manage remotes.", |
| 635 | "user": "User identity is managed via `muse auth register`. Run `muse auth whoami` to inspect.", |
| 636 | } |
| 637 | |
| 638 | _SETTABLE_NAMESPACES = {"hub", "domain", "limits", "commit", "reflog", "symlog", "push"} |
| 639 | |
| 640 | # Default cap values — used when the [limits] section is absent or the key |
| 641 | # is not set. These are the same values that were previously hardcoded inside |
| 642 | # the individual functions. |
| 643 | _DEFAULT_MAX_WALK_COMMITS: int = 10_000 |
| 644 | _DEFAULT_MAX_ANCESTORS: int = 50_000 |
| 645 | _DEFAULT_MAX_GRAPH_COMMITS: int = 50_000 |
| 646 | _DEFAULT_SHARD_PREFIX_LENGTH: int = 2 |
| 647 | |
| 648 | def get_limit(key: str, repo_root: pathlib.Path | None = None) -> int: |
| 649 | """Return a ``[limits]`` integer cap from config, or its default. |
| 650 | |
| 651 | Args: |
| 652 | key: Limit key — one of ``max_walk_commits``, ``max_ancestors``, |
| 653 | ``max_graph_commits``. |
| 654 | repo_root: Repository root; ``None`` falls back to ``Path.cwd()``. |
| 655 | |
| 656 | Returns: |
| 657 | Configured integer value, or the built-in default if not set. |
| 658 | """ |
| 659 | defaults: DefaultsMap = { |
| 660 | "max_walk_commits": _DEFAULT_MAX_WALK_COMMITS, |
| 661 | "max_ancestors": _DEFAULT_MAX_ANCESTORS, |
| 662 | "max_graph_commits": _DEFAULT_MAX_GRAPH_COMMITS, |
| 663 | "shard_prefix_length": _DEFAULT_SHARD_PREFIX_LENGTH, |
| 664 | } |
| 665 | default = defaults.get(key, 10_000) |
| 666 | config = _load_config(_config_path(repo_root)) |
| 667 | limits = config.get("limits") or {} |
| 668 | # Explicit key dispatch keeps mypy happy on TypedDict literal-required keys. |
| 669 | if key == "max_walk_commits": |
| 670 | val: int | None = limits.get("max_walk_commits") |
| 671 | elif key == "max_ancestors": |
| 672 | val = limits.get("max_ancestors") |
| 673 | elif key == "max_graph_commits": |
| 674 | val = limits.get("max_graph_commits") |
| 675 | elif key == "shard_prefix_length": |
| 676 | val = limits.get("shard_prefix_length") |
| 677 | else: |
| 678 | val = None |
| 679 | if isinstance(val, int) and val > 0: |
| 680 | return val |
| 681 | return default |
| 682 | |
| 683 | def get_config_value(key: str, repo_root: pathlib.Path | None = None) -> str | None: |
| 684 | """Get a config value by dotted key (e.g. ``user.handle``, ``hub.url``). |
| 685 | |
| 686 | Returns ``None`` when the key is not set or the namespace is unknown. |
| 687 | |
| 688 | Args: |
| 689 | key: Dotted key in ``<namespace>.<subkey>`` form. |
| 690 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 691 | |
| 692 | Returns: |
| 693 | String value, or ``None``. |
| 694 | """ |
| 695 | parts = key.split(".", 1) |
| 696 | if len(parts) != 2: |
| 697 | return None |
| 698 | namespace, subkey = parts |
| 699 | config = _load_config(_config_path(repo_root)) |
| 700 | |
| 701 | if namespace == "user": |
| 702 | # User identity lives in identity.toml, keyed by the configured hub URL. |
| 703 | hub_url = (config.get("hub") or {}).get("url", "") |
| 704 | if not hub_url: |
| 705 | return None |
| 706 | try: |
| 707 | from muse.core.identity import load_identity, hostname_from_url |
| 708 | hostname = hostname_from_url(hub_url) |
| 709 | entry = load_identity(hostname) |
| 710 | if entry is None: |
| 711 | return None |
| 712 | if subkey == "handle": |
| 713 | return entry.get("handle") |
| 714 | if subkey == "type": |
| 715 | return entry.get("type") |
| 716 | if subkey == "display_name": |
| 717 | return entry.get("display_name") |
| 718 | if subkey == "email": |
| 719 | return entry.get("email") |
| 720 | except Exception: |
| 721 | pass |
| 722 | return None |
| 723 | |
| 724 | if namespace == "hub": |
| 725 | hub = config.get("hub") or {} |
| 726 | if subkey == "url": |
| 727 | return hub.get("url") |
| 728 | return None |
| 729 | |
| 730 | if namespace == "domain": |
| 731 | domain = config.get("domain") or {} |
| 732 | return domain.get(subkey) |
| 733 | |
| 734 | if namespace == "limits": |
| 735 | limits = config.get("limits") or {} |
| 736 | if subkey == "max_walk_commits": |
| 737 | v = limits.get("max_walk_commits") |
| 738 | return str(v) if isinstance(v, int) else None |
| 739 | if subkey == "max_ancestors": |
| 740 | v = limits.get("max_ancestors") |
| 741 | return str(v) if isinstance(v, int) else None |
| 742 | if subkey == "max_graph_commits": |
| 743 | v = limits.get("max_graph_commits") |
| 744 | return str(v) if isinstance(v, int) else None |
| 745 | if subkey == "shard_prefix_length": |
| 746 | v = limits.get("shard_prefix_length") |
| 747 | return str(v) if isinstance(v, int) else None |
| 748 | return None |
| 749 | |
| 750 | if namespace == "commit": |
| 751 | commit = config.get("commit") or {} |
| 752 | if subkey == "sign": |
| 753 | v = commit.get("sign") |
| 754 | if v is True: |
| 755 | return "true" |
| 756 | if v is False: |
| 757 | return "false" |
| 758 | return None |
| 759 | return None |
| 760 | |
| 761 | if namespace == "reflog": |
| 762 | reflog = config.get("reflog") or {} |
| 763 | if subkey == "expire-days": |
| 764 | v = reflog.get("expire_days") |
| 765 | return str(v) if isinstance(v, int) else None |
| 766 | return None |
| 767 | |
| 768 | if namespace == "symlog": |
| 769 | symlog = config.get("symlog") or {} |
| 770 | if subkey == "expire-days": |
| 771 | v = symlog.get("expire_days") |
| 772 | return str(v) if isinstance(v, int) else None |
| 773 | return None |
| 774 | |
| 775 | if namespace == "push": |
| 776 | push = config.get("push") or {} |
| 777 | if subkey == "tags": |
| 778 | v = push.get("tags") |
| 779 | if v is True: |
| 780 | return "true" |
| 781 | if v is False: |
| 782 | return "false" |
| 783 | return None |
| 784 | return None |
| 785 | |
| 786 | return None |
| 787 | |
| 788 | def set_config_value(key: str, value: str, repo_root: pathlib.Path | None = None) -> None: |
| 789 | """Set a config value by dotted key (e.g. ``user.handle``, ``domain.ticks_per_beat``). |
| 790 | |
| 791 | Args: |
| 792 | key: Dotted key in ``<namespace>.<subkey>`` form. |
| 793 | value: New string value. |
| 794 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 795 | |
| 796 | Raises: |
| 797 | ValueError: If the namespace is blocked, unknown, or the subkey is invalid. |
| 798 | """ |
| 799 | parts = key.split(".", 1) |
| 800 | if len(parts) != 2: |
| 801 | raise ValueError(f"Key must be in 'namespace.subkey' form, got: {key!r}") |
| 802 | namespace, subkey = parts |
| 803 | |
| 804 | if namespace in _BLOCKED_NAMESPACES: |
| 805 | raise ValueError(_BLOCKED_NAMESPACES[namespace]) |
| 806 | |
| 807 | if namespace not in _SETTABLE_NAMESPACES: |
| 808 | raise ValueError( |
| 809 | f"Unknown config namespace {namespace!r}. " |
| 810 | f"Settable namespaces: {', '.join(sorted(_SETTABLE_NAMESPACES))}" |
| 811 | ) |
| 812 | |
| 813 | cp = _config_path(repo_root) |
| 814 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 815 | config = _load_config(cp) |
| 816 | |
| 817 | if namespace == "user": |
| 818 | set_user_field(subkey, value, repo_root) |
| 819 | return |
| 820 | |
| 821 | if namespace == "hub": |
| 822 | if subkey != "url": |
| 823 | raise ValueError(f"Unknown [hub] config key: {subkey!r}. Valid keys: url") |
| 824 | # Route through set_hub_url — it enforces the HTTPS requirement. |
| 825 | set_hub_url(value, repo_root) |
| 826 | return |
| 827 | |
| 828 | if namespace == "limits": |
| 829 | _LIMITS_KEYS = frozenset({ |
| 830 | "max_walk_commits", "max_ancestors", "max_graph_commits", "shard_prefix_length", |
| 831 | }) |
| 832 | if subkey not in _LIMITS_KEYS: |
| 833 | raise ValueError( |
| 834 | f"Unknown [limits] config key: {subkey!r}. " |
| 835 | f"Valid keys: {', '.join(sorted(_LIMITS_KEYS))}" |
| 836 | ) |
| 837 | try: |
| 838 | int_value = int(value) |
| 839 | except ValueError as exc: |
| 840 | raise ValueError( |
| 841 | f"[limits] {subkey} must be an integer, got: {value!r}" |
| 842 | ) from exc |
| 843 | if int_value <= 0: |
| 844 | raise ValueError(f"[limits] {subkey} must be a positive integer, got: {int_value}") |
| 845 | if subkey == "shard_prefix_length" and int_value not in (2, 4): |
| 846 | raise ValueError("shard_prefix_length must be 2 or 4") |
| 847 | limits_section: LimitsConfig = config.get("limits") or {} |
| 848 | if subkey == "max_walk_commits": |
| 849 | limits_section["max_walk_commits"] = int_value |
| 850 | elif subkey == "max_ancestors": |
| 851 | limits_section["max_ancestors"] = int_value |
| 852 | elif subkey == "max_graph_commits": |
| 853 | limits_section["max_graph_commits"] = int_value |
| 854 | elif subkey == "shard_prefix_length": |
| 855 | limits_section["shard_prefix_length"] = int_value |
| 856 | config["limits"] = limits_section |
| 857 | write_text_atomic(cp, _dump_toml(config)) |
| 858 | logger.info("✅ limits.%s = %d", subkey, int_value) |
| 859 | return |
| 860 | |
| 861 | if namespace == "commit": |
| 862 | _COMMIT_KEYS = frozenset({"sign"}) |
| 863 | if subkey not in _COMMIT_KEYS: |
| 864 | raise ValueError( |
| 865 | f"Unknown [commit] config key: {subkey!r}. " |
| 866 | f"Valid keys: {', '.join(sorted(_COMMIT_KEYS))}" |
| 867 | ) |
| 868 | if value not in ("true", "false"): |
| 869 | raise ValueError(f"[commit] {subkey} must be 'true' or 'false', got: {value!r}") |
| 870 | commit_section: CommitConfig = config.get("commit") or {} |
| 871 | if subkey == "sign": |
| 872 | commit_section["sign"] = value == "true" |
| 873 | config["commit"] = commit_section |
| 874 | write_text_atomic(cp, _dump_toml(config)) |
| 875 | logger.info("✅ commit.%s = %s", subkey, value) |
| 876 | return |
| 877 | |
| 878 | if namespace == "reflog": |
| 879 | _REFLOG_KEYS = frozenset({"expire-days"}) |
| 880 | if subkey not in _REFLOG_KEYS: |
| 881 | raise ValueError( |
| 882 | f"Unknown [reflog] config key: {subkey!r}. " |
| 883 | f"Valid keys: {', '.join(sorted(_REFLOG_KEYS))}" |
| 884 | ) |
| 885 | if subkey == "expire-days": |
| 886 | try: |
| 887 | int_value = int(value) |
| 888 | except ValueError as exc: |
| 889 | raise ValueError( |
| 890 | f"[reflog] expire-days must be a positive integer, got: {value!r}" |
| 891 | ) from exc |
| 892 | if int_value <= 0: |
| 893 | raise ValueError(f"[reflog] expire-days must be a positive integer, got: {int_value}") |
| 894 | reflog_section: dict = config.get("reflog") or {} |
| 895 | reflog_section["expire_days"] = int_value |
| 896 | config["reflog"] = reflog_section |
| 897 | write_text_atomic(cp, _dump_toml(config)) |
| 898 | logger.info("✅ reflog.expire-days = %d", int_value) |
| 899 | return |
| 900 | |
| 901 | if namespace == "symlog": |
| 902 | _SYMLOG_KEYS = frozenset({"expire-days"}) |
| 903 | if subkey not in _SYMLOG_KEYS: |
| 904 | raise ValueError( |
| 905 | f"Unknown [symlog] config key: {subkey!r}. " |
| 906 | f"Valid keys: {', '.join(sorted(_SYMLOG_KEYS))}" |
| 907 | ) |
| 908 | if subkey == "expire-days": |
| 909 | try: |
| 910 | sl_int_value = int(value) |
| 911 | except ValueError as exc: |
| 912 | raise ValueError( |
| 913 | f"[symlog] expire-days must be a positive integer, got: {value!r}" |
| 914 | ) from exc |
| 915 | if sl_int_value <= 0: |
| 916 | raise ValueError(f"[symlog] expire-days must be a positive integer, got: {sl_int_value}") |
| 917 | symlog_section: SymlogConfig = config.get("symlog") or {} |
| 918 | symlog_section["expire_days"] = sl_int_value |
| 919 | config["symlog"] = symlog_section |
| 920 | write_text_atomic(cp, _dump_toml(config)) |
| 921 | logger.info("✅ symlog.expire-days = %d", sl_int_value) |
| 922 | return |
| 923 | |
| 924 | if namespace == "push": |
| 925 | _PUSH_KEYS = frozenset({"tags"}) |
| 926 | if subkey not in _PUSH_KEYS: |
| 927 | raise ValueError( |
| 928 | f"Unknown [push] config key: {subkey!r}. " |
| 929 | f"Valid keys: {', '.join(sorted(_PUSH_KEYS))}" |
| 930 | ) |
| 931 | if value not in ("true", "false"): |
| 932 | raise ValueError(f"[push] {subkey} must be 'true' or 'false', got: {value!r}") |
| 933 | push_section: dict = config.get("push") or {} |
| 934 | push_section[subkey] = value == "true" |
| 935 | config["push"] = push_section |
| 936 | write_text_atomic(cp, _dump_toml(config)) |
| 937 | logger.info("✅ push.%s = %s", subkey, value) |
| 938 | return |
| 939 | |
| 940 | # namespace == "domain" |
| 941 | _validate_toml_key(subkey, "domain key") |
| 942 | domain: DomainConfig = config.get("domain") or {} |
| 943 | domain[subkey] = value |
| 944 | config["domain"] = domain |
| 945 | write_text_atomic(cp, _dump_toml(config)) |
| 946 | logger.info("✅ domain.%s = %r", subkey, value) |
| 947 | |
| 948 | def config_as_dict(repo_root: pathlib.Path | None = None) -> ConfigTree: |
| 949 | """Return the full config as a plain ``dict[str, dict[str, str]]`` for JSON output. |
| 950 | |
| 951 | Credentials are never included — the hub section only contains the URL. |
| 952 | |
| 953 | Args: |
| 954 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 955 | |
| 956 | Returns: |
| 957 | Nested dict suitable for ``json.dumps``. |
| 958 | """ |
| 959 | config = _load_config(_config_path(repo_root)) |
| 960 | result: ConfigTree = {} |
| 961 | |
| 962 | hub = config.get("hub") |
| 963 | if hub: |
| 964 | hub_url = hub.get("url", "") |
| 965 | if hub_url: |
| 966 | result["hub"] = {"url": hub_url} |
| 967 | |
| 968 | remotes = config.get("remotes") or {} |
| 969 | if remotes: |
| 970 | remotes_dict: ConfigSection = {} |
| 971 | for rname, entry in sorted(remotes.items()): |
| 972 | url = entry.get("url", "") |
| 973 | if url: |
| 974 | remotes_dict[rname] = url |
| 975 | if remotes_dict: |
| 976 | result["remotes"] = remotes_dict |
| 977 | |
| 978 | domain = config.get("domain") or {} |
| 979 | if domain: |
| 980 | result["domain"] = dict(sorted(domain.items())) |
| 981 | |
| 982 | limits = config.get("limits") or {} |
| 983 | if limits: |
| 984 | limits_dict: ConfigSection = {} |
| 985 | for lk in ("max_walk_commits", "max_ancestors", "max_graph_commits", "shard_prefix_length"): |
| 986 | lv = limits.get(lk) |
| 987 | if lv is not None: |
| 988 | limits_dict[lk] = str(lv) |
| 989 | if limits_dict: |
| 990 | result["limits"] = limits_dict |
| 991 | |
| 992 | return result |
| 993 | |
| 994 | def config_path_for_editor(repo_root: pathlib.Path | None = None) -> pathlib.Path: |
| 995 | """Return the config path for the ``config edit`` command.""" |
| 996 | return _config_path(repo_root) |
| 997 | |
| 998 | # --------------------------------------------------------------------------- |
| 999 | # Branch metadata helpers |
| 1000 | # --------------------------------------------------------------------------- |
| 1001 | |
| 1002 | def write_branch_meta( |
| 1003 | repo_root: pathlib.Path, |
| 1004 | branch_name: str, |
| 1005 | *, |
| 1006 | intent: str | None = None, |
| 1007 | resumable: bool | None = None, |
| 1008 | ) -> None: |
| 1009 | """Write per-branch metadata to ``[branch."<name>"]`` in ``.muse/config.toml``. |
| 1010 | |
| 1011 | Only the supplied keyword arguments are updated; existing fields |
| 1012 | (``remote``, ``merge``, and previously written ``intent``/``resumable``) |
| 1013 | are preserved unchanged. |
| 1014 | |
| 1015 | Args: |
| 1016 | repo_root: Repository root directory. |
| 1017 | branch_name: Name of the branch (e.g. ``"feat/my-thing"``). |
| 1018 | intent: Short description of what this branch is for. |
| 1019 | resumable: Mark this branch as a resumable agent checkpoint. |
| 1020 | """ |
| 1021 | _validate_toml_key(branch_name, "branch name") |
| 1022 | cp = _config_path(repo_root) |
| 1023 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 1024 | config = _load_config(cp) |
| 1025 | branch_map: dict[str, BranchMeta] = dict(config.get("branch") or {}) |
| 1026 | entry: BranchMeta = dict(branch_map.get(branch_name) or {}) # type: ignore[arg-type] |
| 1027 | if intent is not None: |
| 1028 | entry["intent"] = intent |
| 1029 | if resumable is not None: |
| 1030 | entry["resumable"] = resumable |
| 1031 | branch_map[branch_name] = entry |
| 1032 | config["branch"] = branch_map |
| 1033 | write_text_atomic(cp, _dump_toml(config)) |
| 1034 | |
| 1035 | def delete_branch_meta(repo_root: pathlib.Path, branch_name: str) -> None: |
| 1036 | """Remove the ``[branch."<name>"]`` section from ``.muse/config.toml``. |
| 1037 | |
| 1038 | Called by ``muse branch -d/-D`` after a successful branch deletion so |
| 1039 | stale intent/resumable entries do not accumulate indefinitely. No-op |
| 1040 | when the branch has no metadata or the config file is absent. |
| 1041 | """ |
| 1042 | cp = _config_path(repo_root) |
| 1043 | if not cp.exists(): |
| 1044 | return |
| 1045 | config = _load_config(cp) |
| 1046 | branch_map = dict(config.get("branch") or {}) |
| 1047 | if branch_name not in branch_map: |
| 1048 | return |
| 1049 | del branch_map[branch_name] |
| 1050 | config["branch"] = branch_map |
| 1051 | write_text_atomic(cp, _dump_toml(config)) |
| 1052 | |
| 1053 | def read_branch_meta( |
| 1054 | repo_root: pathlib.Path, |
| 1055 | branch_name: str, |
| 1056 | ) -> BranchMeta: |
| 1057 | """Return per-branch metadata from ``.muse/config.toml``. |
| 1058 | |
| 1059 | Returns an empty dict when the branch has no metadata or the config file |
| 1060 | is absent. |
| 1061 | |
| 1062 | Args: |
| 1063 | repo_root: Repository root directory. |
| 1064 | branch_name: Name of the branch (e.g. ``"feat/my-thing"``). |
| 1065 | |
| 1066 | Returns: |
| 1067 | Dict with any of: ``intent`` (str), ``resumable`` (bool), |
| 1068 | ``remote`` (str), ``merge`` (str). |
| 1069 | """ |
| 1070 | config = _load_config(_config_path(repo_root)) |
| 1071 | branch_map = config.get("branch") or {} |
| 1072 | return dict(branch_map.get(branch_name) or {}) |
| 1073 | |
| 1074 | # --------------------------------------------------------------------------- |
| 1075 | # Protected branches helpers |
| 1076 | # --------------------------------------------------------------------------- |
| 1077 | |
| 1078 | def get_protected_branches(repo_root: pathlib.Path | None = None) -> list[str]: |
| 1079 | """Return the list of protected branch patterns from ``[protected_branches]``. |
| 1080 | |
| 1081 | Returns an empty list when the section is absent or has no ``branches`` key. |
| 1082 | |
| 1083 | Args: |
| 1084 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1085 | """ |
| 1086 | config = _load_config(_config_path(repo_root)) |
| 1087 | return list(config.get("protected_branches") or []) |
| 1088 | |
| 1089 | def is_branch_protected(branch: str, patterns: list[str]) -> bool: |
| 1090 | """Return ``True`` if *branch* matches any pattern in *patterns*. |
| 1091 | |
| 1092 | Patterns are matched with :func:`fnmatch.fnmatch` (shell-style globs). |
| 1093 | Matching is case-sensitive, consistent with Python fnmatch behaviour. |
| 1094 | |
| 1095 | Args: |
| 1096 | branch: Branch name to test (e.g. ``"release/1.0"``). |
| 1097 | patterns: List of patterns from ``[protected_branches] branches``. |
| 1098 | """ |
| 1099 | return any(fnmatch.fnmatch(branch, p) for p in patterns) |
| 1100 | |
| 1101 | # --------------------------------------------------------------------------- |
| 1102 | # Remote helpers |
| 1103 | # --------------------------------------------------------------------------- |
| 1104 | |
| 1105 | def get_remote(name: str, repo_root: pathlib.Path | None = None) -> str | None: |
| 1106 | """Return the URL for remote *name*, or ``None`` when not configured. |
| 1107 | |
| 1108 | Args: |
| 1109 | name: Remote name (e.g. ``"origin"``). |
| 1110 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1111 | |
| 1112 | Returns: |
| 1113 | URL string, or ``None``. |
| 1114 | """ |
| 1115 | config = _load_config(_config_path(repo_root)) |
| 1116 | remotes = config.get("remotes") |
| 1117 | if remotes is None: |
| 1118 | return None |
| 1119 | entry = remotes.get(name) |
| 1120 | if entry is None: |
| 1121 | return None |
| 1122 | url = entry.get("url", "") |
| 1123 | return url.strip() if url.strip() else None |
| 1124 | |
| 1125 | def set_remote( |
| 1126 | name: str, |
| 1127 | url: str, |
| 1128 | repo_root: pathlib.Path | None = None, |
| 1129 | ) -> None: |
| 1130 | """Write ``[remotes.<name>] url`` to ``.muse/config.toml``. |
| 1131 | |
| 1132 | Preserves all other sections. Creates the file if absent. |
| 1133 | |
| 1134 | Args: |
| 1135 | name: Remote name (e.g. ``"origin"``). |
| 1136 | url: Remote URL. |
| 1137 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1138 | """ |
| 1139 | cp = _config_path(repo_root) |
| 1140 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 1141 | config = _load_config(cp) |
| 1142 | existing_remotes = config.get("remotes") |
| 1143 | remotes: RemotesMap = {} |
| 1144 | if existing_remotes: |
| 1145 | remotes.update(existing_remotes) |
| 1146 | existing_entry = remotes.get(name) |
| 1147 | entry: RemoteEntry = {} |
| 1148 | if existing_entry is not None: |
| 1149 | if "url" in existing_entry: |
| 1150 | entry["url"] = existing_entry["url"] |
| 1151 | if "branch" in existing_entry: |
| 1152 | entry["branch"] = existing_entry["branch"] |
| 1153 | entry["url"] = url |
| 1154 | remotes[name] = entry |
| 1155 | config["remotes"] = remotes |
| 1156 | write_text_atomic(cp, _dump_toml(config)) |
| 1157 | logger.info("✅ Remote %r set to %s", name, url) |
| 1158 | |
| 1159 | def remove_remote( |
| 1160 | name: str, |
| 1161 | repo_root: pathlib.Path | None = None, |
| 1162 | ) -> None: |
| 1163 | """Remove a named remote and its tracking refs. |
| 1164 | |
| 1165 | Args: |
| 1166 | name: Remote name to remove. |
| 1167 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1168 | |
| 1169 | Raises: |
| 1170 | KeyError: If *name* is not a configured remote. |
| 1171 | """ |
| 1172 | cp = _config_path(repo_root) |
| 1173 | config = _load_config(cp) |
| 1174 | remotes = config.get("remotes") |
| 1175 | if remotes is None or name not in remotes: |
| 1176 | raise KeyError(name) |
| 1177 | del remotes[name] |
| 1178 | config["remotes"] = remotes |
| 1179 | write_text_atomic(cp, _dump_toml(config)) |
| 1180 | logger.info("✅ Remote %r removed from config", name) |
| 1181 | |
| 1182 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 1183 | refs_dir = _remote_tracking_dir(root, name) |
| 1184 | if refs_dir.is_symlink(): |
| 1185 | # Refuse to rmtree a symlink — following a symlink placed by an |
| 1186 | # attacker could delete files outside the repository tree. |
| 1187 | logger.warning("⚠️ Skipping rmtree: remotes dir %s is a symlink", refs_dir) |
| 1188 | elif refs_dir.is_dir(): |
| 1189 | shutil.rmtree(refs_dir) |
| 1190 | logger.debug("✅ Removed tracking refs dir %s", refs_dir) |
| 1191 | |
| 1192 | def rename_remote( |
| 1193 | old_name: str, |
| 1194 | new_name: str, |
| 1195 | repo_root: pathlib.Path | None = None, |
| 1196 | ) -> None: |
| 1197 | """Rename a remote and move its tracking refs. |
| 1198 | |
| 1199 | Args: |
| 1200 | old_name: Current remote name. |
| 1201 | new_name: Desired new remote name. |
| 1202 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1203 | |
| 1204 | Raises: |
| 1205 | KeyError: If *old_name* is not a configured remote. |
| 1206 | ValueError: If *new_name* is already configured. |
| 1207 | """ |
| 1208 | cp = _config_path(repo_root) |
| 1209 | config = _load_config(cp) |
| 1210 | remotes = config.get("remotes") |
| 1211 | if remotes is None or old_name not in remotes: |
| 1212 | raise KeyError(old_name) |
| 1213 | if new_name in remotes: |
| 1214 | raise ValueError(new_name) |
| 1215 | remotes[new_name] = remotes.pop(old_name) |
| 1216 | config["remotes"] = remotes |
| 1217 | write_text_atomic(cp, _dump_toml(config)) |
| 1218 | logger.info("✅ Remote %r renamed to %r", old_name, new_name) |
| 1219 | |
| 1220 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 1221 | old_refs_dir = _remote_tracking_dir(root, old_name) |
| 1222 | new_refs_dir = _remote_tracking_dir(root, new_name) |
| 1223 | if old_refs_dir.is_dir(): |
| 1224 | old_refs_dir.rename(new_refs_dir) |
| 1225 | logger.debug("✅ Moved tracking refs dir %s → %s", old_refs_dir, new_refs_dir) |
| 1226 | |
| 1227 | def list_remotes(repo_root: pathlib.Path | None = None) -> list[RemoteConfig]: |
| 1228 | """Return all configured remotes sorted alphabetically by name. |
| 1229 | |
| 1230 | Args: |
| 1231 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1232 | |
| 1233 | Returns: |
| 1234 | List of ``{"name": str, "url": str}`` dicts. |
| 1235 | """ |
| 1236 | config = _load_config(_config_path(repo_root)) |
| 1237 | remotes = config.get("remotes") |
| 1238 | if remotes is None: |
| 1239 | return [] |
| 1240 | result: list[RemoteConfig] = [] |
| 1241 | for remote_name in sorted(remotes): |
| 1242 | entry = remotes[remote_name] |
| 1243 | url = entry.get("url", "") |
| 1244 | if not url.strip(): |
| 1245 | continue |
| 1246 | rc = RemoteConfig(name=remote_name, url=url.strip()) |
| 1247 | result.append(rc) |
| 1248 | return result |
| 1249 | |
| 1250 | # --------------------------------------------------------------------------- |
| 1251 | # Remote tracking-head helpers |
| 1252 | # --------------------------------------------------------------------------- |
| 1253 | |
| 1254 | def _remote_head_path( |
| 1255 | remote_name: str, |
| 1256 | branch: str, |
| 1257 | repo_root: pathlib.Path | None = None, |
| 1258 | ) -> pathlib.Path: |
| 1259 | """Return the path to the remote tracking pointer file.""" |
| 1260 | root = (repo_root or pathlib.Path.cwd()).resolve() |
| 1261 | return _remote_ref_path(root, remote_name, branch) |
| 1262 | |
| 1263 | def get_remote_head( |
| 1264 | remote_name: str, |
| 1265 | branch: str, |
| 1266 | repo_root: pathlib.Path | None = None, |
| 1267 | ) -> str | None: |
| 1268 | """Return the last-known remote commit ID for *remote_name*/*branch*. |
| 1269 | |
| 1270 | Returns ``None`` when the tracking pointer does not exist. |
| 1271 | |
| 1272 | Args: |
| 1273 | remote_name: Remote name (e.g. ``"origin"``). |
| 1274 | branch: Branch name (e.g. ``"main"``). |
| 1275 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1276 | |
| 1277 | Returns: |
| 1278 | Commit ID string, or ``None``. |
| 1279 | """ |
| 1280 | return read_ref(_remote_head_path(remote_name, branch, repo_root)) |
| 1281 | |
| 1282 | def set_remote_head( |
| 1283 | remote_name: str, |
| 1284 | branch: str, |
| 1285 | commit_id: str, |
| 1286 | repo_root: pathlib.Path | None = None, |
| 1287 | ) -> None: |
| 1288 | """Write the remote tracking pointer for *remote_name*/*branch*. |
| 1289 | |
| 1290 | Args: |
| 1291 | remote_name: Remote name (e.g. ``"origin"``). |
| 1292 | branch: Branch name. |
| 1293 | commit_id: Commit ID to record as the known remote HEAD. |
| 1294 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1295 | """ |
| 1296 | pointer = _remote_head_path(remote_name, branch, repo_root) |
| 1297 | write_text_atomic(pointer, commit_id) |
| 1298 | logger.debug("✅ Remote head %s/%s → %s", remote_name, branch, short_id(commit_id)) |
| 1299 | |
| 1300 | def delete_remote_head( |
| 1301 | remote_name: str, |
| 1302 | branch: str, |
| 1303 | repo_root: pathlib.Path | None = None, |
| 1304 | ) -> bool: |
| 1305 | """Remove the local remote-tracking pointer for *remote_name*/*branch*. |
| 1306 | |
| 1307 | Used after ``muse push --delete`` deletes the branch on the server, or when |
| 1308 | pruning stale tracking refs with ``muse branch -dr``. |
| 1309 | |
| 1310 | Args: |
| 1311 | remote_name: Remote name (e.g. ``"origin"``). |
| 1312 | branch: Branch name (e.g. ``"feat/my-thing"``). |
| 1313 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1314 | |
| 1315 | Returns: |
| 1316 | ``True`` if the pointer file existed and was removed, ``False`` if it |
| 1317 | was already absent (idempotent). |
| 1318 | """ |
| 1319 | pointer = _remote_head_path(remote_name, branch, repo_root) |
| 1320 | if not pointer.is_file(): |
| 1321 | return False |
| 1322 | pointer.unlink() |
| 1323 | # Remove now-empty parent directories (mirrors _cleanup_empty_dirs in branch.py). |
| 1324 | remotes_dir = pointer.parent |
| 1325 | while remotes_dir.name != remote_name: |
| 1326 | try: |
| 1327 | remotes_dir.rmdir() |
| 1328 | except OSError: |
| 1329 | break |
| 1330 | remotes_dir = remotes_dir.parent |
| 1331 | logger.debug("🗑 Remote tracking ref %s/%s removed", remote_name, branch) |
| 1332 | return True |
| 1333 | |
| 1334 | # --------------------------------------------------------------------------- |
| 1335 | # Upstream tracking helpers |
| 1336 | # --------------------------------------------------------------------------- |
| 1337 | |
| 1338 | def set_upstream( |
| 1339 | branch: str, |
| 1340 | remote_name: str, |
| 1341 | repo_root: pathlib.Path | None = None, |
| 1342 | ) -> None: |
| 1343 | """Record *remote_name* as the upstream remote for *branch*. |
| 1344 | |
| 1345 | Args: |
| 1346 | branch: Local (and remote) branch name. |
| 1347 | remote_name: Remote name. |
| 1348 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1349 | """ |
| 1350 | cp = _config_path(repo_root) |
| 1351 | cp.parent.mkdir(parents=True, exist_ok=True) |
| 1352 | config = _load_config(cp) |
| 1353 | existing_remotes = config.get("remotes") |
| 1354 | remotes: RemotesMap = {} |
| 1355 | if existing_remotes: |
| 1356 | remotes.update(existing_remotes) |
| 1357 | existing_entry = remotes.get(remote_name) |
| 1358 | entry: RemoteEntry = {} |
| 1359 | if existing_entry is not None: |
| 1360 | if "url" in existing_entry: |
| 1361 | entry["url"] = existing_entry["url"] |
| 1362 | if "branch" in existing_entry: |
| 1363 | entry["branch"] = existing_entry["branch"] |
| 1364 | entry["branch"] = branch |
| 1365 | remotes[remote_name] = entry |
| 1366 | config["remotes"] = remotes |
| 1367 | write_text_atomic(cp, _dump_toml(config)) |
| 1368 | logger.info("✅ Upstream for branch %r set to %s/%r", branch, remote_name, branch) |
| 1369 | |
| 1370 | def get_upstream( |
| 1371 | branch: str, |
| 1372 | repo_root: pathlib.Path | None = None, |
| 1373 | ) -> str | None: |
| 1374 | """Return the configured upstream remote name for *branch*, or ``None``. |
| 1375 | |
| 1376 | Args: |
| 1377 | branch: Local branch name. |
| 1378 | repo_root: Repository root. Defaults to ``Path.cwd()``. |
| 1379 | |
| 1380 | Returns: |
| 1381 | Remote name string, or ``None``. |
| 1382 | """ |
| 1383 | config = _load_config(_config_path(repo_root)) |
| 1384 | remotes = config.get("remotes") |
| 1385 | if remotes is None: |
| 1386 | return None |
| 1387 | for rname, entry in remotes.items(): |
| 1388 | tracked = entry.get("branch", "") |
| 1389 | if tracked.strip() == branch: |
| 1390 | return rname |
| 1391 | return None |
| 1392 | |
| 1393 | # --------------------------------------------------------------------------- |
| 1394 | # Global user config — ~/.muse/config.toml (safe_dirs) |
| 1395 | # --------------------------------------------------------------------------- |
| 1396 | |
| 1397 | _GLOBAL_MUSE_DIR = _user_muse_dir() |
| 1398 | _GLOBAL_CONFIG_FILE = _user_config_toml_path() |
| 1399 | |
| 1400 | def _load_global_config() -> _SecurityConfig: |
| 1401 | """Load ``~/.muse/config.toml`` and return the ``[security]`` section. |
| 1402 | |
| 1403 | Returns a dict with key ``safe_dirs`` mapping to a list of path strings. |
| 1404 | Returns ``{"safe_dirs": []}`` when the file is absent or unparseable. |
| 1405 | """ |
| 1406 | if not _GLOBAL_CONFIG_FILE.is_file(): |
| 1407 | return {"safe_dirs": []} |
| 1408 | try: |
| 1409 | with _GLOBAL_CONFIG_FILE.open("rb") as fh: |
| 1410 | raw = tomllib.load(fh) |
| 1411 | except Exception as exc: # noqa: BLE001 |
| 1412 | logger.warning("⚠️ Failed to parse %s: %s", _GLOBAL_CONFIG_FILE, exc) |
| 1413 | return {"safe_dirs": []} |
| 1414 | security_raw = raw.get("security") |
| 1415 | if not isinstance(security_raw, dict): |
| 1416 | return {"safe_dirs": []} |
| 1417 | dirs_raw = security_raw.get("safe_dirs") |
| 1418 | if not isinstance(dirs_raw, list): |
| 1419 | return {"safe_dirs": []} |
| 1420 | safe: list[str] = [d for d in dirs_raw if isinstance(d, str) and d.strip()] |
| 1421 | return {"safe_dirs": safe} |
| 1422 | |
| 1423 | def _save_global_config(safe_dirs: list[str]) -> None: |
| 1424 | """Write ``[security] safe_dirs`` to ``~/.muse/config.toml``. |
| 1425 | |
| 1426 | Preserves any other sections that may exist in the file. |
| 1427 | """ |
| 1428 | import os |
| 1429 | _GLOBAL_MUSE_DIR.mkdir(parents=True, exist_ok=True) |
| 1430 | |
| 1431 | # Read existing raw content to preserve other sections. |
| 1432 | existing_lines: list[str] = [] |
| 1433 | if _GLOBAL_CONFIG_FILE.is_file(): |
| 1434 | try: |
| 1435 | existing_lines = _GLOBAL_CONFIG_FILE.read_text("utf-8").splitlines() |
| 1436 | except Exception: # noqa: BLE001 |
| 1437 | existing_lines = [] |
| 1438 | |
| 1439 | # Strip any existing [security] section from the file. |
| 1440 | filtered: list[str] = [] |
| 1441 | in_security = False |
| 1442 | for line in existing_lines: |
| 1443 | stripped = line.strip() |
| 1444 | if stripped == "[security]": |
| 1445 | in_security = True |
| 1446 | continue |
| 1447 | if in_security and stripped.startswith("["): |
| 1448 | in_security = False |
| 1449 | if not in_security: |
| 1450 | filtered.append(line) |
| 1451 | |
| 1452 | # Remove trailing blank lines before appending the new section. |
| 1453 | while filtered and not filtered[-1].strip(): |
| 1454 | filtered.pop() |
| 1455 | |
| 1456 | # Append the new [security] section. |
| 1457 | filtered.append("") |
| 1458 | filtered.append("[security]") |
| 1459 | if safe_dirs: |
| 1460 | items = ", ".join(f'"{_escape(d)}"' for d in safe_dirs) |
| 1461 | filtered.append(f"safe_dirs = [{items}]") |
| 1462 | else: |
| 1463 | filtered.append("safe_dirs = []") |
| 1464 | filtered.append("") |
| 1465 | |
| 1466 | content = "\n".join(filtered) |
| 1467 | tmp = _GLOBAL_CONFIG_FILE.with_suffix(".toml.tmp") |
| 1468 | tmp.write_text(content, encoding="utf-8") |
| 1469 | os.replace(tmp, _GLOBAL_CONFIG_FILE) |
| 1470 | |
| 1471 | def get_global_safe_dirs() -> list[str]: |
| 1472 | """Return the ``safe_dirs`` list from ``~/.muse/config.toml``. |
| 1473 | |
| 1474 | Returns an empty list when not configured. |
| 1475 | """ |
| 1476 | return _load_global_config().get("safe_dirs", []) |
| 1477 | |
| 1478 | def add_global_safe_dir(path: str) -> None: |
| 1479 | """Add *path* to the ``safe_dirs`` list in ``~/.muse/config.toml``. |
| 1480 | |
| 1481 | Normalises the path (``os.path.abspath``) before storing. Idempotent — |
| 1482 | adding the same path twice has no effect. |
| 1483 | |
| 1484 | Args: |
| 1485 | path: Absolute or relative path to trust. |
| 1486 | """ |
| 1487 | import os |
| 1488 | abs_path = os.path.abspath(path) |
| 1489 | current = get_global_safe_dirs() |
| 1490 | if abs_path not in current: |
| 1491 | current.append(abs_path) |
| 1492 | _save_global_config(current) |
| 1493 | logger.info("✅ Trusted path added: %s", abs_path) |
| 1494 | |
| 1495 | def remove_global_safe_dir(path: str) -> None: |
| 1496 | """Remove *path* from the ``safe_dirs`` list in ``~/.muse/config.toml``. |
| 1497 | |
| 1498 | Normalises the path before matching. No-op when the path is not present. |
| 1499 | |
| 1500 | Args: |
| 1501 | path: Absolute or relative path to remove from the trust list. |
| 1502 | """ |
| 1503 | import os |
| 1504 | abs_path = os.path.abspath(path) |
| 1505 | current = get_global_safe_dirs() |
| 1506 | updated = [d for d in current if d != abs_path] |
| 1507 | _save_global_config(updated) |
| 1508 | logger.info("✅ Trusted path removed: %s", abs_path) |
File History
10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6
chore: bump version to 0.2.0.dev2 — nightly.2
Sonnet 4.6
patch
12 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b
revert: keep pyproject.toml in canonical PEP 440 form
Sonnet 4.6
patch
15 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9
docs: add issue docs for push have-negotiation bug (#55) an…
Sonnet 4.6
19 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74
chore: trigger push to surface null-OID paths
Sonnet 4.6
24 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8
chore: prebuild timing test
Sonnet 4.6
34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1
merge: pull staging/dev — advance to 0.2.0rc12
Sonnet 4.6
patch
36 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea
fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub …
Sonnet 4.6
48 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e
fix: rename objects→blobs in push client and all stale test…
Sonnet 4.6
patch
50 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a
fix: repair four test failures from post-migration audit
Sonnet 4.6
patch
57 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf
fix: unified object store migration — idempotent writes, JS…
Sonnet 4.6
minor
⚠
57 days ago