Browse Source

Merge branch 'main' into config-command-line.

Dan Helfman 2 months ago
parent
commit
5490a83d77

+ 2 - 0
NEWS

@@ -29,6 +29,8 @@
    "working_directory" are used.
  * #1044: Fix an error in the systemd credential hook when the credential name contains a "."
    character.
+ * #1048: Fix a "no such file or directory" error in ZFS, Btrfs, and LVM hooks with nested
+   directories that reside on separate devices/filesystems.
 
 1.9.14
  * #409: With the PagerDuty monitoring hook, send borgmatic logs to PagerDuty so they show up in the

+ 17 - 10
borgmatic/borg/recreate.py

@@ -2,6 +2,7 @@ import logging
 import shlex
 
 import borgmatic.borg.environment
+import borgmatic.borg.feature
 import borgmatic.config.paths
 import borgmatic.execute
 from borgmatic.borg import flags
@@ -66,19 +67,25 @@ def recreate_archive(
         + (('--timestamp', recreate_arguments.timestamp) if recreate_arguments.timestamp else ())
         + (('--compression', compression) if compression else ())
         + (('--chunker-params', chunker_params) if chunker_params else ())
-        + (
-            flags.make_match_archives_flags(
-                archive or config.get('match_archives'),
-                config.get('archive_name_format'),
-                local_borg_version,
-            )
-        )
         + (('--recompress', recompress) if recompress else ())
         + exclude_flags
         + (
-            flags.make_repository_archive_flags(repository, archive, local_borg_version)
-            if archive
-            else flags.make_repository_flags(repository, local_borg_version)
+            (
+                flags.make_repository_flags(repository, local_borg_version)
+                + flags.make_match_archives_flags(
+                    archive or config.get('match_archives'),
+                    config.get('archive_name_format'),
+                    local_borg_version,
+                )
+            )
+            if borgmatic.borg.feature.available(
+                borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version
+            )
+            else (
+                flags.make_repository_archive_flags(repository, archive, local_borg_version)
+                if archive
+                else flags.make_repository_flags(repository, local_borg_version)
+            )
         )
     )
 

+ 1 - 1
borgmatic/commands/arguments.py

@@ -1813,7 +1813,7 @@ def make_parsers(schema, unparsed_arguments):
         '--glob-archives',
         dest='match_archives',
         metavar='PATTERN',
