fix(delete): scope -R per-directory delete walk to the transferred prefix

The -R prefix marker installed in synced_dirs was discarded when finalizing
the per-directory delete sender (--delete-during/--delete-delay), so the
up-front root plan was the receive root '.', whose keep list only held the
first prefix component.  The receiver then deleted destination content
outside the transferred prefix (e.g. unrelated/keep.txt), a data-loss bug;
rsync keeps it.

Confine the walk to the -R prefix: send that prefix's plan as the root plan,
only transmit plans at or below it, and never emit the receive root plan for
a scoped run.  Add a differential test covering both --delete-during and
--delete-delay.
This commit is contained in:
2026-09-17 01:00:55 +02:00
parent 125921c11b
commit 946aa934cc
4 changed files with 139 additions and 10 deletions
+22 -3
View File
@@ -454,6 +454,21 @@ static char* delete_scope_root_marker(const Config* config) {
return str_dup(".");
}
/* The -R destination prefix that confines a per-directory delete walk, or NULL
* when the whole receive root is in scope. The marker was installed into
* `synced_dirs` by delete_scope_root_marker(); for a plain recursive transfer
* it is "." (whole root) and for --files-from the list is not a single prefix. */
static const char* delete_plan_walk_root(const Config* config, const ArrayList* synced_dirs) {
if (!config || config->files_from_set != NULL || !config->relative || !config->send_directory)
return NULL;
if (!synced_dirs || synced_dirs->size != 1)
return NULL;
const char* marker = (const char*)synced_dirs->items[0];
if (marker[0] == '\0' || strcmp(marker, ".") == 0)
return NULL;
return marker;
}
/* True when some --files-from entry is an ancestor-or-equal directory of
* `rel` (an empty entry -- the whole tree "." -- counts as the root). */
static bool file_list_ancestor_listed(const FileListSet* set, const char* rel) {
@@ -2898,7 +2913,9 @@ int send_files(Config* config) {
bool prescan_ok = scan_paths_only(config, &prepared.options, NULL, plan_sender, &had_scan_io);
bool plans_ok = false;
if (prescan_ok) {
delete_plan_sender_finalize(plan_sender, config->files_from_set ? synced_dirs : NULL);
const char* walk_root = delete_plan_walk_root(config, synced_dirs);
const ArrayList* scope = config->files_from_set ? synced_dirs : (walk_root ? synced_dirs : NULL);
delete_plan_sender_finalize(plan_sender, scope, walk_root);
delete_plan_sender_set_config(plan_sender, excluded, size_skipped, missing_args);
if (had_scan_io && delete_plan_sender_empty(plan_sender)) {
log_message(LOG_LEVEL_ERROR,
@@ -3261,8 +3278,10 @@ int send_files_multithreaded(Config** config_ptr) {
context->delete_plans, &context->scan_had_io_error);
prepared_scanner_destroy(&prepared);
if (per_dir && prebuilt) {
delete_plan_sender_finalize(context->delete_plans,
config->files_from_set ? context->synced_dirs : NULL);
const char* walk_root = delete_plan_walk_root(config, context->synced_dirs);
const ArrayList* scope =
config->files_from_set ? context->synced_dirs : (walk_root ? context->synced_dirs : NULL);
delete_plan_sender_finalize(context->delete_plans, scope, walk_root);
delete_plan_sender_set_config(context->delete_plans, context->excluded_paths,
context->size_skipped_paths, context->missing_args);
}