From d67ba9cada0fb61b0968992f753c3d9750f72eb3 Mon Sep 17 00:00:00 2001 From: Peter van Arkel Date: Tue, 19 May 2026 20:57:29 +0200 Subject: [PATCH] (feature) Add managed rsync verbosity for dry-runs Add default dry-run rsync output flags so long-running dry-runs expose file-list, progress, stats, and itemized change information in their run-specific log files. Avoid duplicating user-supplied itemize or --info arguments so operators can still tune rsync output from global or host configuration. --- src/pobsync/rsync.py | 21 +++++++++++++++ .../tests/test_run_scheduled_config_source.py | 26 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/pobsync/rsync.py b/src/pobsync/rsync.py index a6d0967..d3b5990 100644 --- a/src/pobsync/rsync.py +++ b/src/pobsync/rsync.py @@ -10,6 +10,9 @@ from pathlib import Path from typing import Callable, Sequence +DEFAULT_DRY_RUN_OUTPUT_ARGS = ["--itemize-changes", "--info=flist2,progress2,stats2"] + + @dataclass(frozen=True) class RsyncResult: exit_code: int @@ -44,6 +47,8 @@ def build_rsync_command( cmd: list[str] = [rsync_binary] cmd.extend(list(rsync_args)) + if dry_run: + _append_default_dry_run_output_args(cmd) # includes/excludes: keep it simple for now: # - if includes are provided, user is responsible for correct rsync include logic. @@ -116,3 +121,19 @@ def _terminate_process_group(process: subprocess.Popen) -> None: except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait(timeout=10) + + +def _append_default_dry_run_output_args(command: list[str]) -> None: + if not _has_itemize_arg(command): + command.append("--itemize-changes") + if not any(arg.startswith("--info=") for arg in command): + command.append("--info=flist2,progress2,stats2") + + +def _has_itemize_arg(command: list[str]) -> bool: + for arg in command: + if arg == "--itemize-changes": + return True + if arg.startswith("-") and not arg.startswith("--") and "i" in arg: + return True + return False diff --git a/src/pobsync_backend/tests/test_run_scheduled_config_source.py b/src/pobsync_backend/tests/test_run_scheduled_config_source.py index 5c5573c..d486062 100644 --- a/src/pobsync_backend/tests/test_run_scheduled_config_source.py +++ b/src/pobsync_backend/tests/test_run_scheduled_config_source.py @@ -93,6 +93,8 @@ class RunScheduledConfigSourceTests(SimpleTestCase): def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): self.assertEqual(log_path, Path("/tmp/pobsync-dryrun/web-01/run-42/rsync.log")) self.assertEqual(timeout_seconds, 900) + self.assertIn("--itemize-changes", command) + self.assertIn("--info=flist2,progress2,stats2", command) log_path.parent.mkdir(parents=True, exist_ok=True) log_path.write_text("run 42\n", encoding="utf-8") return RsyncResult(exit_code=0, command=command) @@ -110,6 +112,30 @@ class RunScheduledConfigSourceTests(SimpleTestCase): self.assertEqual(result["log"], "/tmp/pobsync-dryrun/web-01/run-42/rsync.log") self.assertEqual(result["timeout_seconds"], 900) + def test_dry_run_does_not_duplicate_custom_output_args(self) -> None: + config_source = FakeConfigSource() + + def effective_config_for_host(host: str) -> dict: + config = FakeConfigSource.effective_config_for_host(config_source, host) + config["rsync"]["args_effective"] = ["--archive", "--itemize-changes", "--info=name1,stats2"] + return config + + config_source.effective_config_for_host = effective_config_for_host + + with patch("pobsync.commands.run_scheduled.run_rsync") as run_rsync: + run_rsync.return_value = RsyncResult(exit_code=0, command=["rsync"]) + run_scheduled( + prefix=Path("/missing-prefix"), + host="web-01", + dry_run=True, + config_source=config_source, + ) + + command = run_rsync.call_args.args[0] + self.assertEqual(command.count("--itemize-changes"), 1) + self.assertNotIn("--info=flist2,progress2,stats2", command) + self.assertIn("--info=name1,stats2", command) + def test_dry_run_reports_cancelled_rsync(self) -> None: def fake_run_rsync(command, log_path, timeout_seconds, cancel_check=None): self.assertTrue(cancel_check())