fix(presets): skip an unreadable restore source in preset remove - #4020
Merged
mnriem merged 2 commits intoAug 10, 2026
Merged
Conversation
`_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 github#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>
Contributor
There was a problem hiding this comment.
Pull request overview
Prevents unreadable preset restore sources from crashing preset remove.
Changes:
- Skips unreadable core and extension restore sources.
- Adds regression coverage for core-template decode and permission failures.
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/presets/__init__.py |
Guards restore-source reads. |
tests/test_presets.py |
Tests unreadable core templates. |
Review details
Tip
Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (1)
src/specify_cli/presets/init.py:3455
- This branch has the same silent partial-removal problem as the core-template branch: the stale preset-owned skill survives, while the preset directory and registry record are subsequently removed and this name is excluded from reconciliation. Surface the retained stale skill to the user or preserve state that allows the removal to be retried.
except (OSError, UnicodeDecodeError):
continue
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
mnriem
requested changes
Aug 10, 2026
mnriem
left a comment
Collaborator
There was a problem hiding this comment.
Please address Copilot feedback
…estore 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 github#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>
Contributor
There was a problem hiding this comment.
Review details
Suppressed comments (3)
src/specify_cli/presets/init.py:3433
- When a lower-priority preset also provides this command, this
continueprevents that surviving winner from ever being applied.remove()only passes names returned by_unregister_skills_in_dir()into_reconcile_skills(); because this safely-owned but unrestored name is omitted, the removed preset's content remains even though reconciliation could overwrite it from the readable surviving preset. Keep the file untouched here, but carry the name separately as eligible for post-removal reconciliation rather than dropping it from the caller's affected set.
except (OSError, UnicodeDecodeError) as exc:
self._warn_unrestored_skill(skill_name, core_file, exc)
continue
src/specify_cli/presets/init.py:3483
- This skip also suppresses restoration of a surviving lower-priority preset. Removal builds
affected_skill_dirssolely from returnedmutated_names, so omitting this safely-owned skill means post-removal reconciliation never gets a chance to overwrite it with the next preset winner; stale content from the removed preset remains even though that replacement is readable. Preserve the file on this read failure, but separately retain the name for reconciliation.
except (OSError, UnicodeDecodeError) as exc:
self._warn_unrestored_skill(
skill_name, extension_restore["source_file"], exc
)
continue
src/specify_cli/presets/init.py:3290
- This warning is also emitted when
_reconcile_skills()calls_unregister_skills_in_dir()during post-install reconciliation, and during stale-artifact cleanup while the preset remains registered. In those paths, saying the preset was removed and directing the user to rerunpreset removeis inaccurate. Make the helper context-neutral, or pass the operation/removal state so the recovery instruction matches the caller.
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.",
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
mnriem
self-requested a review
August 10, 2026 17:25
mnriem
approved these changes
Aug 10, 2026
Collaborator
|
Thank you! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
PresetManager._unregister_skills_in_dirrestores each preset-ownedSKILL.mdfrom either a core command template or an extension source. Both reads were bare:A file that exists but cannot be read or decoded — a project-owned override in
.specify/templates/commands/saved as UTF-16/Latin-1, or one with restrictive permissions — therefore raises a rawUnicodeDecodeError/OSError.PresetManager.remove()has no handler for it, and neither does thepreset removeCLI command, sospecify preset remove <id>dies with a traceback.Reproduced against
main:Why this is a gap rather than a design choice
Every other failure in this same loop degrades with
continue— an unsafe registry name, a missing skill subdirectory, a foreign owner. And the sibling reads of the very same files are already guarded:_infer_legacy_skill_provenance—except (OSError, UnicodeDecodeError): continue(:2917)_delete_agent_preset_skills— same clause (:3253)_unregister_skills_in_dir's own ownership check a few lines above the bug — same clause (:3345)_substitute_core_template, which reads the identical.specify/templates/commands/directory, received this boundary in fix(presets): treat an unreadable core template as missing #3961So within one function, the ownership read is guarded and the restore read is not. The two restore reads were the remaining gap in an otherwise-complete boundary.
Fix
Wrap both reads, warn, and
continue.continueis deliberately the recovery, not falling through: theelsebranch below removes the skill outright (shutil.rmtree), so treating an unreadable source as "no source available" 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 returnedmutated_names, so callers don't persist a restore that never happened.Because skipping is nonetheless a partial removal — the preset directory and registry entry are removed while this
SKILL.mdkeeps the removed preset's content, and its absence frommutated_namesalso keeps it out of reconciliation — both arms report it through a shared_warn_unrestored_skillhelper:warnings.warnmatches how the surrounding code reports non-fatal degradation that shouldn't roll back a persisted change — the reconciliation failures inremove()/install_from_directory, and the unreadable core template in_substitute_core_template(#3961).Tests
Three regression tests, one per exception arm plus one per restore branch:
test_unregister_skills_in_dir_unreadable_core_template_skips— non-UTF-8 core templatetest_unregister_skills_in_dir_unreadable_core_template_oserror_skips— mockedPermissionError, so theOSErrorhalf is covered under privileged CI where permission bits aren't enforcedtest_unregister_skills_in_dir_unreadable_extension_source_skips— non-UTF-8 extension command file; the extension branch is unreachable from the core-template tests (a skill backed by an installed extension never reaches that read), so it can regress independentlyAll three assert the skill survives byte-for-byte, is not reported as mutated, and that the warning fires (
pytest.warns, so dropping it fails the suite).Verified each fails without the source change — the extension case with the raw
UnicodeDecodeError, i.e. reproducing the crash rather than only the missing warning.Verification
pytest tests/test_presets.py→ 583 passed, 2 skipped, 7 failedtest_symlinked_*,test_dangling_symlink_fails_closed). Confirmed identical on a clean checkout ofmainwith my changes stashed — unrelated to this PR.ruff check→ All checks passed🤖 Generated with Claude Code