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.
    contain multiple paths.
  * #1168: Fix for the "list", "info", and "delete" options in "extra_borg_options" being ignored
  * #1168: Fix for the "list", "info", and "delete" options in "extra_borg_options" being ignored
    when "--archive" is omitted with Borg 1.x.
    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
  * Add a "rename" option to "extra_borg_options" to support passing arbitrary flags to "borg
    rename".
    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)
         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:
         for pattern in subvolume.contained_patterns:
             snapshot_pattern = make_borg_snapshot_pattern(subvolume.path, pattern)
             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(
         borgmatic.hooks.data_source.config.inject_pattern(
             patterns, make_snapshot_exclude_pattern(subvolume.path)
             patterns, make_snapshot_exclude_pattern(subvolume.path)

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

@@ -1,3 +1,4 @@
+import contextlib
 import json
 import json
 import logging
 import logging
 import shutil
 import shutil
@@ -131,7 +132,23 @@ def inject_pattern(patterns, data_source_pattern):
     patterns.insert(0, 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,
     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
     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.
     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
     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
     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.
     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:
     if data_source_pattern.type == borgmatic.borg.pattern.Pattern_type.ROOT:
         patterns.insert(
         patterns.insert(
-            index + 1,
+            last_contained_pattern_index + 1,
             borgmatic.borg.pattern.Pattern(
             borgmatic.borg.pattern.Pattern(
                 path=data_source_pattern.path,
                 path=data_source_pattern.path,
                 type=borgmatic.borg.pattern.Pattern_type.INCLUDE,
                 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,
             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:
         for pattern in logical_volume.contained_patterns:
             snapshot_pattern = make_borg_snapshot_pattern(
             snapshot_pattern = make_borg_snapshot_pattern(
                 pattern,
                 pattern,
@@ -276,7 +280,9 @@ def dump_data_sources(
                 normalized_runtime_directory,
                 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 []
     return []
 
 

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

@@ -300,6 +300,10 @@ def dump_data_sources(
             snapshot_mount_path,
             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:
         for pattern in dataset.contained_patterns:
             snapshot_pattern = make_borg_snapshot_pattern(
             snapshot_pattern = make_borg_snapshot_pattern(
                 pattern,
                 pattern,
@@ -307,7 +311,9 @@ def dump_data_sources(
                 normalized_runtime_directory,
                 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 []
     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():
 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': {}}
     config = {'btrfs': {}}
     flexmock(module).should_receive('get_subvolumes').and_return(
     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'),)),
             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('/foo'),
         Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/./mnt/subvol1'),
         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/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('/mnt/subvol2/.borgmatic-snapshot-1234/./mnt/subvol2', Pattern_type.INCLUDE),
         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():
 def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
     config = {'lvm': {}}
     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 = (
     logical_volumes = (
         module.Logical_volume(
         module.Logical_volume(
             name='lvolume1',
             name='lvolume1',
             device_path='/dev/lvolume1',
             device_path='/dev/lvolume1',
             mount_point='/mnt/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(
         module.Logical_volume(
             name='lvolume2',
             name='lvolume2',
@@ -75,6 +82,9 @@ def test_dump_data_sources_snapshots_and_mounts_and_updates_patterns():
 
 
     assert patterns == [
     assert patterns == [
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir'),
         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/lvolume1/subdir', Pattern_type.INCLUDE),
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'),
         Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2', Pattern_type.INCLUDE),
         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',
         '/mnt/subvol2',
         object,
         object,
     ).and_return(Pattern('/mnt/subvol2/.borgmatic-snapshot-1234/mnt/subvol2'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/subvol1'),
         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',
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
@@ -465,6 +469,7 @@ def test_dump_data_sources_snapshots_each_subvolume_and_replaces_patterns():
             '/mnt/subvol2/.borgmatic-snapshot-1234/mnt/subvol2',
             '/mnt/subvol2/.borgmatic-snapshot-1234/mnt/subvol2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
         object,
         object,
@@ -527,6 +532,9 @@ def test_dump_data_sources_uses_custom_btrfs_command_in_commands():
         '/mnt/subvol1',
         '/mnt/subvol1',
         object,
         object,
     ).and_return(Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/subvol1'),
         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',
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
         object,
         object,
@@ -594,6 +603,9 @@ def test_dump_data_sources_with_findmnt_command_warns():
         '/mnt/subvol1',
         '/mnt/subvol1',
         object,
         object,
     ).and_return(Pattern('/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/subvol1'),
         Pattern('/mnt/subvol1'),
@@ -601,6 +613,7 @@ def test_dump_data_sources_with_findmnt_command_warns():
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             '/mnt/subvol1/.borgmatic-snapshot-1234/mnt/subvol1',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_pattern').with_args(
         object,
         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('snapshot_subvolume').never()
     flexmock(module).should_receive('make_snapshot_exclude_pattern').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('replace_pattern').never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_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('make_snapshot_path').never()
     flexmock(module).should_receive('snapshot_subvolume').never()
     flexmock(module).should_receive('snapshot_subvolume').never()
     flexmock(module).should_receive('make_snapshot_exclude_pattern').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('replace_pattern').never()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('inject_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():
 def test_replace_pattern_swaps_out_pattern_in_place():
     patterns = [
     patterns = [
         module.borgmatic.borg.pattern.Pattern('/etc'),
         module.borgmatic.borg.pattern.Pattern('/etc'),
@@ -217,6 +263,7 @@ def test_replace_pattern_swaps_out_pattern_in_place():
             '/foo/bar',
             '/foo/bar',
             type=module.borgmatic.borg.pattern.Pattern_type.EXCLUDE,
             type=module.borgmatic.borg.pattern.Pattern_type.EXCLUDE,
         ),
         ),
+        0,
     )
     )
 
 
     assert patterns == [
     assert patterns == [
@@ -243,6 +290,7 @@ def test_replace_pattern_with_unknown_pattern_falls_back_to_injecting():
         patterns,
         patterns,
         module.borgmatic.borg.pattern.Pattern('/unknown'),
         module.borgmatic.borg.pattern.Pattern('/unknown'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
         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('/etc'),
         module.borgmatic.borg.pattern.Pattern('/var'),
         module.borgmatic.borg.pattern.Pattern('/var'),
         module.borgmatic.borg.pattern.Pattern('/lib'),
         module.borgmatic.borg.pattern.Pattern('/lib'),
+        module.borgmatic.borg.pattern.Pattern('/run'),
     ]
     ]
 
 
     module.replace_pattern(
     module.replace_pattern(
         patterns,
         patterns,
         module.borgmatic.borg.pattern.Pattern('/var'),
         module.borgmatic.borg.pattern.Pattern('/var'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
+        2,
     )
     )
 
 
     assert patterns == [
     assert patterns == [
         module.borgmatic.borg.pattern.Pattern('/etc'),
         module.borgmatic.borg.pattern.Pattern('/etc'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
         module.borgmatic.borg.pattern.Pattern('/foo/bar'),
+        module.borgmatic.borg.pattern.Pattern('/lib'),
         module.borgmatic.borg.pattern.Pattern(
         module.borgmatic.borg.pattern.Pattern(
             '/foo/bar',
             '/foo/bar',
             type=module.borgmatic.borg.pattern.Pattern_type.INCLUDE,
             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],
         logical_volumes[1],
         '/run/borgmatic',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/lvolume1/subdir'),
         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',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
@@ -375,6 +379,7 @@ def test_dump_data_sources_snapshots_and_mounts_and_replaces_patterns():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
 
 
     assert (
     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('get_logical_volumes').and_return(())
     flexmock(module).should_receive('snapshot_logical_volume').never()
     flexmock(module).should_receive('snapshot_logical_volume').never()
     flexmock(module).should_receive('mount_snapshot').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()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
 
 
     assert (
     assert (
@@ -477,6 +485,9 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
         logical_volumes[1],
         logical_volumes[1],
         '/run/borgmatic',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/lvolume1/subdir'),
         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',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
@@ -492,6 +504,7 @@ def test_dump_data_sources_uses_snapshot_size_for_snapshot():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
 
 
     assert (
     assert (
@@ -580,6 +593,9 @@ def test_dump_data_sources_uses_custom_commands():
         logical_volumes[1],
         logical_volumes[1],
         '/run/borgmatic',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/lvolume1/subdir'),
         Pattern('/mnt/lvolume1/subdir'),
@@ -587,6 +603,7 @@ def test_dump_data_sources_uses_custom_commands():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
@@ -595,6 +612,7 @@ def test_dump_data_sources_uses_custom_commands():
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
 
 
     assert (
     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('snapshot_logical_volume').never()
     flexmock(module).should_receive('get_snapshots').never()
     flexmock(module).should_receive('get_snapshots').never()
     flexmock(module).should_receive('mount_snapshot').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()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
 
 
     assert (
     assert (
@@ -714,6 +735,9 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
         logical_volumes[1],
         logical_volumes[1],
         '/run/borgmatic',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/lvolume1/subdir'),
         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',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume1/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
@@ -729,6 +754,7 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             '/run/borgmatic/lvm_snapshots/b33f/./mnt/lvolume2',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
 
 
     assert (
     assert (
@@ -785,6 +811,9 @@ def test_dump_data_sources_with_missing_snapshot_errors():
         snapshot_name='lvolume2_borgmatic-1234',
         snapshot_name='lvolume2_borgmatic-1234',
     ).never()
     ).never()
     flexmock(module).should_receive('mount_snapshot').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()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
 
 
     with pytest.raises(ValueError):
     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',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     patterns = [Pattern('/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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/dataset/subdir'),
         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',
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
 
 
     assert (
     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.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_dataset').never()
     flexmock(module).should_receive('snapshot_dataset').never()
     flexmock(module).should_receive('mount_snapshot').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()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
     patterns = [Pattern('/mnt/dataset')]
     patterns = [Pattern('/mnt/dataset')]
 
 
@@ -400,6 +407,9 @@ def test_dump_data_sources_uses_custom_commands():
         dataset,
         dataset,
         '/run/borgmatic',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/dataset/subdir'),
         Pattern('/mnt/dataset/subdir'),
@@ -407,6 +417,7 @@ def test_dump_data_sources_uses_custom_commands():
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     patterns = [Pattern('/mnt/dataset/subdir')]
     patterns = [Pattern('/mnt/dataset/subdir')]
     hook_config = {
     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.os).should_receive('getpid').and_return(1234)
     flexmock(module).should_receive('snapshot_dataset').never()
     flexmock(module).should_receive('snapshot_dataset').never()
     flexmock(module).should_receive('mount_snapshot').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()
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').never()
     patterns = [Pattern('/mnt/dataset')]
     patterns = [Pattern('/mnt/dataset')]
 
 
@@ -480,6 +494,9 @@ def test_dump_data_sources_ignores_mismatch_between_given_patterns_and_contained
         dataset,
         dataset,
         '/run/borgmatic',
         '/run/borgmatic',
     ).and_return(Pattern('/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir'))
     ).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(
     flexmock(module.borgmatic.hooks.data_source.config).should_receive('replace_pattern').with_args(
         object,
         object,
         Pattern('/mnt/dataset/subdir'),
         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',
             '/run/borgmatic/zfs_snapshots/b33f/./mnt/dataset/subdir',
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
             source=module.borgmatic.borg.pattern.Pattern_source.HOOK,
         ),
         ),
+        0,
     ).once()
     ).once()
     patterns = [Pattern('/hmm')]
     patterns = [Pattern('/hmm')]