feat(protocol): destination-state itemize for dirs/symlinks; --stats deleted breakdown

This commit is contained in:
2026-09-23 21:52:40 +02:00
parent cb2979fdf1
commit 29f8be161c
20 changed files with 761 additions and 99 deletions
+67 -4
View File
@@ -123,8 +123,15 @@ static char itemize_type_char(const ChangeEvent* event) {
static bool times_match(const Config* config, const ChangeEvent* event) {
if (!event->dest.known || !event->dest.existed)
return false;
if (event->mtime_sec == event->dest.mtime_sec)
if (event->mtime_sec == event->dest.mtime_sec) {
/* A regular file's sub-second mtime IS preserved by the receiver, so an nsec
difference is a real change. A directory or symlink has no preserved
sub-second mtime (rsync's quick-check compares whole seconds there), so a
nanosecond-only difference must not render a spurious `.d..t` / `.L..t`. */
if (event->is_directory || event->is_symlink || event->is_special)
return true;
return event->mtime_nsec == event->dest.mtime_nsec;
}
long long delta = (long long)event->mtime_sec - (long long)event->dest.mtime_sec;
if (delta < 0)
delta = -delta;
@@ -145,6 +152,10 @@ static void itemize_code(const Config* config, const ChangeEvent* event, char co
/* rsync: an existing directory that only has attribute changes carries no
transfer, so the update column is `.` rather than `>`. */
update = '.';
else if (event->is_symlink)
/* rsync: an existing symlink whose target is unchanged is a `.` update
(attributes only); a changed target is `c` (the link value changed). */
update = event->dest.target_matches ? '.' : 'c';
else
update = '>';
code[0] = update;
@@ -155,12 +166,20 @@ static void itemize_code(const Config* config, const ChangeEvent* event, char co
code[11] = '\0';
return;
}
bool size_diff = event->size != event->dest.size;
bool time_diff = !times_match(config, event);
/* rsync's value/checksum column: `c` for a symlink whose target changed (the
link value is the compared content); no destination digest is available for
a regular file. */
bool value_diff = event->is_symlink && !event->dest.target_matches;
/* rsync itemizes size only for regular files: a directory's st_size and a
symlink's target length are not compared. */
bool size_diff = !event->is_directory && !event->is_symlink && !event->is_special &&
event->size != event->dest.size;
/* rsync itemizes the time column only when -t/--times is in effect. */
bool time_diff = config->preserve_times && !times_match(config, event);
bool perms_diff = (event->mode & 07777) != (event->dest.mode & 07777);
bool owner_diff = event->uid != (uid_t)event->dest.uid;
bool group_diff = event->gid != (gid_t)event->dest.gid;
code[2] = '.'; /* checksum: no destination digest available */
code[2] = value_diff ? 'c' : '.';
code[3] = size_diff ? 's' : '.';
code[4] = time_diff ? 't' : '.';
code[5] = (config->preserve_perms && perms_diff) ? 'p' : '.';
@@ -172,6 +191,24 @@ static void itemize_code(const Config* config, const ChangeEvent* event, char co
code[11] = '\0';
}
/* True when the itemized destination entry is unchanged, i.e. rsync would print
* no line at all. Reuses itemize_code so suppression is exactly consistent
* with what would have been rendered: the update column must be `.` and every
* attribute column must be `.`. */
static bool itemize_is_unchanged(const Config* config, const ChangeEvent* event) {
if (!event->dest.known || !event->dest.existed)
return false;
char code[12];
itemize_code(config, event, code);
if (code[0] != '.')
return false;
for (int i = 2; i < 11; i++) {
if (code[i] != '.')
return false;
}
return true;
}
/* rsync %n: the transfer-relative name, with a trailing slash for directories.
* The transfer root is `.` (so `%n` renders `./`), matching rsync's root entry. */
static bool append_name(StrBuf* buf, const ChangeEvent* event) {
@@ -678,6 +715,19 @@ void change_emit_file_sent_bytes(const Config* config, const File* file,
char* name = NULL;
char* path = NULL;
fill_event_from_file(config, file, &event, &name, &path);
if (file->is_symlink) {
/* Output parity (protocol 2.30.0): an unchanged symlink is silent, like
rsync's quick check. The itemize/log stream suppresses it only when every
attribute matches; the name stream suppresses it whenever the link target
is unchanged (rsync names a symlink only when it relinks or creates it). */
bool itemize_output = config->itemize_changes || config->out_format != NULL ||
(config->log_file != NULL && config->log_file_format != NULL);
bool suppress = itemize_output
? itemize_is_unchanged(config, &event)
: (event.dest.known && event.dest.existed && event.dest.target_matches);
if (suppress)
event.decision = CHANGE_UP_TO_DATE;
}
if (name != NULL && path != NULL) {
fill_event_checksum(config, file, &event);
change_emit(config, &event);
@@ -729,6 +779,19 @@ void change_emit_dir_sent(const Config* config, const File* file) {
char* name = NULL;
char* path = NULL;
fill_event_from_file(config, file, &event, &name, &path);
/* Output parity (protocol 2.30.0): suppress a directory rsync would leave
silent. The itemize/log stream suppresses it only when every attribute
matches (`.d.........`); the name stream suppresses any pre-existing
directory (rsync names a directory only when it is created). */
bool itemize_output = config->itemize_changes || config->out_format != NULL ||
(config->log_file != NULL && config->log_file_format != NULL);
bool suppress = itemize_output ? itemize_is_unchanged(config, &event)
: (event.dest.known && event.dest.existed);
/* The transfer root's line is an unconditional FastSync residual (rsync keys
it off the root's own attribute change); keep emitting it. */
bool is_root = event.name != NULL && event.name[0] == '\0';
if (suppress && !is_root)
event.decision = CHANGE_UP_TO_DATE;
if (name != NULL && path != NULL)
change_emit(config, &event);
free(name);
+4 -1
View File
@@ -2807,8 +2807,11 @@ static int cli_finalize_config(Config* config, bool verbose, bool no_delta, bool
* need the pre-transfer destination snapshot (new vs modified and which
* attributes differ), so ask the receiver to report it on every per-file
* check. This is a wire field. */
bool progress_active =
!config->quiet && (config->show_progress || (config->info_level & LOG_INFO_PROGRESS) != 0);
config->report_dest_info = config->itemize_changes || config->out_format != NULL ||
(config->log_file != NULL && config->log_file_format != NULL);
(config->log_file != NULL && config->log_file_format != NULL) ||
progress_active;
/* Wire-stats parity: --stats, --progress/-P, an --out-format token that needs
* a wire counter (%b/%c), or a dry-run --delete need the receiver's
* end-of-transfer STATUS_STATS report. This is a wire field (protocol
+145 -4
View File
@@ -162,6 +162,9 @@ void report_transfer_stats(const Config* config, const TransferStats* stats, tim
created_breakdown, sizeof(created_breakdown));
unsigned long long created_total =
recv->created_reg + recv->created_dir + recv->created_link + recv->created_special;
char deleted_breakdown[128];
type_breakdown(recv->deleted_reg, recv->deleted_dir, recv->deleted_link, recv->deleted_special,
deleted_breakdown, sizeof(deleted_breakdown));
printf("\n");
if (breakdown[0] != '\0')
printf("Number of files: %llu %s\n", flist_total, breakdown);
@@ -173,7 +176,13 @@ void report_transfer_stats(const Config* config, const TransferStats* stats, tim
printf("Number of created files: %llu %s\n", created_total, created_breakdown);
else
printf("Number of created files: %llu\n", created_total);
printf("Number of deleted files: %llu\n", recv->deleted_files);
/* Protocol 2.30.0: the receiver reports the removed entries split by type, so
this line matches rsync's `Number of deleted files: X (reg: A, dir: B,
link: C, special: D)` (only the non-zero categories are listed). */
if (deleted_breakdown[0] != '\0')
printf("Number of deleted files: %llu %s\n", recv->deleted_files, deleted_breakdown);
else
printf("Number of deleted files: %llu\n", recv->deleted_files);
printf("Number of regular files transferred: %llu\n", stats->transferred_regular);
printf("Total file size: %s bytes\n", total);
printf("Total transferred file size: %s bytes\n", transferred);
@@ -526,9 +535,16 @@ static void client_progress_emit_ancestors(const Config* config, const char* rel
if (dir != NULL)
change_emit_dir_sent(config, dir);
} else {
char* escaped = output_escape(prefix, config->eight_bit_output);
printf("%s/\n", escaped ? escaped : prefix);
free(escaped);
/* --progress/-P names a directory only when it is newly created;
rsync stays silent for a pre-existing directory even when one of
its children changed (protocol 2.30.0 dest-state report). */
const File* dir = progress_dir_lookup(prefix);
bool existed = dir != NULL && dir->dest_state.known && dir->dest_state.existed;
if (!existed) {
char* escaped = output_escape(prefix, config->eight_bit_output);
printf("%s/\n", escaped ? escaped : prefix);
free(escaped);
}
}
g_progress_index++;
} else {
@@ -553,6 +569,122 @@ void client_change_emit_ancestors(const Config* config, const File* file) {
client_progress_emit_ancestors(config, rel);
}
/* Send one STATUS_MKDIR probe (probe=1) for a pre-count directory and cache the
* receiver's pre-transfer destination snapshot in `dir->dest_state`. Returns
* false on a protocol/transport error. */
static bool client_probe_dir_state(int fd, File* dir) {
if (dir == NULL || file_wire_path(dir) == NULL)
return true;
if (!send_status(fd, STATUS_MKDIR) || !send_int(fd, 1) || !send_wire_str(fd, file_wire_path(dir)))
return false;
Status status = STATUS_ERROR;
if (!receive_status(fd, &status) || status != STATUS_DEST_INFO ||
!format_dest_state_receive(fd, &dir->dest_state)) {
log_message(LOG_LEVEL_ERROR, "Directory destination-state probe failed");
return false;
}
return true;
}
/* Output parity (protocol 2.30.0): ask the receiver for each not-yet-probed
* ancestor directory's pre-transfer state BEFORE the entry that first triggers
* it is sent, so the ancestor's -i/--out-format line renders rsync's
* `.d..t......` (existing, attributes changed) versus `cd+++++++++` (created)
* and an unchanged directory is suppressed. The probe is a STATUS_MKDIR frame
* with probe=1 (the receiver reports and creates nothing), so it must run before
* the receiver implicitly creates the parent for the child. Each directory is
* probed at most once; the result is cached in the pre-count File's dest_state.
* Returns false on a protocol/transport error (the caller aborts the transfer). */
bool client_change_probe_ancestors(const Config* config, const File* file, int fd) {
if (config == NULL || file == NULL || fd < 0 || !config->report_dest_info)
return true;
if (!g_progress_dir_index_valid || !g_progress_precount.dir_refs)
return true;
const char* rel = delete_display_path(config, file_wire_path(file));
if (rel == NULL)
return true;
size_t rel_len = strlen(rel);
for (size_t i = 1; i < rel_len; i++) {
if (rel[i] != '/')
continue;
char* prefix = malloc(i + 1);
if (prefix == NULL)
return false;
memcpy(prefix, rel, i);
prefix[i] = '\0';
if (path_index_contains(&g_progress_dir_index, prefix)) {
File* dir = progress_dir_lookup(prefix);
/* The probe path is the same wire path the real STATUS_MKDIR would carry
(the pre-count File's send_path, or its absolute source path for a
plain recursive scan), not the display-relative prefix. */
if (dir != NULL && !dir->dest_state.known && !client_probe_dir_state(fd, dir)) {
free(prefix);
return false;
}
}
free(prefix);
}
return true;
}
/* Mark a directory the data pass already itemized/named so the end-of-transfer
* pending-directory flush does not report it a second time. `file` is a
* transferred directory entry (an empty-directory STATUS_MKDIR). */
void client_change_mark_dir(const Config* config, const File* file) {
if (config == NULL || file == NULL || !g_progress_emitted_valid ||
g_progress_emitted_keys == NULL)
return;
const char* rel = delete_display_path(config, file_wire_path(file));
if (rel == NULL || rel[0] == '\0' || str_hash_set_lookup(&g_progress_emitted, rel))
return;
char* key = str_dup(rel);
if (key == NULL)
return;
if (!array_list_add(g_progress_emitted_keys, key)) {
free(key);
return;
}
str_hash_set_insert_ref(&g_progress_emitted, key);
}
/* Emit the itemize lines for source directories that CHANGED but had no
* transferred child, so no ancestor emission reached them (rsync reports a
* directory whose attributes changed even when its contents did not). Runs at
* the end of the data pass, BEFORE the deferred STATUS_DIR_TIMES apply, so the
* probe still observes each untouched directory's pre-transfer state. Only the
* itemize/out-format/log streams report attribute-only directory changes;
* --progress/-P stays silent for them, matching rsync. Best-effort: a probe
* failure simply stops the flush (the transfer's verdict is unaffected). */
void client_change_emit_pending_dirs(const Config* config, int fd) {
if (config == NULL || fd < 0 || !config->report_dest_info)
return;
bool itemize_output = config->itemize_changes || config->out_format != NULL ||
(config->log_file != NULL && config->log_file_format != NULL);
if (!itemize_output)
return;
if (!g_progress_dir_index_valid || g_progress_precount.dir_refs == NULL ||
!g_progress_emitted_valid || g_progress_emitted_keys == NULL)
return;
for (int i = 0; i < g_progress_precount.dir_refs->size; i++) {
DirRef* ref = (DirRef*)g_progress_precount.dir_refs->items[i];
if (ref == NULL || ref->name == NULL || ref->name[0] == '\0')
continue;
if (str_hash_set_lookup(&g_progress_emitted, ref->name))
continue;
File* dir = ref->file;
if (dir == NULL)
continue;
if (!dir->dest_state.known && !client_probe_dir_state(fd, dir))
return;
change_emit_dir_sent(config, dir);
char* key = str_dup(ref->name);
if (key != NULL && array_list_add(g_progress_emitted_keys, key))
str_hash_set_insert_ref(&g_progress_emitted, key);
else
free(key);
}
}
/* rsync's --info=name/progress line for one entry: transfer-relative name (a
* trailing slash for directories) plus the ` -> target` symlink suffix. */
static char* progress_entry_line(const File* file, const char* rel) {
@@ -645,6 +777,15 @@ void client_progress_file(const Config* config, const File* file) {
void client_progress_name(const Config* config, const File* file) {
if (!g_progress_active || file == NULL)
return;
/* rsync's --progress/-P name stream reports an entry only when it is created
or (for a symlink) actually relinked: a pre-existing directory or an
unchanged symlink is silent (protocol 2.30.0 dest-state report). */
if (file->dest_state.known && file->dest_state.existed) {
if (file->is_dir || (file->is_symlink && file->dest_state.target_matches)) {
g_progress_index++;
return;
}
}
const char* rel = delete_display_path(config, file_wire_path(file));
if (!config->itemize_changes && config->out_format == NULL) {
char* line = progress_entry_line(file, rel ? rel : "");
+54 -7
View File
@@ -615,14 +615,34 @@ static bool send_file_direct(File* file, int fd, bool use_metadata, int compress
static bool send_directory_entry(const Client* client, File* file, const Config* config) {
if (!file || !file_wire_path(file))
return false;
if (!send_status(client->file_descriptor, STATUS_MKDIR) ||
!send_wire_str(client->file_descriptor, file_wire_path(file)))
int fd = client->file_descriptor;
if (!send_status(fd, STATUS_MKDIR))
return false;
if (config->use_metadata && !metadata_send(client->file_descriptor, file->metadata))
/* Output parity (protocol 2.30.0): when report_dest_info is negotiated every
STATUS_MKDIR body is prefixed with a probe flag (1 = probe only, 0 = a real
create), so the receiver knows whether to expect the metadata/xattr block. */
if (config->report_dest_info && !send_int(fd, 0))
return false;
if (!send_wire_str(fd, file_wire_path(file)))
return false;
if (config->use_metadata && !metadata_send(fd, file->metadata))
return false;
/* Directory xattrs/ACLs (-X/-A) ride the same trailing block as regular files
when the xattr transport was negotiated. */
return !config->use_xattrs || xattr_send(client->file_descriptor, file->xattrs);
if (config->use_xattrs && !xattr_send(fd, file->xattrs))
return false;
/* The receiver answers with the directory's pre-transfer destination state
BEFORE creating it, so the sender can render rsync's `.d..t......` versus
`cd+++++++++` and suppress an unchanged directory. */
if (config->report_dest_info) {
Status status;
if (!receive_status(fd, &status) || status != STATUS_DEST_INFO ||
!format_dest_state_receive(fd, &file->dest_state)) {
log_message(LOG_LEVEL_ERROR, "Unexpected reply to the directory destination-state report");
return false;
}
}
return true;
}
/* P7 Wave D: transmit every captured source directory's metadata in terminal
@@ -678,7 +698,21 @@ static bool send_symlink_entry(const Client* client, File* file, const Config* c
return false;
/* Symlink xattrs/ACLs (-X/-A) ride the same trailing block as regular files
and directories when the xattr transport was negotiated. */
return !config->use_xattrs || xattr_send(fd, file->xattrs);
if (config->use_xattrs && !xattr_send(fd, file->xattrs))
return false;
/* The receiver answers with the symlink's pre-transfer destination state
(including whether the on-disk link target already matches) BEFORE creating
it, so the sender can render rsync's `cLc........` / `.L..t......` and
suppress an unchanged symlink. */
if (config->report_dest_info) {
Status status;
if (!receive_status(fd, &status) || status != STATUS_DEST_INFO ||
!format_dest_state_receive(fd, &file->dest_state)) {
log_message(LOG_LEVEL_ERROR, "Unexpected reply to the symlink destination-state report");
return false;
}
}
return true;
}
// Send a single file directly via sendfile (non-incremental path).
@@ -869,10 +903,12 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
feed -i/--out-format its ancestor directory lines here. */
if (config->itemize_changes || config->out_format != NULL)
client_change_emit_ancestors(config, chunk->items[i]);
if (chunk->items[i]->is_dir)
if (chunk->items[i]->is_dir) {
change_emit_dir_sent(config, chunk->items[i]);
else
client_change_mark_dir(config, chunk->items[i]);
} else {
change_emit_file_sent(config, chunk->items[i]);
}
if (!chunk->items[i]->is_dir)
transfer_stats_note_transferred(stats, chunk->items[i]);
}
@@ -884,6 +920,11 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
if (f == NULL)
continue;
transfer_stats_note_entry(stats, f);
/* Output parity: probe this entry's ancestor directories' destination state
before the entry (or the first child below them) is sent, while the
receiver has not yet created them implicitly. */
if (!client_change_probe_ancestors(config, f, client->file_descriptor))
return -1;
if (f->is_dir) {
/* Explicit directory entry (--dirs): a MKDIR frame carrying the
destination path (and metadata when negotiated). Directories have no
@@ -892,6 +933,7 @@ static int send_chunk_with_removal(Client* client, Chunk* chunk, Config* config,
return -1;
client_change_emit_ancestors(config, f);
change_emit_dir_sent(config, f);
client_change_mark_dir(config, f);
client_progress_name(config, f);
continue;
}
@@ -1137,6 +1179,7 @@ static int send_chunks_multithreaded(void* pipeline_context) {
(and all parallel workers) has been joined before scanner_done was set, so
the list is complete and race-free; on an early stop the list may be
incomplete and is deliberately not sent. */
client_change_emit_pending_dirs(context->config, client->file_descriptor);
if (!context->scan_stopped_early &&
!send_dir_times(client, context->config, context->dir_entries))
goto send_fail;
@@ -1844,6 +1887,10 @@ static int send_files_finalize(const Config* config, SendFilesState* state) {
}
}
}
/* Output parity: report changed directories that had no transferred child
before the deferred directory times are applied (so the probe still sees
their pre-transfer state). */
client_change_emit_pending_dirs(config, client->file_descriptor);
/* P7 Wave D: every directory has now been traversed (or the scan stopped
early), so transmit the captured directory times last. The receiver defers
applying them until after its own deletion/publication phase. */
+8
View File
@@ -68,6 +68,14 @@ void client_progress_name(const Config* config, const File* file);
/* Emit a transferred entry's ancestor directories (as -i/--out-format change
* lines or --progress name lines) before the entry's own line. */
void client_change_emit_ancestors(const Config* config, const File* file);
/* Output parity (protocol 2.30.0): probe each not-yet-known ancestor directory's
* pre-transfer destination state before the entry that first triggers it is
* sent. Returns false on a protocol/transport error. */
bool client_change_probe_ancestors(const Config* config, const File* file, int fd);
/* Mark a transferred directory entry as already reported, and flush the
* itemize lines for changed directories that had no transferred child. */
void client_change_mark_dir(const Config* config, const File* file);
void client_change_emit_pending_dirs(const Config* config, int fd);
void client_progress_uptodate(const Config* config, const File* file);
void client_progress_prepare(const Config* config, const ArrayList* plan_dirs,
unsigned long long plan_non_dir_count);