Skip to content

fix(presets): skip an unreadable restore source in preset remove - #4020

Merged
mnriem merged 2 commits into
github:mainfrom
Noor-ul-ain001:fix/preset-remove-unreadable-restore-source
Aug 10, 2026
Merged

fix(presets): skip an unreadable restore source in preset remove#4020
mnriem merged 2 commits into
github:mainfrom
Noor-ul-ain001:fix/preset-remove-unreadable-restore-source

Conversation

@Noor-ul-ain001

@Noor-ul-ain001 Noor-ul-ain001 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Problem

PresetManager._unregister_skills_in_dir restores each preset-owned SKILL.md from either a core command template or an extension source. Both reads were bare:

if core_file:
    # Restore from core template
    content = core_file.read_text(encoding="utf-8")   # src/specify_cli/presets/__init__.py:3397
...
if extension_restore:
    content = extension_restore["source_file"].read_text(encoding="utf-8")   # :3438

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 raw UnicodeDecodeError/OSError. PresetManager.remove() has no handler for it, and neither does the preset remove CLI command, so specify preset remove <id> dies with a traceback.

Reproduced against main:

File ".../src/specify_cli/presets/__init__.py", line 3397, in _unregister_skills_in_dir
    content = core_file.read_text(encoding="utf-8")
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 17: invalid start byte

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_provenanceexcept (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 #3961

So 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.

continue is deliberately the recovery, not falling through: the else branch 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 returned mutated_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.md keeps the removed preset's content, and its absence from mutated_names also keeps it out of reconciliation — both arms report it through a shared _warn_unrestored_skill helper:

Skill 'speckit-specify' still contains the removed preset's content: its
restore source '<path>' could not be read (UnicodeDecodeError: ...). The
skill was left in place rather than deleted. Fix or remove that file and
re-run 'specify preset add'/'specify preset remove' to refresh it.

warnings.warn matches how the surrounding code reports non-fatal degradation that shouldn't roll back a persisted change — the reconciliation failures in remove()/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 template
  • test_unregister_skills_in_dir_unreadable_core_template_oserror_skips — mocked PermissionError, so the OSError half is covered under privileged CI where permission bits aren't enforced
  • test_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 independently

All 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.py583 passed, 2 skipped, 7 failed
  • The 7 failures are all pre-existing Windows symlink tests requiring elevation (test_symlinked_*, test_dangling_symlink_fails_closed). Confirmed identical on a clean checkout of main with my changes stashed — unrelated to this PR.
  • ruff check → All checks passed

🤖 Generated with Claude Code

`_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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/specify_cli/presets/__init__.py Outdated
Comment thread src/specify_cli/presets/__init__.py

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review details

Suppressed comments (3)

src/specify_cli/presets/init.py:3433

  • When a lower-priority preset also provides this command, this continue prevents 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_dirs solely from returned mutated_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 rerun preset remove is 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
mnriem self-requested a review August 10, 2026 17:25
@mnriem
mnriem merged commit 1a44a6a into github:main Aug 10, 2026
14 checks passed
@mnriem

mnriem commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants