You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Fix: dir-Overwrite must NEVER wholesale-replace, even on recursive-delete backends
The previous Overwrite logic relied on Volume::delete failing benignly when
called on a non-empty directory — a contract honored by every current backend
(LocalPosix, SMB, MTP, InMemory) but enforced only by the trait doc. A future
backend with recursive delete semantics, or a refactor that consolidates
delete + delete_recursive, would silently flip dir-Overwrite from "merge with
overwrite-on-file-conflict" (Cmdr's UX promise) to "wholesale replace,
deleting files unique to dest". Data-loss footgun.
Fix: stat the dest first, skip the delete entirely for directories. The
recursive copy then merges into the existing tree by construction — same-named
files inside get overwritten by the streaming writers, files unique to dest
are preserved. The merge guarantee is now architectural, not emergent.
The test `dir_overwrite_must_merge_not_replace_even_with_recursive_delete`
proves this with a `RecursiveDeleteVolume` wrapper around `InMemoryVolume`
that intentionally violates the trait's "file or empty directory" contract.
Without the fix the test fails (the wrapper deletes the dest tree before the
copy runs); with the fix it passes (delete is skipped because is_directory
returns true). The companion `file_overwrite_still_deletes_the_existing_file`
test pins the file branch — Overwrite still genuinely replaces files.
Also tightened `Volume::delete` doc to spell out the strict "single node
only" contract and what relies on it. Future backend authors landing here
to wire up a new platform should now get a louder signal that recursive
behavior is forbidden — not just unusual.
CLAUDE.md note in `write_operations/` updated to reflect the new
"enforced architecturally, not by trait contract" rationale.
Copy file name to clipboardExpand all lines: apps/desktop/src-tauri/src/file_system/write_operations/CLAUDE.md
+3-1Lines changed: 3 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -110,7 +110,9 @@ actual `copy_files_start` can consume the cache via `preview_id` in `WriteOperat
110
110
**Move strategy.** Same filesystem detected via device ID comparison (`MetadataExt::dev`). Cross-filesystem move uses a
111
111
`.cmdr-staging-<uuid>` dir at the destination root, then atomic `rename` into place, then source deletion.
112
112
113
-
**Dir-vs-dir conflicts route through `resolve_volume_conflict` like every other shape.** The `volume_move` and `volume_copy` loops used to short-circuit dir-into-dir as a silent merge, bypassing the user's `conflict_resolution`. That made the merge invisible — even when the user picked "Stop" (= ask), nothing prompted. Now every conflict (file-vs-file, dir-vs-dir, file-vs-dir, dir-vs-file) goes through the resolver. For Overwrite specifically, `apply_volume_conflict_resolution` calls `Volume::delete(dest)` which fails benignly on non-empty dirs (`Volume::delete` is contractually for files or empty dirs); the recursive copy then merges into the existing tree (same-named files overwritten, others kept). Net effect for the user: dir-vs-dir Overwrite is "merge with overwrite-on-file-conflict", not "wholesale replace". That's intentional — wholesale replace would risk data loss of files outside the source tree. See the comment in `volume_conflict.rs::apply_volume_conflict_resolution` for the per-shape walkthrough.
113
+
**Dir-vs-dir conflicts route through `resolve_volume_conflict` like every other shape.** The `volume_move` and `volume_copy` loops used to short-circuit dir-into-dir as a silent merge, bypassing the user's `conflict_resolution`. That made the merge invisible — even when the user picked "Stop" (= ask), nothing prompted. Now every conflict (file-vs-file, dir-vs-dir, file-vs-dir, dir-vs-file) goes through the resolver.
114
+
115
+
**Overwrite means merge for dirs, replace for files — enforced architecturally, not by trait contract.**`apply_volume_conflict_resolution` stats the dest first; for files it deletes (so the streaming writer lands a fresh copy), for directories it skips the delete entirely (the recursive copy merges into the existing tree). This is enforced at the call site rather than relying on `Volume::delete`'s "file or empty directory only" contract — a future backend with recursive delete semantics, or a refactor that consolidates `delete` + `delete_recursive`, would otherwise silently flip the UX from merge to wholesale replace and delete files unique to dest. The `dir_overwrite_must_merge_not_replace_even_with_recursive_delete` test in `volume_conflict.rs` pins this with a wrapper Volume that violates the trait contract.
114
116
115
117
**Cross-volume move source-delete is recursive.**`move_between_volumes` in `volume_move.rs` deletes the source via
116
118
`delete_volume_path_recursive` (re-exported from `volume_copy.rs` for this purpose) when the source is a directory.
0 commit comments