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.ts → entry-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
Problem
uninstallleaves owned symlinks on disk when their target source is already gone. It classifies each manifest entry withdetectDrift, which gates onexistsSync— a call that follows the symlink to its now-missing target and returnsfalse. The entry is treated asmissing("already gone"),removeItemis 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 inpruneOrphanedEntries(install side, from #828) and twice inuninstall(uninstallCommandanduninstallSharedGuidance), 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.removeItemis 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).
classifyOwnedEntry(entry, home, force): 'remove' | 'retain' | 'absent'. Alinkedentry returnsremovewithout consultingdetectDrift— this is the fix, since drift detection mis-reports a dangling link asmissing. An unmodified or force-removed file returnsremove; a user-modified file without--forcereturnsretain; an entry already gone from disk returnsabsent.orphan-pruner.ts→entry-remover.ts, reflecting that it now owns entry-removal policy, not just orphan pruning.pruneOrphanedEntriesremains there as one consumer.uninstallloops andpruneOrphanedEntriesdelegate their per-entry decision toclassifyOwnedEntry; 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, sopruneOrphanedEntrieskeeps its existing behavior.Acceptance criteria
Must have
uninstallremoves owned symlinks whose target source no longer exists.classifyOwnedEntryis the single source of the removal rule; bothuninstallloops andpruneOrphanedEntriesconsume it.pruneOrphanedEntriesretains its existing behavior.orphan-pruner.tsis renamed toentry-remover.tswith all references updated.