Skip to content

ADR-015: unapply reuses Plan's classification and only ever removes safely reversible state

Status: accepted

Context

Issue #64 asks for over unapply: a reverse reconciliation from an overlay's desired state and actual filesystem/git state, explicitly without relying on an authoritative serialized ApplyState — "Respect the desired-vs-actual model rather than introducing a separate imperative removal mechanism." ADR-011 already anticipated this: Plan/DesiredTree were built as the shared vocabulary status/diff/unapply (#12/#109/#64) would each build on, and ADR-013 established the precedent that each of these commands defines its own result taxonomy rather than forcing a shared one.

Without a persisted apply-time record, unapply has no way to know whether it created a given directory or whether it already existed (e.g. the user's home directory, when target = "~"), nor whether a git checkout has commits or edits that only exist locally. The issue is explicit that this must never be guessed: "never implicitly discard uncommitted changes or local commits", and destructive transitions must be "explicit and previewable".

Decision

Scope: the named overlay's own entries only, no uses recursion. DesiredTree::build_own is used, mirroring Overlay::apply_inner's per-node scope. Unlike apply, unapply does not recurse into uses: removing a shared dependency's entries here could break another, still-applied overlay that also uses it (a diamond dependency). A used overlay is unapplied by naming it explicitly.

Classification reuses Plan::build's existing Operation, unmodified. For Directory/SymlinkFile/SymlinkDirectory intents, Operation::Noop already means exactly "the target still matches what this overlay would materialize here" (including a dangling soft symlink whose source has since vanished — still our link, just broken) and Operation::Conflict already means "something else occupies the target". crate::unapply::Outcome maps these directly: Noop → Removed, Conflict → NotOwned, Create → AlreadyAbsent. No new read pass over the filesystem, and no changes to Plan/PlanStep/Operation were needed — confirming ADR-011's original promise. Outcome is its own enum, not a reuse of status::Status or diff::Change, per ADR-013's "own taxonomy" precedent.

Plan's classification is deliberately too coarse for Checkout entries (CheckoutMaterializer::classify collapses every non-Missing git state to Operation::Noop by design, ADR-014, to avoid routing git state through filesystem conflict-resolution UI). unapply calls status::git::inspect directly instead — exactly like status/diff already do — and only removes a checkout when it reports Status::Applied: fully in sync with its upstream, nothing uncommitted, nothing unpushed. Every other status (Modified/Ahead/Behind/Diverged/Conflict/Broken) is left untouched, unconditionally — there is no --force override, because there is nothing here that should ever be forced. This directly reuses #110's own safety primitive rather than re-deriving a notion of "clean enough to delete".

No persisted apply-state is needed for directories either. A directory is only ever removed with a non-recursive fs::remove_dir (unapply::remove_dir_if_empty), never remove_dir_all, ignoring NotFound/DirectoryNotEmpty as success rather than error. Combined with bottom-up removal order (DesiredTree sorts entries ascending by target — a directory's entry always precedes anything nested under it, so Report::execute walks .rev() to remove children before parents), this means a directory disappears exactly when, and only when, it turns out to hold nothing but what this overlay put there. A user's home directory (target = "~") or any directory holding unrelated content is never at risk: it is simply never empty, so removal is always silently skipped — no ownership tracking, no special-casing the root target, required.

No new interactive confirmation gate. Because every removal is already gated to exactly-matching, safe-to-lose state, every removal is also reversible: symlinks/directories come back with a plain over apply (overlay source content is never touched by unapply), and a checkout is only ever removed when there was nothing local-only left to lose. --dry-run is therefore sufficient preview, mirroring apply's own UX — no --force/ --no-prompt equivalents exist, since there is nothing left to force through once non-owned/dirty entries are always skipped.

Kept out of the Materializer trait. crate::unapply calls status::git::inspect/actions::fs/the symlink crate directly for its own removal primitives, the same way status/diff/sync already bypass Materializer::materialize for their own concerns (git status inspection, content diffing, fetch/merge/push) rather than growing that trait with methods only one caller needs.

Consequences

Plan/PlanStep/Operation/ActualState/Materializer are all unchanged — unapply is a new top-level module (crate::unapply, plus crate::cli::unapply) built entirely on existing read-only primitives. Nothing here implements #113's rule-aware migrations (symlink ↔ checkout, file-level ↔ directory-level symlink transitions): that remains a Plan/Operation extension for a later issue, untouched by this one. uses-aware/cascading unapply (removing a whole dependency graph safely, accounting for shared use across overlays) is also left for a future issue — the current scope is deliberately the single, explicitly named overlay.

The main cost is conservatism: a directory containing even one unrelated file, or a checkout with a single uncommitted line, is left exactly as-is rather than cleaned up further. This is intentional — the alternative requires either persisted apply-time provenance (the exact "authoritative ApplyState" the issue asks to avoid) or destructive assumptions the issue explicitly forbids.