feat: add --dry-run to clawsync sync

- protocol: add dry_run: bool to FsDirManifest
- session: FsSyncClient::with_dry_run() sets the flag; run() handles
  FsDirDryRun response and returns would_add/modify/remove in SyncStats
  with bytes_transferred=0 and no files written
- session (server): if client sends dry_run=true, compute diffs and send
  FsDirDryRun instead of FsDirNeed; return without writing anything
- cli: --dry-run flag on `sync`; print +/~/- prefix per path and summary
- tests: 4 new unit tests covering add, modify, unchanged, and
  allow_delete+remove scenarios

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
osobh
2026-04-05 09:23:38 -05:00
co-authored by Claude Sonnet 4.6
parent 2ecf894a0a
commit ac8969fb5d
3 changed files with 236 additions and 10 deletions
+33 -4
View File
@@ -202,6 +202,9 @@ enum Commands {
/// Delete files on the remote that don't exist locally. /// Delete files on the remote that don't exist locally.
#[arg(long)] #[arg(long)]
delete: bool, delete: bool,
/// Show what would be transferred without actually transferring anything.
#[arg(long)]
dry_run: bool,
/// Exclude paths matching this glob pattern (may be repeated). /// Exclude paths matching this glob pattern (may be repeated).
#[arg(long, value_name = "GLOB")] #[arg(long, value_name = "GLOB")]
exclude: Vec<String>, exclude: Vec<String>,
@@ -1123,6 +1126,7 @@ async fn cmd_sync(
local: PathBuf, local: PathBuf,
remote: String, remote: String,
delete: bool, delete: bool,
dry_run: bool,
exclude: Vec<String>, exclude: Vec<String>,
quic: bool, quic: bool,
) -> Result<()> { ) -> Result<()> {
@@ -1152,14 +1156,38 @@ async fn cmd_sync(
) )
}; };
let stats = FsSyncClient::new(peer, local, excludes, delete) let mut client = FsSyncClient::new(peer, local, excludes, delete);
.run() if dry_run {
.await?; client = client.with_dry_run();
}
let stats = client.run().await?;
if dry_run {
println!("Dry-run (no changes applied):");
let adds = stats.would_add.as_deref().unwrap_or(&[]);
let mods = stats.would_modify.as_deref().unwrap_or(&[]);
let rems = stats.would_remove.as_deref().unwrap_or(&[]);
for p in adds {
println!(" + {p}");
}
for p in mods {
println!(" ~ {p}");
}
for p in rems {
println!(" - {p}");
}
println!(
"Would: {} add, {} modify, {} remove.",
adds.len(),
mods.len(),
rems.len()
);
} else {
println!( println!(
"Sync complete: {} added, {} modified, {} removed, {} bytes.", "Sync complete: {} added, {} modified, {} removed, {} bytes.",
stats.files_added, stats.files_modified, stats.files_removed, stats.bytes_transferred stats.files_added, stats.files_modified, stats.files_removed, stats.bytes_transferred
); );
}
Ok(()) Ok(())
} }
@@ -1808,9 +1836,10 @@ fn main() -> Result<()> {
local, local,
remote, remote,
delete, delete,
dry_run,
exclude, exclude,
quic, quic,
} => cmd_sync(local, remote, delete, exclude, quic).await, } => cmd_sync(local, remote, delete, dry_run, exclude, quic).await,
Commands::ServeFs { Commands::ServeFs {
dir, dir,
bind, bind,
+195 -2
View File
@@ -30,12 +30,22 @@ const PIPELINE_WINDOW: usize = 16;
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Aggregate statistics from one completed sync session. /// Aggregate statistics from one completed sync session.
///
/// In dry-run mode `bytes_transferred` is always 0 and the `would_*` fields
/// carry the paths that *would* be affected. In normal mode the `would_*`
/// fields are `None`.
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct SyncStats { pub struct SyncStats {
pub files_added: u32, pub files_added: u32,
pub files_modified: u32, pub files_modified: u32,
pub files_removed: u32, pub files_removed: u32,
pub bytes_transferred: u64, pub bytes_transferred: u64,
/// Populated only in dry-run mode: paths that would be added.
pub would_add: Option<Vec<String>>,
/// Populated only in dry-run mode: paths that would be modified.
pub would_modify: Option<Vec<String>>,
/// Populated only in dry-run mode: paths that would be removed.
pub would_remove: Option<Vec<String>>,
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -48,6 +58,7 @@ pub struct FsSyncClient {
local_root: PathBuf, local_root: PathBuf,
excludes: GlobSet, excludes: GlobSet,
_delete: bool, _delete: bool,
dry_run: bool,
} }
impl FsSyncClient { impl FsSyncClient {
@@ -57,9 +68,17 @@ impl FsSyncClient {
local_root, local_root,
excludes, excludes,
_delete: delete, _delete: delete,
dry_run: false,
} }
} }
/// Enable dry-run mode: the server will report what *would* change without
/// actually writing any files or transferring data.
pub fn with_dry_run(mut self) -> Self {
self.dry_run = true;
self
}
/// Execute the full directory sync protocol. /// Execute the full directory sync protocol.
pub async fn run(mut self) -> Result<SyncStats, FsSyncError> { pub async fn run(mut self) -> Result<SyncStats, FsSyncError> {
// ── RTT 1: build local manifest and send it ────────────────────────── // ── RTT 1: build local manifest and send it ──────────────────────────
@@ -74,6 +93,7 @@ impl FsSyncClient {
.send(&SyncMessage::FsDirManifest { .send(&SyncMessage::FsDirManifest {
local_root: self.local_root.to_string_lossy().into_owned(), local_root: self.local_root.to_string_lossy().into_owned(),
entries: manifest.to_wire(), entries: manifest.to_wire(),
dry_run: self.dry_run,
}) })
.await?; .await?;
@@ -82,6 +102,23 @@ impl FsSyncClient {
needed_files, needed_files,
to_delete, to_delete,
} => (needed_files, to_delete), } => (needed_files, to_delete),
// Dry-run: server reports what would change without applying it.
SyncMessage::FsDirDryRun {
would_add,
would_modify,
would_remove,
} => {
self.conn.shutdown().await?;
return Ok(SyncStats {
files_added: would_add.len() as u32,
files_modified: would_modify.len() as u32,
files_removed: would_remove.len() as u32,
bytes_transferred: 0,
would_add: Some(would_add),
would_modify: Some(would_modify),
would_remove: Some(would_remove),
});
}
SyncMessage::Error { message } => return Err(FsSyncError::Protocol(message)), SyncMessage::Error { message } => return Err(FsSyncError::Protocol(message)),
other => { other => {
return Err(FsSyncError::Protocol(format!( return Err(FsSyncError::Protocol(format!(
@@ -103,6 +140,7 @@ impl FsSyncClient {
files_modified, files_modified,
files_removed, files_removed,
bytes_transferred, bytes_transferred,
..Default::default()
}, },
other => { other => {
return Err(FsSyncError::Protocol(format!( return Err(FsSyncError::Protocol(format!(
@@ -213,6 +251,7 @@ impl FsSyncClient {
files_modified, files_modified,
files_removed, files_removed,
bytes_transferred, bytes_transferred,
..Default::default()
}, },
other => { other => {
return Err(FsSyncError::Protocol(format!( return Err(FsSyncError::Protocol(format!(
@@ -267,8 +306,10 @@ impl FsSyncServer {
first_msg: SyncMessage, first_msg: SyncMessage,
) -> Result<SyncStats, FsSyncError> { ) -> Result<SyncStats, FsSyncError> {
// Receive client's directory manifest. // Receive client's directory manifest.
let client_entries = match first_msg { let (client_entries, client_dry_run) = match first_msg {
SyncMessage::FsDirManifest { entries, .. } => entries, SyncMessage::FsDirManifest {
entries, dry_run, ..
} => (entries, dry_run),
other => { other => {
return Err(FsSyncError::Protocol(format!( return Err(FsSyncError::Protocol(format!(
"expected FsDirManifest, got {other:?}" "expected FsDirManifest, got {other:?}"
@@ -287,6 +328,52 @@ impl FsSyncServer {
// Diff (server perspective: "local" = server, "remote" = client). // Diff (server perspective: "local" = server, "remote" = client).
let diffs = diff_manifests(&server_manifest.entries, &client_entries); let diffs = diff_manifests(&server_manifest.entries, &client_entries);
// Dry-run: report what would change without applying anything.
if client_dry_run {
let would_add: Vec<String> = diffs
.iter()
.filter_map(|d| {
if matches!(d, crate::differ::FileDiff::Removed(_)) {
Some(d.path().to_string())
} else {
None
}
})
.collect();
let would_modify: Vec<String> = diffs
.iter()
.filter_map(|d| {
if matches!(d, crate::differ::FileDiff::Modified(_)) {
Some(d.path().to_string())
} else {
None
}
})
.collect();
let would_remove: Vec<String> = if self.allow_delete {
diffs
.iter()
.filter_map(|d| {
if matches!(d, crate::differ::FileDiff::Added(_)) {
Some(d.path().to_string())
} else {
None
}
})
.collect()
} else {
vec![]
};
self.conn
.send(&SyncMessage::FsDirDryRun {
would_add,
would_modify,
would_remove,
})
.await?;
return Ok(SyncStats::default());
}
// From the server's perspective: // From the server's perspective:
// local = server (what the server currently has) // local = server (what the server currently has)
// remote = client (what the client has; source of truth) // remote = client (what the client has; source of truth)
@@ -517,6 +604,7 @@ impl FsSyncServer {
files_modified, files_modified,
files_removed, files_removed,
bytes_transferred, bytes_transferred,
..Default::default()
}) })
} }
} }
@@ -661,4 +749,109 @@ mod tests {
// extra.bin should still be there — no --delete // extra.bin should still be there — no --delete
assert!(dst.path().join("extra.bin").exists()); assert!(dst.path().join("extra.bin").exists());
} }
// ── Helper: dry-run variant ───────────────────────────────────────────────
async fn run_dry_run_pair(
src_dir: &std::path::Path,
dst_dir: &std::path::Path,
allow_delete: bool,
) -> SyncStats {
let server_bind = TcpServer::bind(any_addr()).await.unwrap();
let addr = server_bind.local_addr;
let dst = dst_dir.to_path_buf();
let server_task = tokio::spawn(async move {
let (conn, _) = server_bind.accept().await.unwrap();
FsSyncServer::new(SyncPeer::Tcp(conn), dst, empty_excludes(), allow_delete)
.handle()
.await
.unwrap()
});
let conn = TcpConnection::connect(addr).await.unwrap();
let stats = FsSyncClient::new(
SyncPeer::Tcp(conn),
src_dir.to_path_buf(),
empty_excludes(),
false,
)
.with_dry_run()
.run()
.await
.unwrap();
server_task.await.unwrap();
stats
}
#[tokio::test]
async fn dry_run_reports_would_add() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("new.bin"), vec![0x11u8; 1024]).unwrap();
let stats = run_dry_run_pair(src.path(), dst.path(), false).await;
assert_eq!(stats.bytes_transferred, 0);
assert!(!dst.path().join("new.bin").exists(), "dry-run must not write files");
let would_add = stats.would_add.unwrap();
assert!(would_add.iter().any(|p| p == "new.bin"), "new.bin not in would_add: {would_add:?}");
}
#[tokio::test]
async fn dry_run_reports_would_modify() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("changed.bin"), vec![0xAAu8; 1024]).unwrap();
fs::write(dst.path().join("changed.bin"), vec![0xBBu8; 1024]).unwrap();
let stats = run_dry_run_pair(src.path(), dst.path(), false).await;
assert_eq!(stats.bytes_transferred, 0);
// File must remain unchanged on destination.
assert_eq!(fs::read(dst.path().join("changed.bin")).unwrap(), vec![0xBBu8; 1024]);
let would_modify = stats.would_modify.unwrap();
assert!(would_modify.iter().any(|p| p == "changed.bin"), "changed.bin not in would_modify: {would_modify:?}");
}
#[tokio::test]
async fn dry_run_unchanged_file_not_reported() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
let data = vec![0x55u8; 512];
fs::write(src.path().join("same.bin"), &data).unwrap();
fs::write(dst.path().join("same.bin"), &data).unwrap();
let stats = run_dry_run_pair(src.path(), dst.path(), false).await;
let would_add = stats.would_add.unwrap_or_default();
let would_modify = stats.would_modify.unwrap_or_default();
assert!(would_add.is_empty() && would_modify.is_empty(),
"unchanged file must not appear in dry-run output");
}
#[tokio::test]
async fn dry_run_with_allow_delete_reports_would_remove() {
let src = TempDir::new().unwrap();
let dst = TempDir::new().unwrap();
fs::write(src.path().join("keep.bin"), b"keep").unwrap();
fs::write(dst.path().join("keep.bin"), b"keep").unwrap();
fs::write(dst.path().join("server_only.bin"), b"extra").unwrap();
let stats = run_dry_run_pair(src.path(), dst.path(), true).await;
assert_eq!(stats.bytes_transferred, 0);
// File must not be deleted by dry-run.
assert!(dst.path().join("server_only.bin").exists(), "dry-run must not delete files");
let would_remove = stats.would_remove.unwrap();
assert!(would_remove.iter().any(|p| p == "server_only.bin"),
"server_only.bin not in would_remove: {would_remove:?}");
}
} }
@@ -210,6 +210,9 @@ pub enum SyncMessage {
local_root: String, local_root: String,
/// Per-file entries; paths are relative to `local_root`. /// Per-file entries; paths are relative to `local_root`.
entries: Vec<FsManifestEntry>, entries: Vec<FsManifestEntry>,
/// When `true` the server reports what *would* change (via `FsDirDryRun`)
/// without writing any files or sending data.
dry_run: bool,
}, },
/// Server sends back what it needs after comparing manifests. /// Server sends back what it needs after comparing manifests.
@@ -634,6 +637,7 @@ mod tests {
mtime: 2000, mtime: 2000,
}, },
], ],
dry_run: false,
}; };
let bytes = msg.to_bytes().unwrap(); let bytes = msg.to_bytes().unwrap();
let recovered = SyncMessage::from_bytes(&bytes).unwrap(); let recovered = SyncMessage::from_bytes(&bytes).unwrap();