"""Internal bottle `extends:` resolution for manifests.""" from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from .manifest import ManifestBottle from .manifest_egress import ManifestEgressConfig def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]: """Apply `extends:` chains and return resolved ManifestBottle objects.""" cache: dict[str, ManifestBottle] = {} # Per-bottle effective git-gate.repos, as raw dicts keyed by repo name. # Threaded alongside `cache` so a child can field-merge against its # parent's repos without reconstructing them from parsed entries. repos_cache: dict[str, dict[str, object]] = {} for name in raws: if name not in cache: _resolve_one_bottle(name, raws, cache, repos_cache, ()) return cache def _resolve_one_bottle( name: str, raws: dict[str, dict[str, object]], cache: dict[str, ManifestBottle], repos_cache: dict[str, dict[str, object]], seen: tuple[str, ...], ) -> ManifestBottle: from .manifest import ManifestBottle, ManifestError if name in cache: return cache[name] if name in seen: chain = " -> ".join(seen + (name,)) raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}") raw = raws[name] parent_name_raw = raw.get("extends") # Strip `extends:` before passing to ManifestBottle.from_dict so it # is not accidentally treated as a real ManifestBottle field by future # schema additions. It is only meaningful here. child_raw = {k: v for k, v in raw.items() if k != "extends"} if parent_name_raw is None: bottle = ManifestBottle.from_dict(name, child_raw) cache[name] = bottle repos_cache[name] = _resolve_repos_raw({}, child_raw) return bottle if not isinstance(parent_name_raw, str): raise ManifestError( f"bottle '{name}' extends must be a string " f"(was {type(parent_name_raw).__name__})" ) parent_name: str = parent_name_raw if parent_name == name: raise ManifestError( f"bottle '{name}' extends itself; remove the " f"self-reference" ) if parent_name not in raws: avail = ", ".join(sorted(raws.keys())) or "(none)" raise ManifestError( f"bottle '{name}' extends '{parent_name}' which is not " f"defined. Available bottles: {avail}" ) parent = _resolve_one_bottle( parent_name, raws, cache, repos_cache, seen + (name,) ) merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw) bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name) cache[name] = bottle repos_cache[name] = merged_repos_raw return bottle def _merge_bottles( parent: ManifestBottle, child_raw: dict[str, object], merged_repos_raw: dict[str, object], name: str, ) -> ManifestBottle: """Apply PRD 0025 merge rules.""" from .manifest import ManifestBottle, ManifestGitUser from .manifest_egress import validate_egress_routes from .manifest_util import as_json_object # git-gate.repos: when the child declares repos, inject the already # name-merged repo set (computed by _resolve_repos_raw) so the child # parses with the full inherited+overridden list (issue #237). if _child_declares_git_gate_repos(child_raw): git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate") child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}} # Parse the child's declared fields into a ManifestBottle (with the # usual defaults for anything missing). Validation runs the same # way it would for a leaf bottle: typos / wrong types die here. child = ManifestBottle.from_dict(name, child_raw) # env: dict merge, child wins on collision. merged_env = {**parent.env, **child.env} # git-gate.user: per-field overlay. Each non-empty field on child # wins; empties fall through to parent. The default ManifestGitUser() # is two empty strings, so a child that omits git-gate.user # inherits the parent's user verbatim. merged_git_user = ManifestGitUser( name=child.git_user.name or parent.git_user.name, email=child.git_user.email or parent.git_user.email, ) # git-gate.repos: when declared, child.git already holds the merged # set (an explicit empty dict clears parent, leaving child.git empty). # When omitted, the parent's entries are inherited verbatim. if _child_declares_git_gate_repos(child_raw): merged_git = child.git else: merged_git = parent.git # egress.routes: missing means inherit; otherwise parent and child # route lists concatenate. Other egress scalar fields remain # presence-driven overlays. merged_egress = ( _merge_egress(parent.egress, child.egress, child_raw) if "egress" in child_raw else parent.egress ) # Presence-driven full-replace for the remaining scalar fields. merged_agent_provider = ( child.agent_provider if "agent_provider" in child_raw else parent.agent_provider ) merged_supervise = ( child.supervise if "supervise" in child_raw else parent.supervise ) validate_egress_routes(name, merged_egress.routes) return ManifestBottle( env=merged_env, agent_provider=merged_agent_provider, git=merged_git, git_user=merged_git_user, egress=merged_egress, supervise=merged_supervise, ) def _resolve_repos_raw( parent_repos: dict[str, object], child_raw: dict[str, object], ) -> dict[str, object]: """Compute a bottle's effective git-gate.repos as raw dicts. Repos are keyed by name. When the child omits git-gate.repos it inherits the parent's set verbatim; an explicit empty dict clears it. Otherwise parent and child unite by name, with same-name entries field-merged (parent fields are defaults, child fields win).""" from .manifest_util import as_json_object if not _child_declares_git_gate_repos(child_raw): return parent_repos child_repos = _declared_repos_raw(child_raw) if not child_repos: return {} # Parent entries keep their order; child-only names are appended. names = list(parent_repos) + [n for n in child_repos if n not in parent_repos] return { name: { **as_json_object(parent_repos.get(name, {}), "parent git-gate repo"), **as_json_object(child_repos.get(name, {}), "child git-gate repo"), } for name in names } def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]: """Return the child's explicitly declared git-gate.repos as raw dicts, or an empty dict when none are declared.""" from .manifest_util import as_json_object if not _child_declares_git_gate_repos(child_raw): return {} git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate") return as_json_object(git_raw.get("repos", {}), "child git-gate.repos") def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool: from .manifest_util import as_json_object git_raw = child_raw.get("git-gate") if git_raw is None: return False git_obj = as_json_object(git_raw, "child git-gate") return "repos" in git_obj def _merge_egress( parent: ManifestEgressConfig, child: ManifestEgressConfig, child_raw: dict[str, object], ) -> ManifestEgressConfig: from .manifest_egress import ManifestEgressConfig from .manifest_util import as_json_object child_egress_raw = as_json_object(child_raw.get("egress"), "child egress") routes = parent.routes + child.routes log = child.Log if "log" in child_egress_raw else parent.Log return ManifestEgressConfig(routes=routes, Log=log)