From 140fdacd234e542c989c4821ce56fb42dac0b648 Mon Sep 17 00:00:00 2001 From: kartsan03 Date: Fri, 30 Oct 2026 00:52:16 +0000 Subject: [PATCH] fix(integrations): refresh manifest hashes after init --force and switch rollback `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) --- src/specify_cli/command_init.py | 11 +++ .../integrations/command_switch.py | 10 ++ .../integrations/test_command_switch.py | 96 +++++++++++++++++++ tests/specify_cli/test_command_init.py | 75 +++++++++++++++ 4 files changed, 192 insertions(+) diff --git a/src/specify_cli/command_init.py b/src/specify_cli/command_init.py index 31fa2277dd..fb09c74fb2 100644 --- a/src/specify_cli/command_init.py +++ b/src/specify_cli/command_init.py @@ -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( @@ -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, diff --git a/src/specify_cli/integrations/command_switch.py b/src/specify_cli/integrations/command_switch.py index f9a42de3ea..ce3e3d1892 100644 --- a/src/specify_cli/integrations/command_switch.py +++ b/src/specify_cli/integrations/command_switch.py @@ -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, @@ -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) diff --git a/tests/specify_cli/integrations/test_command_switch.py b/tests/specify_cli/integrations/test_command_switch.py index 8341c9adee..35e3164936 100644 --- a/tests/specify_cli/integrations/test_command_switch.py +++ b/tests/specify_cli/integrations/test_command_switch.py @@ -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). diff --git a/tests/specify_cli/test_command_init.py b/tests/specify_cli/test_command_init.py index f932167cdf..4d2c4cd3b5 100644 --- a/tests/specify_cli/test_command_init.py +++ b/tests/specify_cli/test_command_init.py @@ -16,6 +16,7 @@ from __future__ import annotations +import hashlib import importlib import json import os @@ -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)