Browse Source

Fix for a regression in the ZFS, LVM, and Btrfs hooks in which partial excludes of snapshot paths were ignored (#1169).

Dan Helfman 1 week ago
parent
commit
0f7ebcb4b7

+ 2 - 0
NEWS

@@ -3,6 +3,8 @@
    contain multiple paths.
  * #1168: Fix for the "list", "info", and "delete" options in "extra_borg_options" being ignored
    when "--archive" is omitted with Borg 1.x.
+ * #1169: Fix for a regression in the ZFS, LVM, and Btrfs hooks in which partial excludes of
+   snapshot paths were ignored.
  * Add a "rename" option to "extra_borg_options" to support passing arbitrary flags to "borg
    rename".
 

+ 7 - 1
borgmatic/hooks/data_source/btrfs.py

@@ -329,9 +329,15 @@ def dump_data_sources(
 
         snapshot_subvolume(btrfs_command, subvolume.path, snapshot_path)
 
+        last_contained_pattern_index = borgmatic.hooks.data_source.config.get_last_pattern_index(
+            patterns, subvolume.contained_patterns
+        )
+
         for pattern in subvolume.contained_patterns:
             snapshot_pattern = make_borg_snapshot_pattern(subvolume.path, pattern)
-            borgmatic.hooks.data_source.config.replace_pattern(patterns, pattern, snapshot_pattern)
+            borgmatic.hooks.data_source.config.replace_pattern(
+                patterns, pattern, snapshot_pattern, last_contained_pattern_index
+            )
 
         borgmatic.hooks.data_source.config.inject_pattern(
             patterns, make_snapshot_exclude_pattern(subvolume.path)

+ 45 - 4
borgmatic/hooks/data_source/config.py

@@ -1,3 +1,4 @@
+import contextlib
 import json
 import logging
 import shutil
@@ -131,7 +132,23 @@ def inject_pattern(patterns, data_source_pattern):
     patterns.insert(0, data_source_pattern)
 
 
-def replace_pattern(patterns, pattern_to_replace, data_source_pattern):
+def get_last_pattern_index(patterns, patterns_subset):
+    '''
+    Given a sequence of all patterns and a subset of those patterns, find each subset pattern in the
+    all patterns sequence and return the highest (last) index.
+    '''
+    last_pattern_index = 0
+
+    for pattern in patterns_subset:
+        with contextlib.suppress(ValueError):
+            last_pattern_index = max(patterns.index(pattern), last_pattern_index)
+
+    return last_pattern_index
+
+
+def replace_pattern(
+    patterns, pattern_to_replace, data_source_pattern, last_contained_pattern_index
+):
     '''
     Given a list of borgmatic.borg.pattern.Pattern instances representing the configured patterns,
     replace the given pattern with the given data source pattern. The idea is that borgmatic is
@@ -139,8 +156,32 @@ def replace_pattern(patterns, pattern_to_replace, data_source_pattern):
     that the hook's data gets included in the backup.
 
     As part of this replacement, if the data source pattern is a root pattern, also insert an
-    "include" version of the given root pattern right after the replaced pattern, in an attempt to
-    preempt any of the user's configured exclude patterns that may follow.
+    "include" version of the given root pattern right after the given last contained pattern index
+    in an attempt to preempt any of the user's configured global exclude patterns that may follow.
+    But we don't want to preempt any intentional partial excludes of the data source pattern itself,
+    which is why the include goes after the last contained pattern index.
+
+    For instance, let's say that the patterns are effectively:
+
+       R /foo
+       R /bar
+       - /bar/.cache
+       R /baz
+       - **
+
+    ... and "R /bar" is the pattern to replace, data source pattern is "R /bar/snapshot", and the
+    last contained pattern index is 2 (corresponding to "- /bar/.cache"). The resulting patterns
+    after calling this function would be:
+
+       R /foo
+       R /bar/snapshot
+       - /bar/snapshot/.cache
+       + /bar/snapshot
+       R /baz
+       - **
+
+    Note that the positioning of "+ /bar/snapshot" means that it overrides the "- **" global exclude
+    but not the "- /bar/snapshot/.cache" contained pattern exclude.
 
     If the pattern to replace can't be found in the given patterns, then just inject the data source
     pattern at the start of the list.
@@ -156,7 +197,7 @@ def replace_pattern(patterns, pattern_to_replace, data_source_pattern):
 
     if data_source_pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT:
         patterns.insert(
-            index + 1,
+            last_contained_pattern_index + 1,
             borgmatic.borg.pattern.Pattern(
                 path=data_source_pattern.path,
                 type=borgmatic.borg.pattern.Pattern_type.INCLUDE,

+ 7 - 1
borgmatic/hooks/data_source/lvm.py

@@ -269,6 +269,10 @@ def dump_data_sources(
             snapshot_mount_path,
         )
 
+        last_contained_pattern_index = borgmatic.hooks.data_source.config.get_last_pattern_index(
+            patterns, logical_volume.contained_patterns
+        )
+
         for pattern in logical_volume.contained_patterns:
             snapshot_pattern = make_borg_snapshot_pattern(
                 pattern,
@@ -276,7 +280,9 @@ def dump_data_sources(
                 normalized_runtime_directory,
             )
 
-            borgmatic.hooks.data_source.config.replace_pattern(patterns, pattern, snapshot_pattern)
+            borgmatic.hooks.data_source.config.replace_pattern(
+                patterns, pattern, snapshot_pattern, last_contained_pattern_index
+            )
 
     return []
 

+ 7 - 1
borgmatic/hooks/data_source/zfs.py

@@ -300,6 +300,10 @@ def dump_data_sources(
             snapshot_mount_path,
         )
 
+        last_contained_pattern_index = borgmatic.hooks.data_source.config.get_last_pattern_index(
+            patterns, dataset.contained_patterns
+        )
+
         for pattern in dataset.contained_patterns:
             snapshot_pattern = make_borg_snapshot_pattern(
                 pattern,
@@ -307,7 +311,9 @@ def dump_data_sources(
                 normalized_runtime_directory,
             )
 
-            borgmatic.hooks.data_source.config.replace_pattern(patterns, pattern, snapshot_pattern)
+            borgmatic.hooks.data_source.config.replace_pattern(
+                patterns, pattern, snapshot_pattern, last_contained_pattern_index
+            )
 
     return []
 

+ 14 - 2
tests/integration/hooks/data_source/test_btrfs.py

@@ -5,11 +5,22 @@ from borgmatic.hooks.data_source import btrfs as module
 
 
 def test_dump_data_sources_snapshots_each_subvolume_and_updates_patterns():
-    patterns = [Pattern('/foo'), Pattern('/mnt/subvol1'), Pattern('/mnt/subvol2')]
+    patterns = [
+        Pattern('/foo'),
+        Pattern('/mnt/subvol1'),
+        Pattern('/mnt/subvol1/.cache', Pattern_type.EXCLUDE),
+        Pattern('/mnt/subvol2'),
+    ]
     config = {'btrfs': {}}
     flexmock(module).should_receive('get_subvolumes').and_return(
         (
-            module.Subvolume('/mnt/subvol1', contained_patterns=(Pattern('/mnt/subvol1'),)),
+            module.Subvolume(
+                '/mnt/subvol1',
+                contained_patterns=(
+                    Pattern('/mnt/subvol1'),
+                    Pattern('/mnt/subvol1/.cache', Pattern_type.EXCLUDE),
+                ),
+            ),
             module.Subvolume('/mnt/subvol2', contained_patterns=(Pattern('/mnt/subvol2'),)),
         ),
     )
@@ -50,6 +61,7 @@ def test_dump_data_sources_snapshots_each_subvolume_and_updates_patterns():
         ),
         Pattern('/foo'),
         Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/./mnt/subvol1'),
+        Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/./mnt/subvol1/.cache', Pattern_type.EXCLUDE),
         Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/./mnt/subvol1', Pattern_type.INCLUDE),
         Pattern('/mnt/subvol2/.borgmatic-snapshot-1234/./mnt/subvol2'),
         Pattern('/mnt/subvol2/.borgmatic-snapshot-1234/./mnt/subvol2', Pattern_type.INCLUDE),

+ 12 - 2
tests/integration/hooks/data_source/test_lvm.py

@@ -6,13 +6,20 @@ from borgmatic.hooks.data_source import lvm as module
 
 def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
     config = {'lvm': {}}
-    patterns = [Pattern('/mnt/lvolume1/subdir'), Pattern('/mnt/lvolume2')]
+    patterns = [
+        Pattern('/mnt/lvolume1/subdir'),
+        Pattern('/mnt/lvolume1/subdir/.cache', Pattern_type.EXCLUDE),
+        Pattern('/mnt/lvolume2'),
+    ]
     logical_volumes = (
         module.Logical_volume(
             name='lvolume1',
             device_path='/dev/lvolume1',
             mount_point='/mnt/lvolume1',
-            contained_patterns=(Pattern('/mnt/lvolume1/subdir'),),
+            contained_patterns=(
+                Pattern('/mnt/lvolume1/subdir'),
+                Pattern('/mnt/lvolume1/subdir/.cache', Pattern_type.EXCLUDE),
+            ),
         ),
         module.Logical_volume(
             name='lvolume2',
@@ -75,6 +82,9 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
 
     assert patterns == [
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'),
+        Pattern(
+            '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir/.cache', Pattern_type.EXCLUDE
+        ),
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir', Pattern_type.INCLUDE),
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2', Pattern_type.INCLUDE),

+ 19 - 0
tests/unit/hooks/data_source/test_btrfs.py

@@ -450,6 +450,9 @@ def test_dump_data_sources_snapshots_each_subvolume_and_replaces_patterns():
         '/mnt/subvol2',
         object,
     ).and_return(Pattern('/mnt/subvol2/.borgmatic-snapshot-1234/mnt/subvol2'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/subvol1'),
@@ -457,6 +460,7 @@ def test_dump_data_sources_snapshots_each_subvolume_and_replaces_patterns():
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
@@ -465,6 +469,7 @@ def test_dump_data_sources_snapshots_each_subvolume_and_replaces_patterns():
             '/mnt/subvol2/.borgmatic-snapshot-1234/mnt/subvol2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
         object,
@@ -527,6 +532,9 @@ def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
         '/mnt/subvol1',
         object,
     ).and_return(Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/subvol1'),
@@ -534,6 +542,7 @@ def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
         object,
@@ -594,6 +603,9 @@ def test_dump_data_sources_with_findmnt_command_warns():
         '/mnt/subvol1',
         object,
     ).and_return(Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/subvol1'),
@@ -601,6 +613,7 @@ def test_dump_data_sources_with_findmnt_command_warns():
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
         object,
@@ -641,6 +654,9 @@ def test_dump_data_sources_with_dry_run_skips_snapshot_and_patterns_update():
     )
     flexmock(module).should_receive('snapshot_subvolume').never()
     flexmock(module).should_receive('make_snapshot_exclude_pattern').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').never()
 
@@ -666,6 +682,9 @@ def test_dump_data_sources_without_matching_subvolumes_skips_snapshot_and_patter
     flexmock(module).should_receive('make_snapshot_path').never()
     flexmock(module).should_receive('snapshot_subvolume').never()
     flexmock(module).should_receive('make_snapshot_exclude_pattern').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').never()
 

+ 52 - 1
tests/unit/hooks/data_source/test_config.py

@@ -203,6 +203,52 @@ def test_inject_pattern_with_root_pattern_prepends_it_along_with_corresponding_i
     ]
 
 
+def test_get_last_pattern_index_with_ordered_subset_patterns_finds_last_one():
+    patterns = [
+        module.borgmatic.borg.pattern.Pattern('/foo'),
+        module.borgmatic.borg.pattern.Pattern('/bar'),
+        module.borgmatic.borg.pattern.Pattern('/baz'),
+        module.borgmatic.borg.pattern.Pattern('/quux'),
+    ]
+    patterns_subset = [
+        module.borgmatic.borg.pattern.Pattern('/bar'),
+        module.borgmatic.borg.pattern.Pattern('/baz'),
+    ]
+
+    assert module.get_last_pattern_index(patterns, patterns_subset) == 2
+
+
+def test_get_last_pattern_index_with_unordered_subset_patterns_finds_last_one():
+    patterns = [
+        module.borgmatic.borg.pattern.Pattern('/foo'),
+        module.borgmatic.borg.pattern.Pattern('/bar'),
+        module.borgmatic.borg.pattern.Pattern('/baz'),
+        module.borgmatic.borg.pattern.Pattern('/quux'),
+    ]
+    patterns_subset = [
+        module.borgmatic.borg.pattern.Pattern('/baz'),
+        module.borgmatic.borg.pattern.Pattern('/bar'),
+    ]
+
+    assert module.get_last_pattern_index(patterns, patterns_subset) == 2
+
+
+def test_get_last_pattern_index_with_unknown_subset_patterns_skips_it():
+    patterns = [
+        module.borgmatic.borg.pattern.Pattern('/foo'),
+        module.borgmatic.borg.pattern.Pattern('/bar'),
+        module.borgmatic.borg.pattern.Pattern('/baz'),
+        module.borgmatic.borg.pattern.Pattern('/quux'),
+    ]
+    patterns_subset = [
+        module.borgmatic.borg.pattern.Pattern('/baz'),
+        module.borgmatic.borg.pattern.Pattern('/unknown'),
+        module.borgmatic.borg.pattern.Pattern('/bar'),
+    ]
+
+    assert module.get_last_pattern_index(patterns, patterns_subset) == 2
+
+
 def test_replace_pattern_swaps_out_pattern_in_place():
     patterns = [
         module.borgmatic.borg.pattern.Pattern('/etc'),
@@ -217,6 +263,7 @@ def test_replace_pattern_swaps_out_pattern_in_place():
             '/foo/bar',
             type=module.borgmatic.borg.pattern.Pattern_type.EXCLUDE,
         ),
+        0,
     )
 
     assert patterns == [
@@ -243,6 +290,7 @@ def test_replace_pattern_with_unknown_pattern_falls_back_to_injecting():
         patterns,
         module.borgmatic.borg.pattern.Pattern('/unknown'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
+        0,
     )
 
 
@@ -251,20 +299,23 @@ def test_replace_pattern_with_root_pattern_swaps_it_in_along_with_corresponding_
         module.borgmatic.borg.pattern.Pattern('/etc'),
         module.borgmatic.borg.pattern.Pattern('/var'),
         module.borgmatic.borg.pattern.Pattern('/lib'),
+        module.borgmatic.borg.pattern.Pattern('/run'),
     ]
 
     module.replace_pattern(
         patterns,
         module.borgmatic.borg.pattern.Pattern('/var'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
+        2,
     )
 
     assert patterns == [
         module.borgmatic.borg.pattern.Pattern('/etc'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
+        module.borgmatic.borg.pattern.Pattern('/lib'),
         module.borgmatic.borg.pattern.Pattern(
             '/foo/bar',
             type=module.borgmatic.borg.pattern.Pattern_type.INCLUDE,
         ),
-        module.borgmatic.borg.pattern.Pattern('/lib'),
+        module.borgmatic.borg.pattern.Pattern('/run'),
     ]

+ 29 - 0
tests/unit/hooks/data_source/test_lvm.py

@@ -360,6 +360,9 @@ def test_dump_data_sources_snapshots_and_mounts_and_replaces_patterns():
         logical_volumes[1],
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/lvolume1/subdir'),
@@ -367,6 +370,7 @@ def test_dump_data_sources_snapshots_and_mounts_and_replaces_patterns():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
@@ -375,6 +379,7 @@ def test_dump_data_sources_snapshots_and_mounts_and_replaces_patterns():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
 
     assert (
@@ -396,6 +401,9 @@ def test_dump_data_sources_with_no_logical_volumes_skips_snapshots():
     flexmock(module).should_receive('get_logical_volumes').and_return(())
     flexmock(module).should_receive('snapshot_logical_volume').never()
     flexmock(module).should_receive('mount_snapshot').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
 
     assert (
@@ -477,6 +485,9 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
         logical_volumes[1],
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/lvolume1/subdir'),
@@ -484,6 +495,7 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
@@ -492,6 +504,7 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
 
     assert (
@@ -580,6 +593,9 @@ def test_dump_data_sources_uses_custom_commands():
         logical_volumes[1],
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/lvolume1/subdir'),
@@ -587,6 +603,7 @@ def test_dump_data_sources_uses_custom_commands():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
@@ -595,6 +612,7 @@ def test_dump_data_sources_uses_custom_commands():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
 
     assert (
@@ -633,6 +651,9 @@ def test_dump_data_sources_with_dry_run_skips_snapshots_and_does_not_touch_patte
     flexmock(module).should_receive('snapshot_logical_volume').never()
     flexmock(module).should_receive('get_snapshots').never()
     flexmock(module).should_receive('mount_snapshot').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
 
     assert (
@@ -714,6 +735,9 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
         logical_volumes[1],
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/lvolume1/subdir'),
@@ -721,6 +745,7 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
@@ -729,6 +754,7 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
 
     assert (
@@ -785,6 +811,9 @@ def test_dump_data_sources_with_missing_snapshot_errors():
         snapshot_name='lvolume2_borgmatic-1234',
     ).never()
     flexmock(module).should_receive('mount_snapshot').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
 
     with pytest.raises(ValueError):

+ 18 - 0
tests/unit/hooks/data_source/test_zfs.py

@@ -328,6 +328,9 @@ def test_dump_data_sources_snapshots_and_mounts_and_replaces_patterns():
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     patterns = [Pattern('/mnt/dataset/subdir')]
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/dataset/subdir'),
@@ -335,6 +338,7 @@ def test_dump_data_sources_snapshots_and_mounts_and_replaces_patterns():
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
 
     assert (
@@ -355,6 +359,9 @@ def test_dump_data_sources_with_no_datasets_skips_snapshots():
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_dataset').never()
     flexmock(module).should_receive('mount_snapshot').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
     patterns = [Pattern('/mnt/dataset')]
 
@@ -400,6 +407,9 @@ def test_dump_data_sources_uses_custom_commands():
         dataset,
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/dataset/subdir'),
@@ -407,6 +417,7 @@ def test_dump_data_sources_uses_custom_commands():
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     patterns = [Pattern('/mnt/dataset/subdir')]
     hook_config = {
@@ -437,6 +448,9 @@ def test_dump_data_sources_with_dry_run_skips_commands_and_does_not_touch_patter
     flexmock(module.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_dataset').never()
     flexmock(module).should_receive('mount_snapshot').never()
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
     patterns = [Pattern('/mnt/dataset')]
 
@@ -480,6 +494,9 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
         dataset,
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
+    flexmock(module.borgmatic.hooks.data_source.config).should_receive(
+        'get_last_pattern_index'
+    ).and_return(0)
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         Pattern('/mnt/dataset/subdir'),
@@ -487,6 +504,7 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
+        0,
     ).once()
     patterns = [Pattern('/hmm')]