Compare commits

..

3 Commits

Author SHA1 Message Date
didericis-claude 0f201430b6 refactor: scan filenames at resolve, parse only selected agent at preflight
lint / lint (push) Successful in 1m33s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 16s
Manifest.resolve() now returns an empty-dict manifest with only directory
paths recorded (home_md, cwd_md). No content is read from any .md file
until load_for_agent() is called for a specific agent at preflight.

- Manifest.from_md_dirs: scan-only, no frontmatter parsing
- Manifest.load_for_agent: parses the selected agent file and its bottle
  chain; works on eager (from_json_obj) manifests too by returning self
- Manifest.all_agent_names: scans filenames in lazy mode
- backend._validate: calls load_for_agent and propagates upgraded spec
- cli/info.py, cli/list.py, cli/start.py: use load_for_agent / all_agent_names
- manifest_extends.py: reverted to original (no partial-resolve helpers)
- manifest_loader.py: only scan_agent_names + load_bottle_chain_from_dir
- Tests updated to call load_for_agent before accessing agents/bottles;
  test_md_agent_repos_deferred renamed to test_md_agent_repos_fails_at_preflight
2026-06-20 02:45:33 +00:00
didericis-claude 6db2227307 fix: resolve pyright reportUnusedImport in manifest_extends
lint / lint (push) Successful in 1m32s
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 16s
Import ManifestError at module level from manifest_util (no circular
dep) and remove the redundant local imports from function bodies that
were shadowing it. ManifestBottle retains its local import pattern to
avoid the circular manifest ↔ manifest_extends dependency.
2026-06-20 02:24:49 +00:00
didericis-claude ca26ee35fe feat: defer broken manifest parse errors to preflight
lint / lint (push) Failing after 1m31s
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 15s
Broken bottle/agent files no longer block the agent selector or prevent
unrelated agents from loading. Per-file parse errors are collected in
`Manifest.broken_agents`; the CLI selector includes them via
`all_agent_names`, and the error surfaces only when the specific agent
is selected and launch is attempted (in `require_agent`/`bottle_for`).

