Skip to content

fix(integrations): don't abort uninstall when the manifest file can't be deleted - #3805

Open
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix-uninstall-manifest-unlink
Open

fix(integrations): don't abort uninstall when the manifest file can't be deleted#3805
jawwad-ali wants to merge 1 commit into
github:mainfrom
jawwad-ali:fix-uninstall-manifest-unlink

Conversation

@jawwad-ali

Copy link
Copy Markdown
Contributor

Problem

IntegrationManifest.uninstall() (src/specify_cli/integrations/manifest.py) guards every tracked-file delete:

try:
    path.unlink()
except OSError:
    skipped.append(path)
    continue

…but the manifest's own delete is bare:

if remove_manifest and manifest.exists():
    manifest.unlink()          # <-- unguarded

The manifest is deleted last, after every tracked file is already gone. So an undeletable manifest — a read-only file, a directory left at the path, a Windows lock, an ACL — raises out of uninstall() when the destructive work has already happened.

Why this matters more than a lost return value

The caller doesn't just lose (removed, skipped). Everything after the call is skipped (integrations/_install_commands.py:282):

  • reassigning the default integration when the removed one was default
  • _write_integration_json(...) / _remove_integration_json(...)
  • _clear_init_options_for_integration(...)

So the files are gone but integration.json still records the integration as installed — and possibly still as the default. The project is left in a state the CLI can't reach again through uninstall.

Reproduction on current main (2e44ed6)

Leaving a directory at the manifest path (portable, no chmod, no monkeypatch):

manifest saved: True | tracked file: True
manifest path is now a directory: True
uninstall() RAISED PermissionError: [WinError 5] Access is denied: ...test.manifest.json
  tracked file deleted anyway? True   <-- work done, but caller lost (removed, skipped)

After the fix, same harness:

uninstall() -> removed=1 skipped=1  (no crash)

Fix

+13/-1 in one source file — guard it like its sibling:

try:
    manifest.unlink()
except OSError:
    skipped.append(manifest)

The empty-parent cleanup below is deliberately left unconditional rather than moved into an else:. With the manifest still on disk its parent is non-empty, so the first rmdir() raises and breaks immediately — an else: would be behaviourally identical while re-indenting 8 lines for no reason. I mention it so the omission reads as a decision, not an oversight.

Precedent

Anticipating one review question

The CLI renders skipped under a header along the lines of "N modified file(s) were preserved", and an undeletable manifest isn't "modified". I still think skipped is right: the docstring commits only to `(removed, skipped)` — absolute paths, and kimi already puts undeletable directories there. The alternative — a third return value — would be a signature change to a public method for a rare I/O error. Happy to adjust the CLI wording in a follow-up if you'd rather the header were broader.

Verification

  • Fail-before / pass-after: the new test raises PermissionError: [WinError 5] on unpatched src and passes with the fix.
  • tests/integrations/test_manifest.py48 passed, 6 failed; the same 6 fail on clean main (2e44ed6) with 47 passed — all six are Windows symlink-privilege tests (test_rejects_symlink_target, test_symlink_removed_with_force, …), unrelated to this change. Delta is exactly my one new passing test.
  • tests/integrations/ → 2253 passed, 19 skipped; failures identical to baseline.
  • uvx ruff@0.15.0 check → clean

The new test needs no chmod and no monkeypatch: Path.unlink() on a directory raises IsADirectoryError on Linux and PermissionError on Windows/macOS — both OSError subclasses, so it exercises the guard on every CI platform.


Written with assistance from Claude Code. Bug found, reproduced, and verified by me on current main; every number above is from output I ran.

…eleted

`IntegrationManifest.uninstall()` guards every tracked-file `path.unlink()`
with `except OSError: skipped.append(path)`, but the manifest's own
`manifest.unlink()` is bare. The manifest is deleted *last*, so an
undeletable manifest (read-only file, a directory left at the path, a
Windows lock) raises after the tracked files are already gone.

The caller loses the `(removed, skipped)` result and never runs its
post-uninstall bookkeeping — reassigning the default integration,
rewriting/removing `integration.json`, clearing init options — leaving a
removed integration still recorded as installed.

Report it in `skipped` like any other file we could not remove, mirroring
the `path.unlink()` guard above and the same `except OSError:
skipped.append(...)` pattern in kimi's legacy-directory cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jawwad-ali
jawwad-ali requested a review from mnriem as a code owner July 28, 2026 16:01
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.

1 participant