Skip to content

Commit e232048

Browse files
authored
fix(integrations): refresh manifest hashes after init --force and switch rollback (#5193)
`specify init --force` and the rollback of a failed `integration switch` re-register installed extensions and presets after the integration manifest is written, but never refresh the hashes of files those presets rewrite. A preset that overrides a core command then shows up as a modified file in `integration status`, and `integration upgrade` refuses without `--force`. #5145 fixed the same gap for `switch` and `use`. `init --force` saves a fresh manifest right before registration, so it resyncs every tracked file, like `upgrade` and a switch to a new target. The rollback restores an integration whose manifest was written earlier, so it snapshots the unmodified files first, like `use`, and earlier user edits are still reported as modified. Both steps stay best-effort. Refs #5078 Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
1 parent 5becbff commit e232048

4 files changed

Lines changed: 192 additions & 0 deletions

File tree

‎src/specify_cli/command_init.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ def init(
743743
from .integrations._helpers import (
744744
_register_extensions_for_agent,
745745
_register_presets_for_agent,
746+
_resync_manifest_after_registration,
746747
)
747748

748749
_register_extensions_for_agent(
@@ -763,6 +764,16 @@ def init(
763764
),
764765
)
765766

767+
# The manifest was saved right before registration, so every
768+
# tracked file is unmodified; passing them all keeps recovered
769+
# markers on files registration didn't change (#5078).
770+
_resync_manifest_after_registration(
771+
manifest,
772+
resolved_integration.key,
773+
unmodified_files=set(manifest.files),
774+
continuing="The project was re-initialized, but its manifest hashes may be stale.",
775+
)
776+
766777
integration_settings = _with_integration_setting(
767778
{},
768779
resolved_integration.key,

‎src/specify_cli/integrations/command_switch.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,9 @@ def integration_switch(
362362
# artifacts. Rescaffold so the restored default is
363363
# actually usable. Both helpers are best-effort and
364364
# cannot raise past this point.
365+
fallback_manifest, unmodified_files = _snapshot_manifest_before_registration(
366+
project_root, fallback_key
367+
)
365368
_register_extensions_for_agent(
366369
project_root,
367370
fallback_key,
@@ -372,6 +375,13 @@ def integration_switch(
372375
fallback_key,
373376
continuing="The switch was rolled back; installed presets may need re-registration.",
374377
)
378+
if fallback_manifest is not None:
379+
_resync_manifest_after_registration(
380+
fallback_manifest,
381+
fallback_key,
382+
unmodified_files=unmodified_files,
383+
continuing="The switch was rolled back, but its manifest hashes may be stale.",
384+
)
375385
else:
376386
_write_integration_json(
377387
project_root, fallback_key, installed_keys, _integration_settings(current)

‎tests/specify_cli/integrations/test_command_switch.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,102 @@ def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
967967
template = project / ".specify" / "templates" / "plan-template.md"
968968
assert "$speckit-plan" in template.read_text(encoding="utf-8")
969969

970+
@pytest.mark.parametrize("edited_command", [None, "plan", "tasks"])
971+
def test_failed_switch_syncs_fallback_manifest_without_blessing_user_edits(
972+
self, tmp_path, tasks_preset_src, monkeypatch, edited_command
973+
):
974+
"""Rollback refreshes preset hashes while retaining earlier edits (#5078)."""
975+
from specify_cli.integrations import get_integration
976+
from specify_cli.integrations.manifest import IntegrationManifest
977+
978+
project = _init_project(tmp_path, "claude")
979+
result = _run_in_project(project, ["integration", "install", "codex", "--script", "sh"])
980+
assert result.exit_code == 0, result.output
981+
result = _run_in_project(project, ["preset", "add", "--dev", str(tasks_preset_src)])
982+
assert result.exit_code == 0, result.output
983+
if edited_command:
984+
edited_rel = f".agents/skills/speckit-{edited_command}/SKILL.md"
985+
before = IntegrationManifest.load("codex", project).files[edited_rel]
986+
(project / edited_rel).write_bytes(b"User-edited skill\n")
987+
988+
def fail_setup(*args, **kwargs):
989+
raise OSError("injected setup failure")
990+
991+
monkeypatch.setattr(type(get_integration("gemini")), "setup", fail_setup)
992+
result = _run_in_project(project, ["integration", "switch", "gemini", "--script", "sh"])
993+
assert result.exit_code == 1, result.output
994+
assert "injected setup failure" in " ".join(strip_ansi(result.output).split())
995+
data = json.loads((project / ".specify/integration.json").read_bytes())
996+
assert data["integration"] == "codex", "precondition: fallback restored"
997+
manifest = IntegrationManifest.load("codex", project)
998+
if edited_command:
999+
assert manifest.files[edited_rel] == before, "pre-existing edits must not be recorded as pristine"
1000+
assert edited_rel in manifest.check_modified()
1001+
else:
1002+
rel = ".agents/skills/speckit-tasks/SKILL.md"
1003+
content = (project / rel).read_bytes()
1004+
assert b"Preset tasks body" in content
1005+
assert manifest.files[rel] == hashlib.sha256(content).hexdigest(), (
1006+
"manifest hash must match the preset-overridden skill (#5078)"
1007+
)
1008+
result = _run_in_project(project, ["integration", "status", "--json"])
1009+
assert result.exit_code == 0, result.output
1010+
report = json.loads(result.output)
1011+
assert report["modified_managed_files"] == (1 if edited_command else 0)
1012+
result = _run_in_project(project, ["integration", "upgrade", "codex", "--script", "sh"])
1013+
assert result.exit_code == (1 if edited_command else 0), result.output
1014+
if edited_command:
1015+
assert "modified since installation" in result.output
1016+
1017+
@pytest.mark.parametrize("state", ["missing", "corrupt", "save-error"])
1018+
def test_failed_switch_manifest_refresh_is_best_effort(
1019+
self, tmp_path, tasks_preset_src, monkeypatch, state
1020+
):
1021+
"""Manifest failures must retain registration and the switch error (#5078)."""
1022+
from specify_cli.integrations import get_integration
1023+
from specify_cli.integrations.manifest import IntegrationManifest
1024+
1025+
project = _init_project(tmp_path, "claude")
1026+
result = _run_in_project(project, ["integration", "install", "codex", "--script", "sh"])
1027+
assert result.exit_code == 0, result.output
1028+
result = _run_in_project(project, ["preset", "add", "--dev", str(tasks_preset_src)])
1029+
assert result.exit_code == 0, result.output
1030+
manifest_path = project / ".specify/integrations/codex.manifest.json"
1031+
if state == "missing":
1032+
manifest_path.unlink()
1033+
elif state == "corrupt":
1034+
manifest_path.write_bytes(b"{invalid")
1035+
else:
1036+
original_save = IntegrationManifest.save
1037+
1038+
def fail_fallback_save(self):
1039+
if self.key == "codex":
1040+
raise OSError("injected manifest save failure")
1041+
return original_save(self)
1042+
1043+
monkeypatch.setattr(IntegrationManifest, "save", fail_fallback_save)
1044+
1045+
def fail_setup(*args, **kwargs):
1046+
raise OSError("injected setup failure")
1047+
1048+
monkeypatch.setattr(type(get_integration("gemini")), "setup", fail_setup)
1049+
result = _run_in_project(project, ["integration", "switch", "gemini", "--script", "sh"])
1050+
1051+
assert result.exit_code == 1, result.output
1052+
output = " ".join(strip_ansi(result.output).split())
1053+
assert "Error: Failed to install integration 'gemini' during switch: injected setup failure" in output
1054+
assert b"Preset tasks body" in (project / ".agents/skills/speckit-tasks/SKILL.md").read_bytes()
1055+
if state == "missing":
1056+
assert not manifest_path.exists(), "no manifest may be created"
1057+
assert "Warning:" not in output
1058+
elif state == "corrupt":
1059+
assert manifest_path.read_bytes() == b"{invalid"
1060+
assert "snapshot manifest hashes for integration 'codex'" in output
1061+
assert "resync manifest hashes" not in output
1062+
else:
1063+
assert "injected manifest save failure" in output
1064+
assert "The switch was rolled back, but its manifest hashes may be stale." in output
1065+
9701066
def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
9711067
"""Regression (review 3624184343).
9721068

‎tests/specify_cli/test_command_init.py‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
import hashlib
1920
import importlib
2021
import json
2122
import os
@@ -301,3 +302,77 @@ def test_rendering_init_preserves_skill_guidance(tmp_path, monkeypatch):
301302
"clarify", "analyze", "checklist"):
302303
assert f"/speckit-{command}" in output
303304
assert (tmp_path / "proj" / ".github" / "skills" / f"speckit-{command}" / "SKILL.md").is_file()
305+
306+
307+
def test_init_force_syncs_manifest_hash_for_preset_override(tmp_path, monkeypatch):
308+
"""Re-init must record preset bytes and retain setup's other files (#5078)."""
309+
from specify_cli.integrations.manifest import IntegrationManifest
310+
311+
monkeypatch.chdir(tmp_path)
312+
args = ["--integration", "codex", "--script", "sh", "--ignore-agent-tools", "--non-interactive"]
313+
result = CliRunner().invoke(app, ["init", "proj", *args])
314+
assert result.exit_code == 0, result.output
315+
project = tmp_path / "proj"
316+
preset = tmp_path / "tasks-preset"
317+
(preset / "commands").mkdir(parents=True)
318+
(preset / "commands/speckit.tasks.md").write_bytes(
319+
b"---\ndescription: Tasks override\n---\nPreset tasks body\n"
320+
)
321+
(preset / "preset.yml").write_bytes(b"""schema_version: '1.0'
322+
preset: {id: tasks-preset, name: Tasks Preset, version: 1.0.0, description: x}
323+
requires: {speckit_version: '>=0.1.0'}
324+
provides:
325+
templates:
326+
- {type: command, name: speckit.tasks, file: commands/speckit.tasks.md}
327+
""")
328+
monkeypatch.chdir(project)
329+
result = CliRunner().invoke(app, ["preset", "add", "--dev", str(preset)])
330+
assert result.exit_code == 0, result.output
331+
plan_rel = ".agents/skills/speckit-plan/SKILL.md"
332+
original_plan = (project / plan_rel).read_bytes()
333+
(project / plan_rel).write_bytes(b"User-edited skill\n")
334+
335+
result = CliRunner().invoke(app, ["init", "--here", "--force", *args])
336+
assert result.exit_code == 0, result.output
337+
338+
manifest = IntegrationManifest.load("codex", project)
339+
rel = ".agents/skills/speckit-tasks/SKILL.md"
340+
content = (project / rel).read_bytes()
341+
assert b"Preset tasks body" in content
342+
assert manifest.files[rel] == hashlib.sha256(content).hexdigest(), (
343+
"manifest hash must match the preset-overridden skill (#5078)"
344+
)
345+
assert (project / plan_rel).read_bytes() == original_plan, "force re-init still replaces user edits"
346+
assert manifest.files[plan_rel] == hashlib.sha256(original_plan).hexdigest()
347+
result = CliRunner().invoke(app, ["integration", "status", "--json"])
348+
assert result.exit_code == 0, result.output
349+
assert json.loads(result.output)["modified_managed_files"] == 0
350+
result = CliRunner().invoke(app, ["integration", "upgrade", "codex", "--script", "sh"])
351+
assert result.exit_code == 0, result.output
352+
353+
354+
def test_init_force_keeps_recovered_markers(tmp_path, monkeypatch):
355+
"""Re-init resync must not re-record files registration left unchanged (#5078)."""
356+
from specify_cli.integrations import get_integration
357+
from specify_cli.integrations.manifest import IntegrationManifest
358+
359+
monkeypatch.chdir(tmp_path)
360+
args = ["--integration", "codex", "--script", "sh", "--ignore-agent-tools", "--non-interactive"]
361+
result = CliRunner().invoke(app, ["init", "proj", *args])
362+
assert result.exit_code == 0, result.output
363+
project = tmp_path / "proj"
364+
integration_class = type(get_integration("codex"))
365+
original_setup = integration_class.setup
366+
rel = ".agents/skills/speckit-plan/SKILL.md"
367+
368+
def setup_with_recovered_file(self, project_root, manifest, *args, **kwargs):
369+
created = original_setup(self, project_root, manifest, *args, **kwargs)
370+
manifest.record_existing(rel, recovered=True)
371+
return created
372+
373+
monkeypatch.setattr(integration_class, "setup", setup_with_recovered_file)
374+
monkeypatch.chdir(project)
375+
result = CliRunner().invoke(app, ["init", "--here", "--force", *args])
376+
377+
assert result.exit_code == 0, result.output
378+
assert IntegrationManifest.load("codex", project).is_recovered(rel)

0 commit comments

Comments
 (0)