docs: clarify %b semantics, temper thread-safety claim, harden %M scan; add -m coverage
CI / lint (pull_request) Successful in 20s
CI / sanitizers (undefined) (pull_request) Successful in 41s
CI / sanitizers (address) (pull_request) Successful in 41s
CI / fuzz-build (pull_request) Successful in 16s
CI / coverage (pull_request) Successful in 35s
CI / build-and-test (pull_request) Successful in 1m22s
CI / valgrind (pull_request) Successful in 36s

Address c-review nits on the itemize/output feature:
- RSYNC_COMPAT.md: state that %b is the source length (always == %l) because
  no wire-byte counter exists; keep Summary equal to the matrix (recounted:
  54 implemented / 84 not-implemented, 147 rows total - four rows flipped).
- change_list.h/.c: document bytes_sent == size; note itemize/out-format lines
  never interleave with each other but may interleave with legacy log
  messages sharing the stream; mark the %M stat() path best-effort.
- change_render_format scan in format_uses_mtime now mirrors the tokenizer
  (skips '%%' and unknown '%X' pairs) so a literal '%%M' no longer triggers
  the stat() fallback.
- Integration tests: --list-only under -m; a changed file on a second
  --incremental run emits exactly one '>f' line while unchanged files print
  nothing; --log-file + --log-file-format under -m.
This commit is contained in:
2026-09-06 12:49:49 +02:00
parent 7f5547e900
commit 1b67f5ffcf
4 changed files with 101 additions and 14 deletions
+18 -4
View File
@@ -266,9 +266,20 @@ void change_emit(const Config* config, const ChangeEvent* event) {
static bool format_uses_mtime(const char* format) {
if (format == NULL)
return false;
for (const char* p = format; *p != '\0'; p++) {
if (p[0] == '%' && p[1] == 'M')
/* Mirror change_render_format's tokenizer: "%%" is a literal percent (so
* "%%M" does NOT expand %M) and unknown "%X" escapes consume both chars.
* This keeps the optional stat() fallback below in step with the renderer. */
for (const char* p = format; *p != '\0';) {
if (*p != '%') {
p++;
continue;
}
char token = p[1];
if (token == '\0')
break;
if (token == 'M')
return true;
p += 2;
}
return false;
}
@@ -282,12 +293,15 @@ void change_emit_file_sent(const Config* config, const File* file) {
event.decision = CHANGE_SENT;
event.is_directory = false;
event.size = file->data != NULL ? file->data->size : 0;
/* FastSync does not currently count post-compression/delta wire bytes, so
* the reported value is the source length that had to be delivered. */
/* FastSync has no wire-byte counter yet, so %b reports the source length
* that had to be delivered (always equal to %l); the actual bytes written
* to the socket (compressed/delta) are not measured. */
event.bytes_sent = event.size;
if (file->metadata != NULL) {
event.mtime_sec = file->metadata->mtime_sec;
} else if (format_uses_mtime(config->out_format) || format_uses_mtime(config->log_file_format)) {
/* Best-effort fallback for %M when no metadata was captured (no -M): the
* path is stat()ed just to fill the field, and any failure leaves 0. */
struct stat st;
if (file->path != NULL && stat(file->path, &st) == 0)
event.mtime_sec = st.st_mtime;
+14 -8
View File
@@ -14,8 +14,10 @@
* FastSync is a push-style tool: the client sends files from the source tree
* to a server that writes them under the destination root. Events are
* emitted by whichever code path decides a file's fate (the single-threaded
* send loop and the `-m` sender thread both call the same per-file sender, so
* only that one thread ever reports events - no cross-thread printing races).
* send loop and the `-m` sender thread both call the same per-file sender), so
* all change events are emitted by exactly one thread and itemize/out-format
* lines never interleave with each other. They may still interleave with
* legacy log messages (log.c) that share the same stdout/log-file stream.
*/
typedef enum {
@@ -27,9 +29,12 @@ typedef struct {
const char* path; /* full source path */
ChangeDecision decision;
bool is_directory;
unsigned long long size; /* source file length in bytes */
unsigned long long bytes_sent; /* payload bytes sent (best effort) */
time_t mtime_sec; /* 0 when unknown */
unsigned long long size; /* source file length in bytes */
/* The number of bytes reported for a sent file. FastSync has no wire-byte
* counter, so this is always the source length (== size / %l); actual
* post-compression/delta bytes on the wire are not counted. */
unsigned long long bytes_sent;
time_t mtime_sec; /* 0 when unknown */
} ChangeEvent;
/* True when any output mode is active and per-file events matter. */
@@ -46,9 +51,10 @@ bool change_list_enabled(const Config* config);
char* change_render_itemize(const ChangeEvent* event);
/* Expand an --out-format/--log-file-format template. Tokens:
* %f full source path %b bytes sent (== %l for a whole file)
* %n leaf (base) name %M mtime in whole seconds since the epoch
* %l file length in bytes %% a literal percent sign
* %f full source path %b "bytes sent" == the source length (%l);
* %n leaf (base) name actual post-compression/delta wire bytes
* %l file length in bytes are not counted
* %M mtime in whole seconds %% a literal percent sign
* Unknown %X sequences are preserved verbatim. Caller frees the result. */
char* change_render_format(const char* format, const ChangeEvent* event);