-        help='Only consider archive names, hashes, or series matching this pattern',
+        help='Only consider archive names, hashes, or series matching this pattern [Borg 2.x+ only]',
     )
     recreate_group.add_argument(
         '-h', '--help', action='help', help='Show this help message and exit'

+ 8 - 0
borgmatic/hooks/data_source/snapshot.py

@@ -1,3 +1,4 @@
+import os
 import pathlib
 
 IS_A_HOOK = False
@@ -11,6 +12,10 @@ def get_contained_patterns(parent_directory, candidate_patterns):
     paths, but there's a parent directory (logical volume, dataset, subvolume, etc.) at /var, then
     /var is what we want to snapshot.
 
+    If a parent directory and a candidate pattern are on different devices, skip the pattern. That's
+    because any snapshot of a parent directory won't actually include "contained" directories if
+    they reside on separate devices.
+
     For this function to work, a candidate pattern path can't have any globs or other non-literal
     characters in the initial portion of the path that matches the parent directory. For instance, a
     parent directory of /var would match a candidate pattern path of /var/log/*/data, but not a
@@ -27,6 +32,8 @@ def get_contained_patterns(parent_directory, candidate_patterns):
     if not candidate_patterns:
         return ()
 
+    parent_device = os.stat(parent_directory).st_dev if os.path.exists(parent_directory) else None
+
     contained_patterns = tuple(
         candidate
         for candidate in candidate_patterns
@@ -35,6 +42,7 @@ def get_contained_patterns(parent_directory, candidate_patterns):
             pathlib.PurePath(parent_directory) == candidate_path
             or pathlib.PurePath(parent_directory) in candidate_path.parents
         )
+        if candidate.device == parent_device
     )
     candidate_patterns -= set(contained_patterns)
 

+ 233 - 55
tests/unit/borg/test_recreate.py

@@ -25,9 +25,15 @@ def test_recreate_archive_dry_run_skips_execution():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     flexmock(module.borgmatic.execute).should_receive('execute_command').never()
 
     recreate_arguments = flexmock(
@@ -57,10 +63,16 @@ def test_recreate_calls_borg_with_required_flags():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -86,10 +98,16 @@ def test_recreate_with_remote_path():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--remote-path', 'borg1', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--remote-path', 'borg1', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -115,10 +133,16 @@ def test_recreate_with_lock_wait():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--lock-wait', '5', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--lock-wait', '5', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -143,10 +167,16 @@ def test_recreate_with_log_info():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--info', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--info', '--repo', 'repo'))
 
     insert_logging_mock(logging.INFO)
 
@@ -173,10 +203,16 @@ def test_recreate_with_log_debug():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--debug', '--show-rc', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--debug', '--show-rc', '--repo', 'repo'))
     insert_logging_mock(logging.DEBUG)
 
     module.recreate_archive(
@@ -202,10 +238,16 @@ def test_recreate_with_log_json():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--log-json', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--log-json', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -229,12 +271,18 @@ def test_recreate_with_list_config_calls_borg_with_list_flag():
     flexmock(module.borgmatic.borg.create).should_receive('make_exclude_flags').and_return(())
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     flexmock(module).should_receive('make_list_filter_flags').and_return('AME+-')
     insert_execute_command_mock(
-        ('borg', 'recreate', '--list', '--filter', 'AME+-', 'repo::archive')
+        ('borg', 'recreate', '--list', '--filter', 'AME+-', '--repo', 'repo')
     )
 
     module.recreate_archive(
@@ -259,13 +307,19 @@ def test_recreate_with_patterns_from_flag():
     flexmock(module.borgmatic.borg.create).should_receive('make_exclude_flags').and_return(())
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     mock_patterns_file = flexmock(name='patterns_file')
     flexmock(module).should_receive('write_patterns_file').and_return(mock_patterns_file)
     insert_execute_command_mock(
-        ('borg', 'recreate', '--patterns-from', 'patterns_file', 'repo::archive')
+        ('borg', 'recreate', '--patterns-from', 'patterns_file', '--repo', 'repo')
     )
 
     module.recreate_archive(
@@ -290,11 +344,17 @@ def test_recreate_with_exclude_flags():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     flexmock(module).should_receive('make_exclude_flags').and_return(('--exclude', 'pattern'))
-    insert_execute_command_mock(('borg', 'recreate', '--exclude', 'pattern', 'repo::archive'))
+    insert_execute_command_mock(('borg', 'recreate', '--exclude', 'pattern', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -319,10 +379,16 @@ def test_recreate_with_target_flag():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--target', 'new-archive', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--target', 'new-archive', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -347,11 +413,17 @@ def test_recreate_with_comment_flag():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     insert_execute_command_mock(
-        ('borg', 'recreate', '--comment', shlex.quote('This is a test comment'), 'repo::archive')
+        ('borg', 'recreate', '--comment', shlex.quote('This is a test comment'), '--repo', 'repo')
     )
 
     module.recreate_archive(
@@ -377,11 +449,17 @@ def test_recreate_with_timestamp_flag():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     insert_execute_command_mock(
-        ('borg', 'recreate', '--timestamp', '2023-10-01T12:00:00', 'repo::archive')
+        ('borg', 'recreate', '--timestamp', '2023-10-01T12:00:00', '--repo', 'repo')
     )
 
     module.recreate_archive(
@@ -407,10 +485,16 @@ def test_recreate_with_compression_flag():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--compression', 'lz4', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--compression', 'lz4', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -435,11 +519,17 @@ def test_recreate_with_chunker_params_flag():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
     insert_execute_command_mock(
-        ('borg', 'recreate', '--chunker-params', '19,23,21,4095', 'repo::archive')
+        ('borg', 'recreate', '--chunker-params', '19,23,21,4095', '--repo', 'repo')
     )
 
     module.recreate_archive(
@@ -465,10 +555,16 @@ def test_recreate_with_recompress_flag():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--recompress', 'always', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--recompress', 'always', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -493,10 +589,16 @@ def test_recreate_with_match_archives_star():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -521,10 +623,16 @@ def test_recreate_with_match_archives_regex():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -549,10 +657,16 @@ def test_recreate_with_match_archives_shell():
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
     flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(())
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+    ).and_return(
+        (
+            '--repo',
+            'repo',
+        )
+    )
+    insert_execute_command_mock(('borg', 'recreate', '--repo', 'repo'))
 
     module.recreate_archive(
         repository='repo',
@@ -572,22 +686,58 @@ def test_recreate_with_match_archives_shell():
     )
 
 
-def test_recreate_with_glob_archives_flag():
+def test_recreate_with_match_archives_and_feature_available_calls_borg_with_match_archives():
     flexmock(module.borgmatic.borg.create).should_receive('make_exclude_flags').and_return(())
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
-    flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(
-        ('--glob-archives', 'foo-*')
+    flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').with_args(
+        'foo-*', None, '1.2.3'
+    ).and_return(('--match-archives', 'foo-*'))
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
+        ('--repo', 'repo')
+    )
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
+    insert_execute_command_mock(('borg', 'recreate', '--repo', 'repo', '--match-archives', 'foo-*'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive=None,
+        config={'match_archives': 'foo-*'},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives='foo-*',
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_archives_flag_and_feature_available_calls_borg_with_match_archives():
+    flexmock(module.borgmatic.borg.create).should_receive('make_exclude_flags').and_return(())
+    flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
+    flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
+    flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').with_args(
+        'archive', None, '1.2.3'
+    ).and_return(('--match-archives', 'archive'))
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
+        ('--repo', 'repo')
+    )
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
+    insert_execute_command_mock(
+        ('borg', 'recreate', '--repo', 'repo', '--match-archives', 'archive')
     )
-    flexmock(module.borgmatic.borg.flags).should_receive(
-        'make_repository_archive_flags'
-    ).and_return(('repo::archive',))
-    insert_execute_command_mock(('borg', 'recreate', '--glob-archives', 'foo-*', 'repo::archive'))
 
     module.recreate_archive(
         repository='repo',
         archive='archive',
-        config={},
+        config={'match_archives': 'foo-*'},
         local_borg_version='1.2.3',
         recreate_arguments=flexmock(
             list=None,
@@ -602,31 +752,59 @@ def test_recreate_with_glob_archives_flag():
     )
 
 
-def test_recreate_with_match_archives_flag():
+def test_recreate_with_match_archives_and_feature_not_available_calls_borg_without_match_archives():
     flexmock(module.borgmatic.borg.create).should_receive('make_exclude_flags').and_return(())
     flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
     flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
-    flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(
-        ('--match-archives', 'sh:foo-*')
+    flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').never()
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False)
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(
+        ('repo',)
+    )
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_archive_flags').never()
+    insert_execute_command_mock(('borg', 'recreate', 'repo'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive=None,
+        config={'match_archives': 'foo-*'},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives='foo-*',
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
     )
+
+
+def test_recreate_with_archives_flags_and_feature_not_available_calls_borg_with_combined_repo_and_archive():
+    flexmock(module.borgmatic.borg.create).should_receive('make_exclude_flags').and_return(())
+    flexmock(module.borgmatic.borg.create).should_receive('write_patterns_file').and_return(None)
+    flexmock(module.borgmatic.borg.create).should_receive('make_list_filter_flags').and_return('')
+    flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').never()
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False)
     flexmock(module.borgmatic.borg.flags).should_receive(
         'make_repository_archive_flags'
-    ).and_return(('--repo', 'repo', 'archive'))
-    insert_execute_command_mock(
-        ('borg', 'recreate', '--match-archives', 'sh:foo-*', '--repo', 'repo', 'archive')
-    )
+    ).and_return(('repo::archive',))
+    flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').never()
+    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
 
     module.recreate_archive(
         repository='repo',
         archive='archive',
-        config={},
-        local_borg_version='2.0.0b3',
+        config={'match_archives': 'foo-*'},
+        local_borg_version='1.2.3',
         recreate_arguments=flexmock(
             list=None,
             target=None,
             comment=None,
             timestamp=None,
-            match_archives='sh:foo-*',
+            match_archives='foo-*',
         ),
         global_arguments=flexmock(dry_run=False, log_json=False),
         local_path='borg',

+ 86 - 12
tests/unit/hooks/data_source/test_snapshot.py

@@ -1,34 +1,108 @@
+from flexmock import flexmock
+
 from borgmatic.borg.pattern import Pattern
 from borgmatic.hooks.data_source import snapshot as module
 
 
 def test_get_contained_patterns_without_candidates_returns_empty():
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=flexmock()))
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+
     assert module.get_contained_patterns('/mnt', {}) == ()
 
 
 def test_get_contained_patterns_with_self_candidate_returns_self():
-    candidates = {Pattern('/foo'), Pattern('/mnt'), Pattern('/bar')}
+    device = flexmock()
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device))
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    candidates = {
+        Pattern('/foo', device=device),
+        Pattern('/mnt', device=device),
+        Pattern('/bar', device=device),
+    }
 
-    assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt'),)
-    assert candidates == {Pattern('/foo'), Pattern('/bar')}
+    assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt', device=device),)
+    assert candidates == {Pattern('/foo', device=device), Pattern('/bar', device=device)}
 
 
 def test_get_contained_patterns_with_self_candidate_and_caret_prefix_returns_self():
-    candidates = {Pattern('^/foo'), Pattern('^/mnt'), Pattern('^/bar')}
+    device = flexmock()
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device))
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    candidates = {
+        Pattern('^/foo', device=device),
+        Pattern('^/mnt', device=device),
+        Pattern('^/bar', device=device),
+    }
 
-    assert module.get_contained_patterns('/mnt', candidates) == (Pattern('^/mnt'),)
-    assert candidates == {Pattern('^/foo'), Pattern('^/bar')}
+    assert module.get_contained_patterns('/mnt', candidates) == (Pattern('^/mnt', device=device),)
+    assert candidates == {Pattern('^/foo', device=device), Pattern('^/bar', device=device)}
 
 
 def test_get_contained_patterns_with_child_candidate_returns_child():
-    candidates = {Pattern('/foo'), Pattern('/mnt/subdir'), Pattern('/bar')}
+    device = flexmock()
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device))
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    candidates = {
+        Pattern('/foo', device=device),
+        Pattern('/mnt/subdir', device=device),
+        Pattern('/bar', device=device),
+    }
 
-    assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/subdir'),)
-    assert candidates == {Pattern('/foo'), Pattern('/bar')}
+    assert module.get_contained_patterns('/mnt', candidates) == (
+        Pattern('/mnt/subdir', device=device),
+    )
+    assert candidates == {Pattern('/foo', device=device), Pattern('/bar', device=device)}
 
 
 def test_get_contained_patterns_with_grandchild_candidate_returns_child():
-    candidates = {Pattern('/foo'), Pattern('/mnt/sub/dir'), Pattern('/bar')}
+    device = flexmock()
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device))
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    candidates = {
+        Pattern('/foo', device=device),
+        Pattern('/mnt/sub/dir', device=device),
+        Pattern('/bar', device=device),
+    }
+
+    assert module.get_contained_patterns('/mnt', candidates) == (
+        Pattern('/mnt/sub/dir', device=device),
+    )
+    assert candidates == {Pattern('/foo', device=device), Pattern('/bar', device=device)}
+
+
+def test_get_contained_patterns_ignores_child_candidate_on_another_device():
+    one_device = flexmock()
+    another_device = flexmock()
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=one_device))
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    candidates = {
+        Pattern('/foo', device=one_device),
+        Pattern('/mnt/subdir', device=another_device),
+        Pattern('/bar', device=one_device),
+    }
+
+    assert module.get_contained_patterns('/mnt', candidates) == ()
+    assert candidates == {
+        Pattern('/foo', device=one_device),
+        Pattern('/mnt/subdir', device=another_device),
+        Pattern('/bar', device=one_device),
+    }
+
+
+def test_get_contained_patterns_with_non_existent_parent_directory_ignores_child_candidate():
+    device = flexmock()
+    flexmock(module.os).should_receive('stat').and_return(flexmock(st_dev=device))
+    flexmock(module.os.path).should_receive('exists').and_return(False)
+    candidates = {
+        Pattern('/foo', device=device),
+        Pattern('/mnt/subdir', device=device),
+        Pattern('/bar', device=device),
+    }
 
-    assert module.get_contained_patterns('/mnt', candidates) == (Pattern('/mnt/sub/dir'),)
-    assert candidates == {Pattern('/foo'), Pattern('/bar')}
+    assert module.get_contained_patterns('/mnt', candidates) == ()
+    assert candidates == {
+        Pattern('/foo', device=device),
+        Pattern('/mnt/subdir', device=device),
+        Pattern('/bar', device=device),
+    }