Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,36 +483,102 @@ def _register_presets_for_agent(
)


def _snapshot_manifest_before_registration(
project_root: Path, agent_key: str,
) -> tuple[Any, set[str]]:
"""Snapshot an installed manifest's unmodified files before rescaffolding (#5078).
Same rules as ``check_modified()``, applied one file at a time after
``record_existing()``'s path check, so no file outside the project is
read. A path that fails the check or can't be read counts as modified,
with a warning: its hash isn't refreshed after registration.
"""
try:
from .manifest import IntegrationManifest, _sha256, _validate_record_path

manifest = IntegrationManifest.load(agent_key, project_root)
unmodified: set[str] = set()
for rel, expected_hash in manifest.files.items():
# RuntimeError is a symlink loop in Path.resolve() before 3.13.
try:
abs_path = _validate_record_path(Path(rel), manifest.project_root)
if not abs_path.exists() or (
abs_path.is_file() and _sha256(abs_path) == expected_hash
):
unmodified.add(rel)
except (OSError, RuntimeError, ValueError) as file_err:
from .. import _print_cli_warning

_print_cli_warning(
"snapshot manifest hash for",
"file",
rel,
file_err,
continuing="Its hash won't be refreshed after registration.",
)
return manifest, unmodified
except FileNotFoundError:
return None, set()
except Exception as manifest_err: # noqa: BLE001 - manifest refresh is best-effort
from .. import _print_cli_warning

_print_cli_warning(
"snapshot manifest hashes for",
"integration",
agent_key,
manifest_err,
continuing="Continuing without refreshing manifest hashes.",
)
return None, set()


def _resync_manifest_after_registration(
new_manifest: Any,
agent_key: str,
*,
continuing: str,
unmodified_files: set[str] | None = None,
) -> None:
"""Refresh tracked-file hashes after extensions/presets re-registration.
``_register_extensions_for_agent`` / ``_register_presets_for_agent`` run
after ``new_manifest`` is saved and can overwrite files it already
tracks (e.g. a preset overriding a core command rendered as a skill).
Nothing else touches the project between the manifest save and these
calls, so any tracked file whose bytes now differ was changed by our own
registration step, not by the user — re-hash it and persist the refresh
so ``check_modified()`` doesn't misreport a legitimate override as
tampering (see #4696).
For ``upgrade`` (#4696) and a new-target ``switch`` (#5078), nothing else
touches the project between the manifest save and these calls, so any
tracked file whose bytes now differ was changed by registration. Re-hash
it so ``check_modified()`` doesn't misreport a legitimate override.
``use`` and installed-target ``switch`` reuse an older manifest (#5078).
They must pass ``unmodified_files`` from before registration; only those
files whose bytes changed are refreshed, preserving earlier user edits.
A new-target ``switch`` passes every tracked file, so files registration
didn't change keep their recovered markers.
Best-effort: registration itself is best-effort, so a failure here must
not abort the surrounding upgrade/use/switch transaction.
"""
try:
from .manifest import _sha256, _validate_record_path

changed = False
for rel in new_manifest.files:
for rel, expected_hash in new_manifest.files.items():
if unmodified_files is not None and rel not in unmodified_files:
continue
abs_path = new_manifest.project_root / rel
try:
if abs_path.is_symlink() or not abs_path.is_file():
continue
# Only re-hash bytes registration changed: record_existing()
# also clears a file's recovered marker. Check the path the
# way record_existing() does before reading it.
if unmodified_files is not None and _sha256(
_validate_record_path(Path(rel), new_manifest.project_root)
) == expected_hash:
continue
new_manifest.record_existing(rel)
changed = True
except (ValueError, OSError) as file_err:
except (ValueError, OSError, RuntimeError) as file_err:
from .. import _print_cli_warning

_print_cli_warning(
Expand Down
20 changes: 19 additions & 1 deletion src/specify_cli/integrations/command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
)
from ._commands import integration_app
from ._lifecycle import external_lifecycle, lifecycle_owns_rollback, lifecycle_success
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _clear_init_options_for_integration, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json
from ._helpers import _MANIFEST_READ_ERRORS, _SharedTemplateRefreshError, _clear_init_options_for_integration, _cli_error_detail, _cli_phase_label, _get_speckit_version, _read_integration_json, _register_extensions_for_agent, _register_presets_for_agent, _remove_integration_json, _resync_manifest_after_registration, _resolve_integration_options, _resolve_script_type, _set_default_integration, _set_default_integration_or_exit, _snapshot_manifest_before_registration, _unregister_extensions_for_agent, _unregister_presets_for_agent, _write_integration_json