Closes #236
2026-06-20 02:02:28 +00:00
12 changed files with 134 additions and 187 deletions
+13 -1
View File
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
from ..egress import EgressPlan from ..egress import EgressPlan
from ..git_gate import GitGatePlan from ..git_gate import GitGatePlan
from ..log import die, info from ..log import die, info
from ..manifest import Manifest from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..env import resolve_env, ResolvedEnv from ..env import resolve_env, ResolvedEnv
@@ -367,7 +367,9 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
manifest = spec.manifest.load_for_agent(spec.agent_name) manifest = spec.manifest.load_for_agent(spec.agent_name)
spec = replace(spec, manifest=manifest) spec = replace(spec, manifest=manifest)
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills) self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec) self._validate_agent_provider_dockerfile(spec)
return spec return spec
@@ -383,6 +385,16 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run." f"Create it under ~/.claude/skills/, then re-run."
) )
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already
enforced by Manifest validation; this only checks presence."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"git upstream key file not found for '{entry.Name}': {key}")
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None: def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name) bottle = spec.manifest.bottle_for(spec.agent_name)
dockerfile = bottle.agent_provider.dockerfile dockerfile = bottle.agent_provider.dockerfile
+16 -5
View File
@@ -12,11 +12,22 @@ import shlex
# uses true/24-bit colors for its own chrome, which would otherwise bypass # uses true/24-bit colors for its own chrome, which would otherwise bypass
# the palette entirely. # the palette entirely.
_COLORS: dict[str, tuple[int, str, int, str, str]] = { _COLORS: dict[str, tuple[int, str, int, str, str]] = {
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"), "black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"),
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"), "red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"),
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"), "green": (2, "#27ae60", 10, "#2ecc71", "#071a09"),
"blue": (12, "#3498db", 4, "#2471a3", "#080820"), "yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"),
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"), "blue": (4, "#2471a3", 12, "#3498db", "#07071a"),
"magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"),
"cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"),
"white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"),
"bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"),
"bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
"bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
"bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
"bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"),
"bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
"bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"),
"bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"),
} }
# OSC 104 resets all indexed palette entries; OSC 111 resets default background. # OSC 104 resets all indexed palette entries; OSC 111 resets default background.
+16 -5
View File
@@ -11,11 +11,22 @@ from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
_ANSI_COLOR_CODES: dict[str, str] = { _ANSI_COLOR_CODES: dict[str, str] = {
"red": "\033[91m", "black": "\033[30m",
"green": "\033[92m", "red": "\033[31m",
"yellow": "\033[93m", "green": "\033[32m",
"blue": "\033[94m", "yellow": "\033[33m",
"magenta": "\033[95m", "blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
} }
_ANSI_RESET = "\033[0m" _ANSI_RESET = "\033[0m"
+11 -3
View File
@@ -226,15 +226,20 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ANSI_COLORS = [ _ANSI_COLORS = [
"red", "green", "yellow", "blue", "magenta", "red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
"bright-red", "bright-green", "bright-blue", "bright-yellow",
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
] ]
_CURSES_COLOR_MAP: dict[str, int] = { _CURSES_COLOR_MAP: dict[str, int] = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED, "red": curses.COLOR_RED,
"green": curses.COLOR_GREEN, "green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW, "yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE, "blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA, "magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE,
} }
_COLOR_NONE = "(none)" _COLOR_NONE = "(none)"
@@ -374,10 +379,13 @@ def _init_color_pairs() -> dict[str, int]:
curses.use_default_colors() curses.use_default_colors()
pair_idx = 2 # pair 1 reserved for other uses pair_idx = 2 # pair 1 reserved for other uses
for name in _ANSI_COLORS: for name in _ANSI_COLORS:
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE) base = name.replace("bright-", "")
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
try: try:
curses.init_pair(pair_idx, fg, -1) curses.init_pair(pair_idx, fg, -1)
attr = curses.color_pair(pair_idx) | curses.A_BOLD attr = curses.color_pair(pair_idx)
if name.startswith("bright-"):
attr |= curses.A_BOLD
attrs[name] = attr attrs[name] = attr
pair_idx += 1 pair_idx += 1
except curses.error: except curses.error:
+32 -10
View File
@@ -42,19 +42,41 @@ def _prompt_path(guest_home: str) -> str:
_STATUS_LINE_COLORS = { _STATUS_LINE_COLORS = {
"red": "\033[91m", "black": "\033[30m",
"green": "\033[92m", "red": "\033[31m",
"yellow": "\033[93m", "green": "\033[32m",
"blue": "\033[94m", "yellow": "\033[33m",
"magenta": "\033[95m", "blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
} }
_CLAUDE_THEME_COLORS = { _CLAUDE_THEME_COLORS = {
"red": "redBright", "black": "black",
"green": "greenBright", "red": "red",
"yellow": "yellowBright", "green": "green",
"blue": "blueBright", "yellow": "yellow",
"magenta": "magentaBright", "blue": "blue",
"magenta": "magenta",
"cyan": "cyan",
"white": "white",
"bright-black": "blackBright",
"bright-red": "redBright",
"bright-green": "greenBright",
"bright-yellow": "yellowBright",
"bright-blue": "blueBright",
"bright-magenta": "magentaBright",
"bright-cyan": "cyanBright",
"bright-white": "whiteBright",
} }
+18 -66
View File
@@ -5,20 +5,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import ManifestBottle from .manifest import ManifestBottle, ManifestGitEntry
from .manifest_egress import ManifestEgressConfig from .manifest_egress import ManifestEgressConfig
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]: def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved ManifestBottle objects.""" """Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, ManifestBottle] = {} 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: for name in raws:
if name not in cache: if name not in cache:
_resolve_one_bottle(name, raws, cache, repos_cache, ()) _resolve_one_bottle(name, raws, cache, ())
return cache return cache
@@ -26,7 +22,6 @@ def _resolve_one_bottle(
name: str, name: str,
raws: dict[str, dict[str, object]], raws: dict[str, dict[str, object]],
cache: dict[str, ManifestBottle], cache: dict[str, ManifestBottle],
repos_cache: dict[str, dict[str, object]],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> ManifestBottle: ) -> ManifestBottle:
from .manifest import ManifestBottle, ManifestError from .manifest import ManifestBottle, ManifestError
@@ -46,7 +41,6 @@ def _resolve_one_bottle(
if parent_name_raw is None: if parent_name_raw is None:
bottle = ManifestBottle.from_dict(name, child_raw) bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle cache[name] = bottle
repos_cache[name] = _resolve_repos_raw({}, child_raw)
return bottle return bottle
if not isinstance(parent_name_raw, str): if not isinstance(parent_name_raw, str):
@@ -66,33 +60,20 @@ def _resolve_one_bottle(
f"bottle '{name}' extends '{parent_name}' which is not " f"bottle '{name}' extends '{parent_name}' which is not "
f"defined. Available bottles: {avail}" f"defined. Available bottles: {avail}"
) )
parent = _resolve_one_bottle( parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
parent_name, raws, cache, repos_cache, seen + (name,) bottle = _merge_bottles(parent, child_raw, 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 cache[name] = bottle
repos_cache[name] = merged_repos_raw
return bottle return bottle
def _merge_bottles( def _merge_bottles(
parent: ManifestBottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
merged_repos_raw: dict[str, object],
name: str, name: str,
) -> ManifestBottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """Apply PRD 0025 merge rules."""
from .manifest import ManifestBottle, ManifestGitUser from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes 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 # Parse the child's declared fields into a ManifestBottle (with the
# usual defaults for anything missing). Validation runs the same # usual defaults for anything missing). Validation runs the same
@@ -111,11 +92,11 @@ def _merge_bottles(
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
# git-gate.repos: when declared, child.git already holds the merged # git-gate.repos: missing means inherit; an explicit empty object
# set (an explicit empty dict clears parent, leaving child.git empty). # clears; otherwise parent and child merge by UpstreamHost with
# When omitted, the parent's entries are inherited verbatim. # child entries replacing duplicate hosts.
if _child_declares_git_gate_repos(child_raw): if _child_declares_git_gate_repos(child_raw):
merged_git = child.git merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
else: else:
merged_git = parent.git merged_git = parent.git
@@ -149,45 +130,6 @@ def _merge_bottles(
) )
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: def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
from .manifest_util import as_json_object from .manifest_util import as_json_object
@@ -198,6 +140,16 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
return "repos" in git_obj return "repos" in git_obj
def _merge_git_remotes(
parent: tuple[ManifestGitEntry, ...],
child: tuple[ManifestGitEntry, ...],
) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
return tuple(by_host.values())
def _merge_egress( def _merge_egress(
parent: ManifestEgressConfig, parent: ManifestEgressConfig,
child: ManifestEgressConfig, child: ManifestEgressConfig,
+4 -3
View File
@@ -92,9 +92,10 @@ class TestSandboxEscape(unittest.TestCase):
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh" "on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
) )
# Throwaway "identity file" for the git-gate's `identity` field. # Throwaway "identity file" so the manifest's _validate_git_entries
# It need not be a real SSH key: test 5 reaches gitleaks before # passes (it only checks `os.path.isfile`, not that the content is
# any SSH attempt anyway. # a real SSH key). Test 5 reaches gitleaks before any SSH attempt
# anyway.
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.") fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
os.close(fd) os.close(fd)
cls._key_path = Path(kp) cls._key_path = Path(kp)
+1 -1
View File
@@ -74,7 +74,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
instance_name="bot-bottle-test", instance_name="bot-bottle-test",
prompt_file=prompt_file, prompt_file=prompt_file,
label="review-api", label="review-api",
color="cyan", color="bright-cyan",
) )
prompt = prompt_file.read_text() prompt = prompt_file.read_text()
config = Path(tmp, "codex-config.toml").read_text() config = Path(tmp, "codex-config.toml").read_text()
+11 -8
View File
@@ -11,14 +11,14 @@ class TestPalettePrintf(unittest.TestCase):
def test_known_color_returns_printf(self): def test_known_color_returns_printf(self):
cmd = palette_printf("red") cmd = palette_printf("red")
self.assertTrue(cmd.startswith("printf '")) self.assertTrue(cmd.startswith("printf '"))
self.assertIn("\\033]4;9;", cmd) # bright-red slot self.assertIn("\\033]4;1;", cmd) # normal red
self.assertIn("\\033]4;1;", cmd) # normal-red slot self.assertIn("\\033]4;9;", cmd) # bright red
self.assertIn("\\033]11;", cmd) # default background tint self.assertIn("\\033]11;", cmd) # default background tint
def test_color_sets_both_palette_slots(self): def test_bright_variant_sets_both_slots(self):
cmd = palette_printf("blue") cmd = palette_printf("bright-blue")
self.assertIn("\\033]4;12;", cmd) # bright-blue slot self.assertIn("\\033]4;12;", cmd) # bright-blue
self.assertIn("\\033]4;4;", cmd) # normal-blue slot self.assertIn("\\033]4;4;", cmd) # blue
def test_unknown_color_returns_empty(self): def test_unknown_color_returns_empty(self):
self.assertEqual("", palette_printf("")) self.assertEqual("", palette_printf(""))
@@ -26,7 +26,10 @@ class TestPalettePrintf(unittest.TestCase):
def test_all_named_colors_produce_output(self): def test_all_named_colors_produce_output(self):
colors = [ colors = [
"red", "green", "yellow", "blue", "magenta", "black", "red", "green", "yellow",
"blue", "magenta", "cyan", "white",
"bright-black", "bright-red", "bright-green", "bright-yellow",
"bright-blue", "bright-magenta", "bright-cyan", "bright-white",
] ]
for color in colors: for color in colors:
with self.subTest(color=color): with self.subTest(color=color):
@@ -62,7 +65,7 @@ class TestExecShellScript(unittest.TestCase):
self.assertFalse(agent_part.startswith("exec ")) self.assertFalse(agent_part.startswith("exec "))
def test_title_and_color_both_appear(self): def test_title_and_color_both_appear(self):
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="magenta") script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan")
assert script is not None assert script is not None
self.assertIn("bot", script) self.assertIn("bot", script)
self.assertIn("\\033]4;", script) self.assertIn("\\033]4;", script)
+3 -3
View File
@@ -276,7 +276,7 @@ class TestClaudeUiProvision(unittest.TestCase):
instance_name="bot-bottle-demo-abc12", instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file, prompt_file=prompt_file,
label="research-ui", label="research-ui",
color="blue", color="bright-cyan",
) )
settings = json.loads((state_dir / "claude-settings.json").read_text()) settings = json.loads((state_dir / "claude-settings.json").read_text())
statusline = (state_dir / "claude-statusline.sh").read_text() statusline = (state_dir / "claude-statusline.sh").read_text()
@@ -288,9 +288,9 @@ class TestClaudeUiProvision(unittest.TestCase):
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
self.assertIn("research-ui", statusline) self.assertIn("research-ui", statusline)
self.assertIn("\x1b[94m", statusline) self.assertIn("\x1b[96m", statusline)
self.assertEqual("dark", theme["base"]) self.assertEqual("dark", theme["base"])
self.assertEqual("ansi:blueBright", theme["overrides"]["claude"]) self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"])
def test_runs_verify_commands(self): def test_runs_verify_commands(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
+1 -1
View File
@@ -158,7 +158,7 @@ class TestCodexProvisionPrompt(unittest.TestCase):
instance_name="bot-bottle-demo-abc12", instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file, prompt_file=prompt_file,
label="research-ui", label="research-ui",
color="cyan", color="bright-cyan",
) )
config = (state_dir / "codex-config.toml").read_text() config = (state_dir / "codex-config.toml").read_text()
prompt_text = prompt_file.read_text() prompt_text = prompt_file.read_text()
+8 -81
View File
@@ -113,8 +113,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by name, """git-gate.user overlays by field; git-gate.repos merges by upstream
with same-name child entries merging field-by-field (child wins).""" host, with child entries replacing duplicate hosts."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
@@ -130,21 +130,19 @@ class TestExtendsGitMerge(unittest.TestCase):
names = [e.Name for e in m.bottles["child"].git] names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_repo_different_name_same_host_coexists(self): def test_child_git_repo_replaces_same_host(self):
# Repos are keyed by Name, not UpstreamHost: two repos with replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}}
# different names on the same host both survive the merge.
same_host_b = {"url": "ssh://git@host-a/b.git", "key": {"provider": "static", "path": "/dev/null"}}
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={ child={
"extends": "base", "extends": "base",
"git-gate": {"repos": {"a2": same_host_b}}, "git-gate": {"repos": {"a2": replacement}},
}, },
) )
entries = m.bottles["child"].git entries = m.bottles["child"].git
self.assertEqual(2, len(entries)) self.assertEqual(1, len(entries))
names = {e.Name for e in entries} self.assertEqual("a2", entries[0].Name)
self.assertEqual({"a", "a2"}, names) self.assertEqual("replacement.git", entries[0].UpstreamPath)
def test_child_omits_git_gate_inherits_full_list(self): def test_child_omits_git_gate_inherits_full_list(self):
m = _build( m = _build(
@@ -166,77 +164,6 @@ class TestExtendsGitMerge(unittest.TestCase):
) )
self.assertEqual((), m.bottles["child"].git) self.assertEqual((), m.bottles["child"].git)
def test_child_same_name_repo_merges_key_field(self):
# Issue #237: child repo with same name as parent should merge
# field-by-field. Child overrides only `key`; parent's url and
# host_key are preserved.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"host_key": "ecdsa-sha2-nistp256 AAAA",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"key": {"provider": "gitea", "forge_token_env": "GITEA_TOKEN"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
e = entries[0]
self.assertEqual("repo", e.Name)
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
self.assertEqual("ecdsa-sha2-nistp256 AAAA", e.KnownHostKey)
self.assertEqual("gitea", e.Key.provider)
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
def test_child_same_name_repo_overrides_url(self):
# Child can override url on a same-name repo; other parent fields
# fall through.
parent_entry = {
"url": "ssh://git@host-a/old.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"url": "ssh://git@host-b/new.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("ssh://git@host-b/new.git", entries[0].Upstream)
def test_child_same_name_plus_new_repo(self):
# Same-name repo is field-merged; a distinct new name in child
# is appended.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {
"repo": {"key": {"provider": "gitea", "forge_token_env": "TOK"}},
"other": self._GIT_ENTRY_B,
}},
},
)
child = m.bottles["child"]
names = {e.Name for e in child.git}
self.assertEqual({"repo", "other"}, names)
repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_git_user_inherits_parent_repos(self): def test_child_git_user_inherits_parent_repos(self):
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},