mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
fix(presets): skip an unreadable restore source in preset remove (#4020)
* fix(presets): skip an unreadable restore source in `preset remove` `_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core command template or an extension source. Both of those reads were bare `read_text(encoding="utf-8")` calls, so a project-owned override in `.specify/templates/commands/` that exists but cannot be read or decoded raised a raw `UnicodeDecodeError`/`OSError` straight out of `PresetManager.remove()`, which has no handler for it — `specify preset remove` dies with a traceback. Every other failure in this loop degrades with `continue`: an unsafe registry name, a missing skill subdirectory, a foreign owner. Sibling reads of the very same directory are already guarded — `_infer_legacy_skill_ provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read in `except (OSError, UnicodeDecodeError): continue`, and the read inside `_substitute_core_template` was just given the same boundary in #3961. The two restore reads were the remaining gap. `continue` is the right recovery here rather than falling through: the `else` branch below removes the skill outright, so treating an unreadable source as "no source" would delete a user's skill at exactly the moment its replacement cannot be generated. Skipping leaves the skill in place and keeps it out of the returned `mutated_names`, so callers don't record a restore that never happened. Two regression tests, one per exception arm: a non-UTF-8 core template, and a mocked `PermissionError` so the `OSError` half is also covered under privileged CI where permission bits aren't enforced. Both assert the skill survives untouched and is not reported as mutated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(presets): warn when a skill keeps preset content after a failed restore Review follow-up on two points. Surface the skipped restore. Skipping is still the correct recovery — the alternative branch deletes the skill — but it was silent, and it is a partial removal: `remove()` goes on to delete the preset directory and the registry entry, while this `SKILL.md` keeps the removed preset's content, and leaving the name out of `mutated_names` also keeps it out of reconciliation, so nothing retries it. Both arms now emit a warning naming the skill, the unreadable source, and the exception, and pointing at the re-run that refreshes it once the file is fixed. `warnings.warn` matches how the surrounding code reports non-fatal degradation (the reconciliation failures in `remove()`/`install_from_directory`, the unreadable core template in `_substitute_core_template` from #3961). Cover the extension arm. A skill backed by an installed extension never reaches the core-template read, so the two branches can regress independently and both prior tests exercised only the core one. `test_unregister_skills_in_dir_unreadable_extension_source_skips` installs an extension whose command file is non-UTF-8 and asserts the skill survives byte-for-byte and is absent from `mutated_names`. Verified it raises the raw `UnicodeDecodeError` against unpatched source. The two existing tests now assert the warning via `pytest.warns` so dropping it fails the suite. pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are the pre-existing Windows symlink tests that need elevation, unchanged from main. ruff check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f0ee1fc419
commit
1a44a6aa08
@@ -3268,6 +3268,30 @@ class PresetManager:
|
||||
if source in owned_sources:
|
||||
shutil.rmtree(skill_subdir)
|
||||
|
||||
@staticmethod
|
||||
def _warn_unrestored_skill(
|
||||
skill_name: str, source_file: Path, exc: BaseException
|
||||
) -> None:
|
||||
"""Warn that a skill kept preset content because its restore source is unreadable.
|
||||
|
||||
Skipping the restore is the safe recovery — the alternative branch
|
||||
deletes the skill outright — but it is still a partial removal: the
|
||||
preset directory and registry entry go away while this ``SKILL.md``
|
||||
keeps the removed preset's content, and reconciliation never revisits
|
||||
it because the name is left out of ``mutated_names``. Name the skill
|
||||
and the source so the condition is actionable instead of silent.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Skill '{skill_name}' still contains the removed preset's content: "
|
||||
f"its restore source '{source_file}' could not be read "
|
||||
f"({exc.__class__.__name__}: {exc}). The skill was left in place "
|
||||
f"rather than deleted. Fix or remove that file and re-run "
|
||||
f"'specify preset add'/'specify preset remove' to refresh it.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def _unregister_skills_in_dir(
|
||||
self,
|
||||
skill_names: List[str],
|
||||
@@ -3394,8 +3418,20 @@ class PresetManager:
|
||||
core_file = None
|
||||
|
||||
if core_file:
|
||||
# Restore from core template
|
||||
content = core_file.read_text(encoding="utf-8")
|
||||
# Restore from core template. An unreadable/undecodable
|
||||
# source cannot produce restored content, so leave the
|
||||
# existing skill untouched rather than leaking a raw
|
||||
# OSError/UnicodeDecodeError out of `preset remove` — and
|
||||
# rather than falling through to the rmtree below, which
|
||||
# would delete a skill precisely when its replacement
|
||||
# cannot be generated. Matches the `continue` guards above
|
||||
# (unsafe name, missing subdir, foreign owner), which also
|
||||
# skip without recording the name as mutated.
|
||||
try:
|
||||
content = core_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
self._warn_unrestored_skill(skill_name, core_file, exc)
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
@@ -3436,7 +3472,16 @@ class PresetManager:
|
||||
continue
|
||||
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
# Same boundary as the core-template branch above: an
|
||||
# unreadable extension source leaves the skill in place
|
||||
# instead of crashing or being deleted.
|
||||
try:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
self._warn_unrestored_skill(
|
||||
skill_name, extension_restore["source_file"], exc
|
||||
)
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
# Mirror the register-time rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/,
|
||||
|
||||
Reference in New Issue
Block a user