fix(fs): recreate empty dirs, replace blocking file; -R --no-implied-dirs implied parents

- Recursive scans emit a directory entry for every traversed directory that
  produced no transferred child, so empty source dirs (and dirs emptied by
  filtering) are recreated like rsync; -m prunes them, --files-from/--list-only
  never emit implicit dirs.
- A directory entry now replaces a destination regular file (rsync removes the
  non-directory) instead of aborting; confined to the secure parent fd.
- -R --no-implied-dirs --files-from: stop refusing a listed file whose parent
  is not listed; create the implied parent with default attributes (no source
  metadata is captured for it), matching rsync 3.4.1.
- Differential gate: drop min_size/empty_dirs_recursive/dirs_plain allowlist
  entries (dirs_plain now hands FastSync the same trailing-slash source as
  rsync); add differential test for the files-from implied parent.
- Docs: -d row -> Parity, tally 108/24/24.

No wire change (PROTOCOL_VERSION stays 2.26.0).
This commit is contained in:
2026-09-18 20:25:42 +02:00
parent d162d93570
commit 13708352ec
12 changed files with 191 additions and 146 deletions
+6 -2
View File
@@ -89,6 +89,9 @@ class Case:
ignore_paths: Tuple[str, ...] = ()
extra_check: Optional[Callable] = None
files_from: Optional[Tuple[str, ...]] = None
# rsync receives ``src + "/"``; FastSync mirrors the path it is given, so a
# trailing-slash-sensitive case must hand FastSync the same form.
fs_src_suffix: str = ""
ci: bool = False
ref: str = ""
@@ -418,6 +421,7 @@ def run_differential( # noqa: PLR0913 (explicit scenario parameters)
ignore_paths: Tuple[str, ...] = (),
extra_check: Optional[Callable] = None,
files_from: Optional[Tuple[str, ...]] = None,
fs_src_suffix: str = "",
) -> Dict[str, object]:
"""Run one rsync/FastSync pair and return the diff aspects.
@@ -447,7 +451,7 @@ def run_differential( # noqa: PLR0913 (explicit scenario parameters)
fs_flags.append(f"--files-from={list_path}")
rs = run_rsync(src, rdst, rs_flags)
fs_result, _ = run_fastsync(src, fdst, fs_flags, server.port)
fs_result, _ = run_fastsync(src + fs_src_suffix, fdst, fs_flags, server.port)
class _View:
"""Adapter so tree_diff/extra_check keep the Case-shaped interface."""
@@ -486,7 +490,7 @@ def execute_case(case: Case, server) -> Dict[str, object]:
layout=case.layout, seed=case.seed, stdout=case.stdout,
compare_modes=case.compare_modes, compare_hardlinks=case.compare_hardlinks,
ignore_paths=case.ignore_paths, extra_check=case.extra_check,
files_from=case.files_from,
files_from=case.files_from, fs_src_suffix=case.fs_src_suffix,
)