Skip to content

Commit 35afa7f

Browse files
committed
Cache hook integration metadata
1 parent e3e2657 commit 35afa7f

2 files changed

Lines changed: 61 additions & 13 deletions

File tree

src/specify_cli/extensions.py

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2515,6 +2515,9 @@ def __init__(self, project_root: Path):
25152515
self.extensions_dir = project_root / ".specify" / "extensions"
25162516
self.config_file = project_root / ".specify" / "extensions.yml"
25172517
self._init_options_cache: Optional[Dict[str, Any]] = None
2518+
self._integration_invocation_cache: Optional[
2519+
tuple[Dict[str, Any] | None, str | None, Any | None]
2520+
] = None
25182521

25192522
def _load_init_options(self) -> Dict[str, Any]:
25202523
"""Load persisted init options used to determine invocation style.
@@ -2529,6 +2532,27 @@ def _load_init_options(self) -> Dict[str, Any]:
25292532
self._init_options_cache = payload if isinstance(payload, dict) else {}
25302533
return self._init_options_cache
25312534

2535+
def _load_integration_invocation_context(
2536+
self,
2537+
) -> tuple[Dict[str, Any] | None, str | None, Any | None]:
2538+
"""Load integration metadata used by hook rendering once per executor."""
2539+
if self._integration_invocation_cache is None:
2540+
try:
2541+
from .integration_state import default_integration_key, try_read_integration_json
2542+
from .integrations import get_integration
2543+
2544+
state, _ = try_read_integration_json(self.project_root)
2545+
key = default_integration_key(state) if state else None
2546+
integration = get_integration(key) if key else None
2547+
self._integration_invocation_cache = (state, key, integration)
2548+
except Exception:
2549+
# Hook rendering must keep working for projects with older or
2550+
# unreadable integration metadata; the init-options fallback
2551+
# below preserves the legacy behavior in that case.
2552+
self._integration_invocation_cache = (None, None, None)
2553+
2554+
return self._integration_invocation_cache
2555+
25322556
@staticmethod
25332557
def _skill_name_from_command(command: Any) -> str:
25342558
"""Map a command id like speckit.plan to speckit-plan skill name."""
@@ -2551,20 +2575,15 @@ def _render_hook_invocation(self, command: Any) -> str:
25512575
if command_id.startswith("speckit."):
25522576
try:
25532577
from .integration_runtime import user_command_invocation_for_integration
2554-
from .integration_state import default_integration_key, try_read_integration_json
2555-
from .integrations import get_integration
25562578

2557-
state, _ = try_read_integration_json(self.project_root)
2558-
if state:
2559-
key = default_integration_key(state)
2560-
integration = get_integration(key) if key else None
2561-
if integration:
2562-
return user_command_invocation_for_integration(
2563-
integration,
2564-
state,
2565-
key,
2566-
command_id,
2567-
)
2579+
state, key, integration = self._load_integration_invocation_context()
2580+
if state and key and integration:
2581+
return user_command_invocation_for_integration(
2582+
integration,
2583+
state,
2584+
key,
2585+
command_id,
2586+
)
25682587
except Exception:
25692588
# Hook rendering must keep working for projects with older or
25702589
# unreadable integration metadata; the init-options fallback

tests/test_extensions.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5101,6 +5101,35 @@ def fake_load_init_options(_project_root):
51015101
assert hook_executor._render_hook_invocation("speckit.tasks") == "/skill:speckit-tasks"
51025102
assert calls["count"] == 1
51035103

5104+
def test_hook_executor_caches_integration_state_lookup(self, project_dir, monkeypatch):
5105+
"""Integration metadata should be loaded once per executor instance."""
5106+
calls = {"count": 0}
5107+
state = {
5108+
"default_integration": "codex",
5109+
"integration": "codex",
5110+
"installed_integrations": ["codex"],
5111+
"integration_settings": {
5112+
"codex": {
5113+
"invoke_separator": "-",
5114+
"command_prefix": "$",
5115+
}
5116+
},
5117+
}
5118+
5119+
def fake_try_read_integration_json(_project_root):
5120+
calls["count"] += 1
5121+
return state, None
5122+
5123+
monkeypatch.setattr(
5124+
"specify_cli.integration_state.try_read_integration_json",
5125+
fake_try_read_integration_json,
5126+
)
5127+
5128+
hook_executor = HookExecutor(project_dir)
5129+
assert hook_executor._render_hook_invocation("speckit.plan") == "$speckit-plan"
5130+
assert hook_executor._render_hook_invocation("speckit.tasks") == "$speckit-tasks"
5131+
assert calls["count"] == 1
5132+
51045133
def test_hook_message_falls_back_when_invocation_is_empty(self, project_dir):
51055134
"""Hook messages should still render actionable command placeholders."""
51065135
init_options = project_dir / ".specify" / "init-options.json"

0 commit comments

Comments
 (0)