def _missing_required_options(integration, parsed_options) -> list[str]:
Expand Down Expand Up @@ -114,6 +114,7 @@ def integration_switch(
parsed_options=parsed_options,
refresh_templates_force=force,
)
manifest, unmodified_files = _snapshot_manifest_before_registration(project_root, target)
_register_extensions_for_agent(
project_root,
target,
Expand All @@ -130,6 +131,13 @@ def integration_switch(
"need re-registration."
),
)
if manifest is not None:
_resync_manifest_after_registration(
manifest,
target,
unmodified_files=unmodified_files,
continuing="The integration switch succeeded, but its manifest hashes may be stale.",
)
lifecycle_success(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
raise typer.Exit(0)

Expand Down Expand Up @@ -390,5 +398,15 @@ def integration_switch(
continuing="The integration switch succeeded, but installed presets may need re-registration.",
)

# The manifest was saved right before registration, so every tracked
# file is unmodified; passing them all keeps recovered markers on files
# registration didn't change.
_resync_manifest_after_registration(
manifest,
target,
unmodified_files=set(manifest.files),
continuing="The integration switch succeeded, but its manifest hashes may be stale.",
)

name = (target_integration.config or {}).get("name", target)
lifecycle_success(f"\n[green]✓[/green] Switched to integration '{escape(str(name))}'")
10 changes: 10 additions & 0 deletions src/specify_cli/integrations/command_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
_read_integration_json,
_register_extensions_for_agent,
_register_presets_for_agent,
_resync_manifest_after_registration,
_resolve_integration_options,
_set_default_integration_or_exit,
_snapshot_manifest_before_registration,
)


Expand Down Expand Up @@ -58,6 +60,7 @@ def integration_use(
f"[cyan]specify integration use {key} --force[/cyan]."
),
)
manifest, unmodified_files = _snapshot_manifest_before_registration(project_root, key)
_register_extensions_for_agent(
project_root,
key,
Expand All @@ -68,4 +71,11 @@ def integration_use(
key,
continuing="The integration was selected, but installed presets may need re-registration.",
)
if manifest is not None:
_resync_manifest_after_registration(
manifest,
key,
unmodified_files=unmodified_files,
continuing="The integration was selected, but its manifest hashes may be stale.",
)
lifecycle_success(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
17 changes: 17 additions & 0 deletions tests/specify_cli/integrations/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,20 @@ def copilot_project(tmp_path, status_copilot_template):
@pytest.fixture
def claude_project(tmp_path, status_claude_template):
return _copy_project_template(tmp_path, status_claude_template)


@pytest.fixture
def tasks_preset_src(tmp_path):
preset = tmp_path / "tasks-preset"
(preset / "commands").mkdir(parents=True)
(preset / "commands/speckit.tasks.md").write_bytes(
b"---\ndescription: Tasks override\n---\nPreset tasks body\n"
)
(preset / "preset.yml").write_bytes(b"""schema_version: '1.0'
preset: {id: tasks-preset, name: Tasks Preset, version: 1.0.0, description: x}
requires: {speckit_version: '>=0.1.0'}
provides:
templates:
- {type: command, name: speckit.tasks, file: commands/speckit.tasks.md}
""")
return preset
108 changes: 108 additions & 0 deletions tests/specify_cli/integrations/test_command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import hashlib
import json # noqa: F401
import os # noqa: F401
import shutil # noqa: F401
Expand All @@ -26,6 +27,113 @@
)

class TestIntegrationSwitch:
@pytest.mark.parametrize("installed", [False, True])
def test_switch_syncs_manifest_hash_for_preset_override(
self, tmp_path, tasks_preset_src, installed
):
"""Both switch paths must record the preset's final bytes (#5078)."""
project = _init_project(tmp_path, "claude")
if installed:
result = _run_in_project(project, [
"integration", "install", "codex", "--script", "sh",
])
assert result.exit_code == 0, result.output
result = _run_in_project(project, ["preset", "add", "--dev", str(tasks_preset_src)])
assert result.exit_code == 0, result.output

result = _run_in_project(project, ["integration", "switch", "codex", "--script", "sh"])
assert result.exit_code == 0, result.output

rel = ".agents/skills/speckit-tasks/SKILL.md"
content = (project / rel).read_bytes()
assert b"Preset tasks body" in content
manifest = json.loads((project / ".specify/integrations/codex.manifest.json").read_bytes())
assert manifest["files"][rel] == hashlib.sha256(content).hexdigest(), (
"manifest hash must match the preset-overridden skill (#5078)"
)
result = _run_in_project(project, ["integration", "upgrade", "codex", "--script", "sh"])
assert result.exit_code == 0, result.output

def test_switch_new_target_keeps_recovered_markers(self, tmp_path, monkeypatch):
"""The new-target resync must not re-record unchanged files (#5078).
``record_existing()`` clears a recovered marker, and ownership checks
such as the events dispatcher cleanup rely on it.
"""
from specify_cli.integrations.codex import CodexIntegration
from specify_cli.integrations.manifest import IntegrationManifest

rel = ".agents/skills/speckit-plan/SKILL.md"
original_setup = CodexIntegration.setup

def setup_with_recovered_file(self, project_root, manifest, *args, **kwargs):
created = original_setup(self, project_root, manifest, *args, **kwargs)
manifest.record_existing(rel, recovered=True)
return created

project = _init_project(tmp_path, "claude")
monkeypatch.setattr(CodexIntegration, "setup", setup_with_recovered_file)

result = _run_in_project(project, ["integration", "switch", "codex", "--script", "sh"])
assert result.exit_code == 0, result.output
assert IntegrationManifest.load("codex", project).is_recovered(rel)

@pytest.mark.parametrize("state", ["missing", "corrupt"])
def test_switch_installed_target_without_readable_manifest_skips_hash_refresh(
self, tmp_path, tasks_preset_src, state
):
"""A missing or unreadable target manifest skips the hash refresh, nothing else (#5078)."""
project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["integration", "install", "codex", "--script", "sh"])
assert result.exit_code == 0, result.output
result = _run_in_project(project, ["preset", "add", "--dev", str(tasks_preset_src)])
assert result.exit_code == 0, result.output
manifest_path = project / ".specify/integrations/codex.manifest.json"
if state == "missing":
manifest_path.unlink()
else:
manifest_path.write_text("{invalid", encoding="utf-8")

