From 4a7703b06a2bb634e83b80e36af94df1cc46b885 Mon Sep 17 00:00:00 2001 From: TapTap Date: Wed, 16 Sep 2026 22:44:22 +0200 Subject: [PATCH] feat(parity): -d/--dirs one-level listing for dir/, dir/. and . A trailing slash (or trailing '/.', or a bare '.') now lists the source's immediate contents -- files transferred, subdirectories created empty -- without recursing, while a bare directory still sends only its own entry. The -R prefix applies to the generated entries and to the root entry. --- src/client/scanner.c | 83 ++++++++++++++++++++-- tests/integration/test_parity_selection.py | 37 ++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/client/scanner.c b/src/client/scanner.c index 0320348..0721fa5 100644 --- a/src/client/scanner.c +++ b/src/client/scanner.c @@ -906,18 +906,20 @@ static int open_next_directory(DirectoryScanner* scanner) { /* ---- --dirs mode ---- With -d the scanner transfers directory entries and never recurses into contents. A plain `-d ` sends only the source-root directory mirror - (created empty at the destination). With -d + --files-from exactly the - listed items are sent: listed directories become empty directory entries and - listed regular files are transferred as files; nothing else is scanned, so - no descent into a listed directory can happen. */ + (created empty at the destination); `-d dir/`, `-d dir/.` and `-d .` list + the directory's immediate contents instead (files plus empty directory + entries), matching rsync. With -d + --files-from exactly the listed items + are sent: listed directories become empty directory entries and listed + regular files are transferred as files; nothing else is scanned, so no + descent into a listed directory can happen. */ /* Directory entries carry no payload, so the dirs generator also bounds every chunk by element count; chunk_deserialize refuses more than this many files per chunk (see MAX_FILES_PER_CHUNK in chunk.c). */ #define DIRS_CHUNK_MAX_FILES 65536U -/* Build the File for the transfer root directory itself (the `-d ` and - * "." cases). */ +/* Build the File for the transfer root directory itself (the `-d ` + * no-trailing-slash case). */ static File* dirs_root_dir_file(DirectoryScanner* scanner) { struct stat st; if (stat(scanner->root_path, &st) != 0 || !S_ISDIR(st.st_mode)) { @@ -940,6 +942,14 @@ static File* dirs_root_dir_file(DirectoryScanner* scanner) { return NULL; } } + if (scanner->options.relative_prefix && scanner->options.relative_prefix[0] != '\0') { + file->send_path = str_dup(scanner->options.relative_prefix); + if (!file->send_path) { + file_destroy(file); + scanner->failed = true; + return NULL; + } + } scanner_capture_xattrs(scanner, file); return file; } @@ -1044,6 +1054,13 @@ static File* dirs_file_for_entry(DirectoryScanner* scanner, const char* entry) { scanner->failed = true; return NULL; } + } else if (scanner->options.relative_prefix) { + file->send_path = scanner_prefix_send_path(scanner->options.relative_prefix, entry); + if (!file->send_path) { + file_destroy(file); + scanner->failed = true; + return NULL; + } } if (scanner->options.use_metadata) { file->metadata = file_metadata_create(file->path, &effective, scanner->options.preserve_atimes, @@ -1077,9 +1094,63 @@ static bool dirs_source_dir_is_empty(const char* path) { return empty; } +/* The next immediate child of the source root for a one-level --dirs listing + * (rsync: -d DIR/ lists DIR's immediate contents without recursing). */ +static File* dirs_next_child(DirectoryScanner* scanner) { + if (!scanner->current_dir) + return NULL; + const struct dirent* entry; + while ((entry = readdir(scanner->current_dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) + continue; + File* file = dirs_file_for_entry(scanner, entry->d_name); + if (scanner->failed) + return NULL; + if (file && !entry_passes_selection(scanner->options.file_list, scanner->options.base_filters, + NULL, entry->d_name, entry->d_name, file->is_dir, + scanner->options.per_dir_filters)) { + file_destroy(file); + continue; + } + if (file && file->is_dir && scanner->options.prune_empty_dirs && + dirs_source_dir_is_empty(file->path)) { + file_destroy(file); + continue; + } + if (file) + return file; + } + closedir(scanner->current_dir); + scanner->current_dir = NULL; + return NULL; +} + /* The next File from the --dirs generator, or NULL when exhausted. */ static File* dirs_next_file(DirectoryScanner* scanner) { if (!scanner->options.file_list) { + const char* spec = scanner->root_path ? scanner->root_path : ""; + size_t n = strlen(spec); + /* rsync: a trailing slash or "/." on the source argument lists the + directory's immediate contents (files and empty directory entries) + without recursing. A bare directory sends only its own entry. */ + bool list_children = + (n == 1 && spec[0] == '.') || + (n > 0 && (spec[n - 1] == '/' || (n >= 2 && spec[n - 1] == '.' && spec[n - 2] == '/'))); + if (list_children) { + if (!scanner->dirs_root_emitted) { + scanner->dirs_root_emitted = true; + if (scanner->options.prune_empty_dirs && dirs_source_dir_is_empty(scanner->root_path)) + return NULL; + scanner->current_dir = opendir(scanner->root_path); + if (!scanner->current_dir) { + scanner->io_error = true; + log_perror("Could not open directory"); + scanner->failed = true; + return NULL; + } + } + return dirs_next_child(scanner); + } if (scanner->dirs_root_emitted) return NULL; scanner->dirs_root_emitted = true; diff --git a/tests/integration/test_parity_selection.py b/tests/integration/test_parity_selection.py index f08760d..9400602 100644 --- a/tests/integration/test_parity_selection.py +++ b/tests/integration/test_parity_selection.py @@ -105,6 +105,43 @@ class TestRelativeGeneral: f"mtime mismatch for {rel} with {extra}" +class TestDirsOneLevel: + """#13: -d with a trailing slash (or '.') lists the source's immediate + contents; FastSync mirrors them below the source-root mirror, so compare + rsync's destination tree against that mirror.""" + + @requires_rsync + @pytest.mark.ci + def test_dirs_trailing_slash_matches_rsync(self, shared_server): + source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_dirs_src")) + os.makedirs(os.path.join(source, "empty"), exist_ok=True) + dest = os.path.join(TEST_DATA_DIR, "sel_dirs_dst") + rdst = os.path.join(TEST_DATA_DIR, "sel_dirs_rdst") + clean_dir(dest) + clean_dir(rdst) + r = _rsync(["-d", source + "/", rdst + "/"]) + assert r.returncode == 0, r.stderr + result, _ = run_client(source + "/", dest, flags=["-d"], port=shared_server.port) + assert result.returncode == 0, result.stderr[:300] + mirror = get_dest_received_dir(dest, source) + assert _tree(rdst) == _tree(mirror) + + @requires_rsync + @pytest.mark.ci + def test_dirs_relative_matches_rsync(self, shared_server): + source = _make_tree(os.path.join(TEST_DATA_DIR, "sel_dirsr_src")) + dest = os.path.join(TEST_DATA_DIR, "sel_dirsr_dst") + rdst = os.path.join(TEST_DATA_DIR, "sel_dirsr_rdst") + clean_dir(dest) + clean_dir(rdst) + spec = source + "/./foo" + r = _rsync(["-d", "-R", spec, rdst + "/"]) + assert r.returncode == 0, r.stderr + result, _ = run_client(spec, dest, flags=["-d", "-R"], port=shared_server.port) + assert result.returncode == 0, result.stderr[:300] + assert _tree(rdst) == _tree(dest) + + class TestClientAliases: """#5: safe rsync option aliases accepted client-side."""