From 376e6500ab92b6faf919a835973195a594c36495 Mon Sep 17 00:00:00 2001 From: TapTap Date: Tue, 15 Sep 2026 23:23:52 +0200 Subject: [PATCH] fix(delete): count recursive missing-arg removals per entry (#290) A non-empty --delete-missing-args directory removed under --force/--delete now has its contents deleted entry-by-entry through the budgeted walker, so every deleted file/dir counts toward --max-delete exactly like rsync (a capped run leaves the remaining entries and exits 25). --- src/shared/file_receive.c | 29 +++++++++++++++++++++++++++-- tests/integration/test_features.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/shared/file_receive.c b/src/shared/file_receive.c index 261de33..94ef0c0 100644 --- a/src/shared/file_receive.c +++ b/src/shared/file_receive.c @@ -3099,10 +3099,35 @@ static bool delete_missing_args_budgeted(const Config* config, DeleteManifest* m free(leaf); leaf = NULL; if (config->use_delete || config->force_delete) { - if (!file_remove_tree_secure(full)) + /* Remove the contents entry-by-entry through the budgeted extras + walker so every deleted file/dir counts toward --max-delete (rsync + parity); the now-empty directory itself costs one more. A run that + hits the cap leaves the remaining entries in place. */ + ArrayList* no_keeps = array_list_create(free); + size_t remaining = budget->max_delete - budget->deleted; + size_t contents_deleted = 0; + size_t contents_skipped = 0; + DeleteWalkResult walk = + no_keeps ? delete_extras_limited(full, no_keeps, NULL, remaining, NULL, 0, + &contents_deleted, &contents_skipped) + : DELETE_WALK_ERROR; + if (no_keeps) + array_list_delete(no_keeps); + budget->deleted += contents_deleted; + budget->skipped += contents_skipped; + if (walk == DELETE_WALK_LIMIT_REACHED) { + budget->limit_hit = true; + } else if (walk != DELETE_WALK_OK) { ok = false; - else + } else if (budget->deleted >= budget->max_delete) { + budget->limit_hit = true; + budget->skipped++; + } else if (file_remove_tree_secure(full)) { + budget->deleted++; removed = true; + } else { + ok = false; + } } else { char* escaped = output_escape(rel, log_get_8_bit_output()); log_message(LOG_LEVEL_WARNING, diff --git a/tests/integration/test_features.py b/tests/integration/test_features.py index bcae8a9..a7d0045 100644 --- a/tests/integration/test_features.py +++ b/tests/integration/test_features.py @@ -2853,6 +2853,36 @@ class TestMissingArgs: f"missing-args deletions ignored the --max-delete budget: {remaining}" assert os.path.isfile(os.path.join(received, "a.txt")) + @pytest.mark.parametrize("mt", [False, True]) + def test_delete_missing_nonempty_dir_counts_each_entry_against_budget(self, mt): + """A non-empty missing-arg directory with --force/--delete is removed + entry-by-entry, each counting toward --max-delete (rsync parity): with a + small cap the run stops after N files and leaves the rest in place.""" + source = self._make_source("mg_dirbudget_src") + dest = os.path.join(TEST_DATA_DIR, "mg_dirbudget_dst") + clean_dir(dest) + with ServerManager() as server: + server.start(extra_args=["--allow-delete"]) + result, _ = run_client(source, dest, port=server.port) + assert result.returncode == 0, f"seed failed: {result.stderr[:200]}" + received = get_dest_received_dir(dest, source) + gone = os.path.join(received, "gone") + os.makedirs(gone) + for i in range(4): + with open(os.path.join(gone, f"f{i}"), "w") as fh: + fh.write("stale") + lst = _write_rel_list(b"a.txt\ngone\n") + flags = ["--files-from", lst, "--delete-missing-args", "--force", + "--max-delete=2"] + (["--threads"] if mt else []) + result, _ = run_client(source, dest, flags=flags, port=server.port) + assert result.returncode == 25, \ + f"non-empty missing-arg dir should cap at 2 and exit 25: {result.stderr[:300]}" + assert os.path.isdir(gone), \ + "the non-empty missing-arg directory should survive a capped run" + remaining = len(os.listdir(gone)) + assert remaining == 2, f"expected 2 entries left, found {remaining}" + assert os.path.isfile(os.path.join(received, "a.txt")) + @pytest.mark.parametrize("mt", [False, True]) def test_delete_missing_args_not_blocked_by_exclude_protection(self, mt): """A missing-arg mirror that sits under a filter-excluded directory is an