result = _run_in_project(project, ["integration", "switch", "codex"])

assert result.exit_code == 0, result.output
data = json.loads((project / ".specify/integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "codex"
skill = project / ".agents/skills/speckit-tasks/SKILL.md"
assert b"Preset tasks body" in skill.read_bytes(), "registration must still run"
output = strip_ansi(result.output)
if state == "missing":
assert not manifest_path.exists(), "no manifest may be created"
assert "Warning:" not in output
else:
assert manifest_path.read_text(encoding="utf-8") == "{invalid"
assert "snapshot manifest hashes for integration 'codex'" in output

@pytest.mark.parametrize("command", ["plan", "tasks"])
def test_switch_installed_target_preserves_modified_manifest_hash(
self, tmp_path, tasks_preset_src, command
):
"""Rescaffolding must not bless earlier user edits as pristine (#5078)."""
from specify_cli.integrations.manifest import IntegrationManifest

project = _init_project(tmp_path, "claude")
result = _run_in_project(project, ["integration", "install", "codex", "--script", "sh"])
assert result.exit_code == 0, result.output
result = _run_in_project(project, ["preset", "add", "--dev", str(tasks_preset_src)])
assert result.exit_code == 0, result.output
rel = f".agents/skills/speckit-{command}/SKILL.md"
before = IntegrationManifest.load("codex", project).files[rel]
(project / rel).write_bytes(b"User-edited skill\n")

result = _run_in_project(project, ["integration", "switch", "codex"])
assert result.exit_code == 0, result.output
manifest = IntegrationManifest.load("codex", project)
assert manifest.files[rel] == before, "pre-existing edits must not be recorded as pristine"
assert rel in manifest.check_modified()
result = _run_in_project(project, ["integration", "upgrade", "codex", "--script", "sh"])
assert result.exit_code == 1, result.output
assert "modified since installation" in result.output

@pytest.mark.parametrize("command", ["install", "switch"])
def test_invalid_incoming_manifest_does_not_block_rollback(self, tmp_path, monkeypatch, caplog, command):
from specify_cli.integrations.copilot import CopilotIntegration
Expand Down
Loading
Loading