Skip to content

Dangling owned symlinks survive uninstall #834

Description

@williamthorsen

Problem

uninstall leaves owned symlinks on disk when their target source is already gone. It classifies each manifest entry with detectDrift, which gates on existsSync — a call that follows the symlink to its now-missing target and returns false. The entry is treated as missing ("already gone"), removeItem is never called, and the dangling link physically remains.

Context

The owned-entry removal rule — a symlink is removed outright; an unmodified or force-removed file is removed; a user-modified file is retained without --force — is currently implemented three times: correctly in pruneOrphanedEntries (install side, from #828) and twice in uninstall (uninstallCommand and uninstallSharedGuidance), neither of which received #828's link-aware branch. The duplication is the root cause: #828 fixed one copy and the uninstall copies silently drifted. removeItem is already link-aware (removes a dangling link, no-ops when absent).

Proposed solution

Extract the rule into a single policy and route all three consumers through it — one source of truth, not three hand-maintained copies (the duplication is what produced this bug).

  • Add classifyOwnedEntry(entry, home, force): 'remove' | 'retain' | 'absent'. A linked entry returns remove without consulting detectDrift — this is the fix, since drift detection mis-reports a dangling link as missing. An unmodified or force-removed file returns remove; a user-modified file without --force returns retain; an entry already gone from disk returns absent.
  • Rename orphan-pruner.tsentry-remover.ts, reflecting that it now owns entry-removal policy, not just orphan pruning. pruneOrphanedEntries remains there as one consumer.
  • Both uninstall loops and pruneOrphanedEntries delegate their per-entry decision to classifyOwnedEntry; each keeps its own disk I/O, logging, dry-run handling, and manifest bookkeeping (the policy is shared, the I/O is not).

The 'absent' verdict preserves each consumer's current handling of genuinely-missing entries, so pruneOrphanedEntries keeps its existing behavior.

Acceptance criteria

Must have

  • uninstall removes owned symlinks whose target source no longer exists.
  • An owned symlink is removed regardless of whether its target content changed (a symlink is never treated as a user-modified file).
  • A genuinely absent entry is still treated as already removed (no error).
  • classifyOwnedEntry is the single source of the removal rule; both uninstall loops and pruneOrphanedEntries consume it.
  • pruneOrphanedEntries retains its existing behavior.
  • orphan-pruner.ts is renamed to entry-remover.ts with all references updated.
  • New and modified behavior is covered by tests.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions