diff --git a/src/remote/util.py b/src/remote/util.py index f06afda..104f3c7 100644 --- a/src/remote/util.py +++ b/src/remote/util.py @@ -217,6 +217,15 @@ def rsync( if extra_args: args.extend(extra_args) + # https://man.archlinux.org/man/extra/rsync/rsync.1.en#FILTER_RULES + if ( + not (includes or extra_args or mirror) + and excludes is not None + and any(exclude in ("*", "**", "***") for exclude in excludes) + ): + logger.info("Skipping sync due to '*' in excludes and no includes") + return + cleanup: List[Path] = [] # It is important to add include patterns before exclude patters because rsync might ignore includes if you do otherwise. _gen_rsync_patterns_file(includes, "--include-from", args, cleanup) diff --git a/test/test_util.py b/test/test_util.py index 62394e4..c294ea7 100644 --- a/test/test_util.py +++ b/test/test_util.py @@ -1,3 +1,4 @@ +import functools import sys from unittest.mock import MagicMock, patch @@ -75,6 +76,56 @@ def test_rsync_copies_files_with_mirror(tmp_path, rsync_ssh): assert not (dst / "fourth.txt").exists() +@pytest.mark.parametrize( + "should_be_skipped, includes, excludes", + [ + (False, [], []), + (False, ["/.remoteenv"], ["*", "foobar"]), + (False, ["/.remoteenv"], ["*"]), + (False, ["/.remoteenv"], ["**"]), + (False, ["/.remoteenv"], ["***"]), + (True, [], ["*", "foobar"]), + (True, [], ["*"]), + (True, [], ["**", "foobar"]), + (True, [], ["**"]), + (True, [], ["***", "foobar"]), + (True, [], ["***"]), + ], +) +@patch("remote.util.subprocess.run") +def test_rsync_skip_on_globstar_exclude(mock_run, rsync_ssh, should_be_skipped, includes, excludes): + mock_run.return_value = MagicMock(returncode=0) + + rsync_with = functools.partial( + rsync, + "src/", + "dst", + rsync_ssh, + info=True, + verbose=True, + mirror=False, + dry_run=True, + includes=includes, + excludes=excludes, + ) + + rsync_with(mirror=False, extra_args=None) + if should_be_skipped: + mock_run.assert_not_called() + else: + mock_run.assert_called_once() + + mock_run.reset_mock() + + rsync_with(mirror=False, extra_args=["--some-extra"]) + mock_run.assert_called_once() + + mock_run.reset_mock() + + rsync_with(mirror=True, extra_args=None) + mock_run.assert_called_once() + + @patch("remote.util.subprocess.run") def test_rsync_respects_all_options(mock_run, rsync_ssh): mock_run.return_value = MagicMock(returncode=0)