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:
co-authored by
Claude Sonnet 4.6
parent
2ecf894a0a
commit
ac8969fb5d
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user