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
11 changes: 11 additions & 0 deletions src/specify_cli/command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,7 @@ def init(
from .integrations._helpers import (
_register_extensions_for_agent,
_register_presets_for_agent,
_resync_manifest_after_registration,
)

_register_extensions_for_agent(
Expand All @@ -763,6 +764,16 @@ def init(
),
)

# 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 (#5078).
_resync_manifest_after_registration(
manifest,
resolved_integration.key,
unmodified_files=set(manifest.files),
continuing="The project was re-initialized, but its manifest hashes may be stale.",
)

integration_settings = _with_integration_setting(
{},
resolved_integration.key,
Expand Down
10 changes: 10 additions & 0 deletions src/specify_cli/integrations/command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ def integration_switch(
# artifacts. Rescaffold so the restored default is
# actually usable. Both helpers are best-effort and
# cannot raise past this point.
fallback_manifest, unmodified_files = _snapshot_manifest_before_registration(
project_root, fallback_key
)
_register_extensions_for_agent(
project_root,
fallback_key,
Expand All @@ -372,6 +375,13 @@ def integration_switch(
fallback_key,
continuing="The switch was rolled back; installed presets may need re-registration.",
)
if fallback_manifest is not None:
_resync_manifest_after_registration(
fallback_manifest,
fallback_key,
unmodified_files=unmodified_files,
continuing="The switch was rolled back, but its manifest hashes may be stale.",
)
else:
_write_integration_json(
project_root, fallback_key, installed_keys, _integration_settings(current)
Expand Down
96 changes: 96 additions & 0 deletions tests/specify_cli/integrations/test_command_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,6 +967,102 @@ def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
template = project / ".specify" / "templates" / "plan-template.md"
assert "$speckit-plan" in template.read_text(encoding="utf-8")

@pytest.mark.parametrize("edited_command", [None, "plan", "tasks"])
def test_failed_switch_syncs_fallback_manifest_without_blessing_user_edits(
self, tmp_path, tasks_preset_src, monkeypatch, edited_command
):
"""Rollback refreshes preset hashes while retaining earlier edits (#5078)."""
from specify_cli.integrations import get_integration
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
if edited_command:
edited_rel = f".agents/skills/speckit-{edited_command}/SKILL.md"
before = IntegrationManifest.load("codex", project).files[edited_rel]
(project / edited_rel).write_bytes(b"User-edited skill\n")

def fail_setup(*args, **kwargs):
raise OSError("injected setup failure")

monkeypatch.setattr(type(get_integration("gemini")), "setup", fail_setup)
result = _run_in_project(project, ["integration", "switch", "gemini", "--script", "sh"])
assert result.exit_code == 1, result.output
assert "injected setup failure" in " ".join(strip_ansi(result.output).split())
data = json.loads((project / ".specify/integration.json").read_bytes())
assert data["integration"] == "codex", "precondition: fallback restored"
manifest = IntegrationManifest.load("codex", project)
if edited_command:
assert manifest.files[edited_rel] == before, "pre-existing edits must not be recorded as pristine"
assert edited_rel in manifest.check_modified()
else:
rel = ".agents/skills/speckit-tasks/SKILL.md"
content = (project / rel).read_bytes()
assert b"Preset tasks body" in content
assert manifest.files[rel] == hashlib.sha256(content).hexdigest(), (
"manifest hash must match the preset-overridden skill (#5078)"
)
result = _run_in_project(project, ["integration", "status", "--json"])
assert result.exit_code == 0, result.output
report = json.loads(result.output)
assert report["modified_managed_files"] == (1 if edited_command else 0)
result = _run_in_project(project, ["integration", "upgrade", "codex", "--script", "sh"])
assert result.exit_code == (1 if edited_command else 0), result.output
if edited_command:
assert "modified since installation" in result.output

@pytest.mark.parametrize("state", ["missing", "corrupt", "save-error"])
def test_failed_switch_manifest_refresh_is_best_effort(
self, tmp_path, tasks_preset_src, monkeypatch, state
):
"""Manifest failures must retain registration and the switch error (#5078)."""
from specify_cli.integrations import get_integration
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
manifest_path = project / ".specify/integrations/codex.manifest.json"
if state == "missing":
manifest_path.unlink()
elif state == "corrupt":
manifest_path.write_bytes(b"{invalid")
else:
original_save = IntegrationManifest.save

def fail_fallback_save(self):
if self.key == "codex":
raise OSError("injected manifest save failure")
return original_save(self)

monkeypatch.setattr(IntegrationManifest, "save", fail_fallback_save)

def fail_setup(*args, **kwargs):
raise OSError("injected setup failure")

monkeypatch.setattr(type(get_integration("gemini")), "setup", fail_setup)
result = _run_in_project(project, ["integration", "switch", "gemini", "--script", "sh"])

assert result.exit_code == 1, result.output
output = " ".join(strip_ansi(result.output).split())
assert "Error: Failed to install integration 'gemini' during switch: injected setup failure" in output
assert b"Preset tasks body" in (project / ".agents/skills/speckit-tasks/SKILL.md").read_bytes()
if state == "missing":
assert not manifest_path.exists(), "no manifest may be created"
assert "Warning:" not in output
elif state == "corrupt":
assert manifest_path.read_bytes() == b"{invalid"
assert "snapshot manifest hashes for integration 'codex'" in output
assert "resync manifest hashes" not in output
else:
assert "injected manifest save failure" in output
assert "The switch was rolled back, but its manifest hashes may be stale." in output

def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
"""Regression (review 3624184343).
Expand Down
75 changes: 75 additions & 0 deletions tests/specify_cli/test_command_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import hashlib
import importlib
import json
import os
Expand Down Expand Up @@ -301,3 +302,77 @@ def test_rendering_init_preserves_skill_guidance(tmp_path, monkeypatch):
"clarify", "analyze", "checklist"):
assert f"/speckit-{command}" in output
assert (tmp_path / "proj" / ".github" / "skills" / f"speckit-{command}" / "SKILL.md").is_file()


def test_init_force_syncs_manifest_hash_for_preset_override(tmp_path, monkeypatch):
"""Re-init must record preset bytes and retain setup's other files (#5078)."""
from specify_cli.integrations.manifest import IntegrationManifest

monkeypatch.chdir(tmp_path)
args = ["--integration", "codex", "--script", "sh", "--ignore-agent-tools", "--non-interactive"]
result = CliRunner().invoke(app, ["init", "proj", *args])
assert result.exit_code == 0, result.output
project = tmp_path / "proj"
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}
""")
monkeypatch.chdir(project)
result = CliRunner().invoke(app, ["preset", "add", "--dev", str(preset)])
assert result.exit_code == 0, result.output
plan_rel = ".agents/skills/speckit-plan/SKILL.md"
original_plan = (project / plan_rel).read_bytes()
(project / plan_rel).write_bytes(b"User-edited skill\n")

result = CliRunner().invoke(app, ["init", "--here", "--force", *args])
assert result.exit_code == 0, result.output

manifest = IntegrationManifest.load("codex", project)
rel = ".agents/skills/speckit-tasks/SKILL.md"
content = (project / rel).read_bytes()
assert b"Preset tasks body" in content
assert manifest.files[rel] == hashlib.sha256(content).hexdigest(), (
"manifest hash must match the preset-overridden skill (#5078)"
)
assert (project / plan_rel).read_bytes() == original_plan, "force re-init still replaces user edits"
assert manifest.files[plan_rel] == hashlib.sha256(original_plan).hexdigest()
result = CliRunner().invoke(app, ["integration", "status", "--json"])
assert result.exit_code == 0, result.output
assert json.loads(result.output)["modified_managed_files"] == 0
result = CliRunner().invoke(app, ["integration", "upgrade", "codex", "--script", "sh"])
assert result.exit_code == 0, result.output


def test_init_force_keeps_recovered_markers(tmp_path, monkeypatch):
"""Re-init resync must not re-record files registration left unchanged (#5078)."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest

monkeypatch.chdir(tmp_path)
args = ["--integration", "codex", "--script", "sh", "--ignore-agent-tools", "--non-interactive"]
result = CliRunner().invoke(app, ["init", "proj", *args])
assert result.exit_code == 0, result.output
project = tmp_path / "proj"
integration_class = type(get_integration("codex"))
original_setup = integration_class.setup
rel = ".agents/skills/speckit-plan/SKILL.md"

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

monkeypatch.setattr(integration_class, "setup", setup_with_recovered_file)
monkeypatch.chdir(project)
result = CliRunner().invoke(app, ["init", "--here", "--force", *args])

assert result.exit_code == 0, result.output
assert IntegrationManifest.load("codex", project).is_recovered(rel)
Loading