Browse Source

Add a recreate action (#1030).

Reviewed-on: https://projects.torsion.org/borgmatic-collective/borgmatic/pulls/1030
Dan Helfman 2 months ago
parent
commit
59f9d56aae

+ 53 - 0
borgmatic/actions/recreate.py

@@ -0,0 +1,53 @@
+import logging
+
+import borgmatic.borg.recreate
+import borgmatic.config.validate
+from borgmatic.actions.create import collect_patterns, process_patterns
+
+logger = logging.getLogger(__name__)
+
+
+def run_recreate(
+    repository,
+    config,
+    local_borg_version,
+    recreate_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "recreate" action for the given repository.
+    '''
+    if recreate_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, recreate_arguments.repository
+    ):
+        if recreate_arguments.archive:
+            logger.answer(f'Recreating archive {recreate_arguments.archive}')
+        else:
+            logger.answer('Recreating repository')
+
+        # Collect and process patterns.
+        processed_patterns = process_patterns(
+            collect_patterns(config), borgmatic.config.paths.get_working_directory(config)
+        )
+
+        borgmatic.borg.recreate.recreate_archive(
+            repository['path'],
+            borgmatic.borg.repo_list.resolve_archive_name(
+                repository['path'],
+                recreate_arguments.archive,
+                config,
+                local_borg_version,
+                global_arguments,
+                local_path,
+                remote_path,
+            ),
+            config,
+            local_borg_version,
+            recreate_arguments,
+            global_arguments,
+            local_path=local_path,
+            remote_path=remote_path,
+            patterns=processed_patterns,
+        )

+ 100 - 0
borgmatic/borg/recreate.py

@@ -0,0 +1,100 @@
+import logging
+import shlex
+
+import borgmatic.borg.environment
+import borgmatic.config.paths
+import borgmatic.execute
+from borgmatic.borg import flags
+from borgmatic.borg.create import make_exclude_flags, make_list_filter_flags, write_patterns_file
+
+logger = logging.getLogger(__name__)
+
+
+def recreate_archive(
+    repository,
+    archive,
+    config,
+    local_borg_version,
+    recreate_arguments,
+    global_arguments,
+    local_path,
+    remote_path=None,
+    patterns=None,
+):
+    '''
+    Given a local or remote repository path, an archive name, a configuration dict,
+    the local Borg version string, an argparse.Namespace of recreate arguments,
+    an argparse.Namespace of global arguments, optional local and remote Borg paths.
+
+    Executes the recreate command with the given arguments.
+    '''
+
+    lock_wait = config.get('lock_wait', None)
+    exclude_flags = make_exclude_flags(config)
+    compression = config.get('compression', None)
+    chunker_params = config.get('chunker_params', None)
+    # Available recompress MODES: 'if-different' (default), 'always', 'never'
+    recompress = config.get('recompress', None)
+
+    # Write patterns to a temporary file and use that file with --patterns-from.
+    patterns_file = write_patterns_file(
+        patterns, borgmatic.config.paths.get_working_directory(config)
+    )
+
+    recreate_command = (
+        (local_path, 'recreate')
+        + (('--remote-path', remote_path) if remote_path else ())
+        + (('--log-json',) if global_arguments.log_json else ())
+        + (('--lock-wait', str(lock_wait)) if lock_wait is not None else ())
+        + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
+        + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())
+        + (('--patterns-from', patterns_file.name) if patterns_file else ())
+        + (
+            (
+                '--list',
+                '--filter',
+                make_list_filter_flags(local_borg_version, global_arguments.dry_run),
+            )
+            if recreate_arguments.list
+            else ()
+        )
+        # Flag --target works only for a single archive
+        + (('--target', recreate_arguments.target) if recreate_arguments.target and archive else ())
+        + (
+            ('--comment', shlex.quote(recreate_arguments.comment))
+            if recreate_arguments.comment
+            else ()
+        )
+        + (('--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(
+                recreate_arguments.match_archives or archive or config.get('match_archives'),
+                config.get('archive_name_format'),
+                local_borg_version,
+            )
+            if recreate_arguments.match_archives
+            else ()
+        )
+        + (('--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)
+        )
+    )
+
+    if global_arguments.dry_run:
+        logger.info('Skipping the archive recreation (dry run)')
+        return
+
+    borgmatic.execute.execute_command(
+        full_command=recreate_command,
+        output_log_level=logging.INFO,
+        environment=borgmatic.borg.environment.make_environment(config),
+        working_directory=borgmatic.config.paths.get_working_directory(config),
+        borg_local_path=local_path,
+        borg_exit_codes=config.get('borg_exit_codes'),
+    )

+ 47 - 0
borgmatic/commands/arguments.py

@@ -27,6 +27,7 @@ ACTION_ALIASES = {
     'break-lock': [],
     'key': [],
     'borg': [],
+    'recreate': [],
 }
 
 
@@ -1545,6 +1546,52 @@ def make_parsers():
     )
     borg_group.add_argument('-h', '--help', action='help', help='Show this help message and exit')
 
+    recreate_parser = action_parsers.add_parser(
+        'recreate',
+        aliases=ACTION_ALIASES['recreate'],
+        help='Recreate an archive in a repository',
+        description='Recreate an archive in a repository',
+        add_help=False,
+    )
+    recreate_group = recreate_parser.add_argument_group('recreate arguments')
+    recreate_group.add_argument(
+        '--repository',
+        help='Path of repository containing archive to recreate, defaults to the configured repository if there is only one, quoted globs supported',
+    )
+    recreate_group.add_argument(
+        '--archive',
+        help='Archive name, hash, or series to recreate',
+    )
+    recreate_group.add_argument(
+        '--list', dest='list', action='store_true', help='Show per-file details'
+    )
+    recreate_group.add_argument(
+        '--target',
+        metavar='TARGET',
+        help='Create a new archive from the specified archive (via --archive), without replacing it',
+    )
+    recreate_group.add_argument(
+        '--comment',
+        metavar='COMMENT',
+        help='Add a comment text to the archive or, if an archive is not provided, to all matching archives',
+    )
+    recreate_group.add_argument(
+        '--timestamp',
+        metavar='TIMESTAMP',
+        help='Manually override the archive creation date/time (UTC)',
+    )
+    recreate_group.add_argument(
+        '-a',
+        '--match-archives',
+        '--glob-archives',
+        dest='match_archives',
+        metavar='PATTERN',
+        help='Only consider archive names, hashes, or series matching this pattern',
+    )
+    recreate_group.add_argument(
+        '-h', '--help', action='help', help='Show this help message and exit'
+    )
+
     return global_parser, action_parsers, global_plus_action_parser
 
 

+ 11 - 0
borgmatic/commands/borgmatic.py

@@ -26,6 +26,7 @@ import borgmatic.actions.info
 import borgmatic.actions.list
 import borgmatic.actions.mount
 import borgmatic.actions.prune
+import borgmatic.actions.recreate
 import borgmatic.actions.repo_create
 import borgmatic.actions.repo_delete
 import borgmatic.actions.repo_info
@@ -397,6 +398,16 @@ def run_actions(
                         local_path,
                         remote_path,
                     )
+                elif action_name == 'recreate' and action_name not in skip_actions:
+                    borgmatic.actions.recreate.run_recreate(
+                        repository,
+                        config,
+                        local_borg_version,
+                        action_arguments,
+                        global_arguments,
+                        local_path,
+                        remote_path,
+                    )
                 elif action_name == 'prune' and action_name not in skip_actions:
                     borgmatic.actions.prune.run_prune(
                         config_filename,

+ 20 - 0
borgmatic/config/schema.yaml

@@ -284,6 +284,23 @@ properties:
             http://borgbackup.readthedocs.io/en/stable/usage/create.html for
             details. Defaults to "lz4".
         example: lz4
+    recompress:
+        type: string
+        enum: ['if-different', 'always', 'never']
+        description: |
+            Mode for recompressing data chunks according to MODE. 
+            Possible modes are:
+            - "if-different": Recompress if the current compression
+            is with a different compression algorithm.
+            - "always": Recompress even if the current compression
+            is with the same compression algorithm. Use this to change
+            the compression level.
+            - "never": Do not recompress. Use this option to explicitly
+            prevent recompression.
+            See https://borgbackup.readthedocs.io/en/stable/usage/recreate.html
+            for details.
+            Defaults to "never".
+        example: if-different
     upload_rate_limit:
         type: integer
         description: |
@@ -767,6 +784,7 @@ properties:
                 - prune
                 - compact
                 - create
+                - recreate
                 - check
                 - delete
                 - extract
@@ -982,6 +1000,7 @@ properties:
                                   - prune
                                   - compact
                                   - create
+                                  - recreate
                                   - check
                                   - delete
                                   - extract
@@ -1046,6 +1065,7 @@ properties:
                                   - prune
                                   - compact
                                   - create
+                                  - recreate
                                   - check
                                   - delete
                                   - extract

+ 39 - 0
tests/unit/actions/test_recreate.py

@@ -0,0 +1,39 @@
+from flexmock import flexmock
+
+from borgmatic.actions import recreate as module
+
+
+def test_run_recreate_does_not_raise():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.borg.recreate).should_receive('recreate_archive')
+
+    recreate_arguments = flexmock(repository=flexmock(), archive=None)
+
+    module.run_recreate(
+        repository={'path': 'repo'},
+        config={},
+        local_borg_version=None,
+        recreate_arguments=recreate_arguments,
+        global_arguments=flexmock(),
+        local_path=None,
+        remote_path=None,
+    )
+
+
+def test_run_recreate_with_archive_does_not_raise():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.borg.recreate).should_receive('recreate_archive')
+
+    recreate_arguments = flexmock(repository=flexmock(), archive='test-archive')
+
+    module.run_recreate(
+        repository={'path': 'repo'},
+        config={},
+        local_borg_version=None,
+        recreate_arguments=recreate_arguments,
+        global_arguments=flexmock(),
+        local_path=None,
+        remote_path=None,
+    )

+ 644 - 0
tests/unit/borg/test_recreate.py

@@ -0,0 +1,644 @@
+import logging
+import shlex
+
+from flexmock import flexmock
+
+from borgmatic.borg import recreate as module
+
+from ..test_verbosity import insert_logging_mock
+
+
+def insert_execute_command_mock(command, working_directory=None, borg_exit_codes=None):
+    flexmock(module.borgmatic.borg.environment).should_receive('make_environment')
+    flexmock(module.borgmatic.execute).should_receive('execute_command').with_args(
+        full_command=command,
+        output_log_level=module.logging.INFO,
+        environment=None,
+        working_directory=working_directory,
+        borg_local_path=command[0],
+        borg_exit_codes=borg_exit_codes,
+    ).once()
+
+
+def mock_dependencies():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+
+
+def test_recreate_archive_dry_run_skips_execution():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    flexmock(module.borgmatic.execute).should_receive('execute_command').never()
+
+    recreate_arguments = flexmock(
+        repository=flexmock(),
+        list=None,
+        target=None,
+        comment=None,
+        timestamp=None,
+        match_archives=None,
+    )
+
+    result = module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=recreate_arguments,
+        global_arguments=flexmock(log_json=False, dry_run=True),
+        local_path='borg',
+    )
+
+    assert result is None
+
+
+def test_recreate_calls_borg_with_required_flags():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        remote_path=None,
+        patterns=None,
+    )
+
+
+def test_recreate_with_remote_path():
+    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(())
+    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'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        remote_path='borg1',
+        patterns=None,
+    )
+
+
+def test_recreate_with_lock_wait():
+    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(())
+    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'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={'lock_wait': '5'},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_log_info():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', '--info', 'repo::archive'))
+
+    insert_logging_mock(logging.INFO)
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_log_debug():
+    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(())
+    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'))
+    insert_logging_mock(logging.DEBUG)
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_log_json():
+    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(())
+    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'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=True),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_list_filter_flags():
+    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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    flexmock(module).should_receive('make_list_filter_flags').and_return('AME+-')
+    insert_execute_command_mock(
+        ('borg', 'recreate', '--list', '--filter', 'AME+-', 'repo::archive')
+    )
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=True,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    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')
+    )
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=['pattern1', 'pattern2'],
+    )
+
+
+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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    flexmock(module).should_receive('make_exclude_flags').and_return(('--exclude', 'pattern'))
+    insert_execute_command_mock(('borg', 'recreate', '--exclude', 'pattern', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={'exclude_patterns': ['pattern']},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_target_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.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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', '--target', 'new-archive', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target='new-archive',
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_comment_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.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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(
+        ('borg', 'recreate', '--comment', shlex.quote('This is a test comment'), 'repo::archive')
+    )
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment='This is a test comment',
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_timestamp_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.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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(
+        ('borg', 'recreate', '--timestamp', '2023-10-01T12:00:00', 'repo::archive')
+    )
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp='2023-10-01T12:00:00',
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_compression_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.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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', '--compression', 'lz4', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={'compression': 'lz4'},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_chunker_params_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.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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(
+        ('borg', 'recreate', '--chunker-params', '19,23,21,4095', 'repo::archive')
+    )
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={'chunker_params': '19,23,21,4095'},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_recompress_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.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.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', '--recompress', 'always', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={'recompress': 'always'},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives=None,
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_match_archives_star():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives='*',
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_match_archives_regex():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives='re:.*',
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_match_archives_shell():
+    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(())
+    flexmock(module.borgmatic.borg.flags).should_receive(
+        'make_repository_archive_flags'
+    ).and_return(('repo::archive',))
+    insert_execute_command_mock(('borg', 'recreate', 'repo::archive'))
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='1.2.3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives='sh:*',
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )
+
+
+def test_recreate_with_glob_archives_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.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_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={},
+        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_match_archives_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.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_repository_archive_flags'
+    ).and_return(('--repo', 'repo', 'archive'))
+    insert_execute_command_mock(
+        ('borg', 'recreate', '--match-archives', 'sh:foo-*', '--repo', 'repo', 'archive')
+    )
+
+    module.recreate_archive(
+        repository='repo',
+        archive='archive',
+        config={},
+        local_borg_version='2.0.0b3',
+        recreate_arguments=flexmock(
+            list=None,
+            target=None,
+            comment=None,
+            timestamp=None,
+            match_archives='sh:foo-*',
+        ),
+        global_arguments=flexmock(dry_run=False, log_json=False),
+        local_path='borg',
+        patterns=None,
+    )

+ 41 - 0
tests/unit/commands/test_borgmatic.py

@@ -1039,6 +1039,47 @@ def test_run_actions_with_skip_actions_skips_create():
     )
 
 
+def test_run_actions_runs_recreate():
+    flexmock(module).should_receive('add_custom_log_levels')
+    flexmock(module).should_receive('get_skip_actions').and_return([])
+    flexmock(module.command).should_receive('Before_after_hooks').and_return(flexmock())
+
+    flexmock(borgmatic.actions.recreate).should_receive('run_recreate').once()
+
+    tuple(
+        module.run_actions(
+            arguments={'global': flexmock(dry_run=False, log_file='foo'), 'recreate': flexmock()},
+            config_filename=flexmock(),
+            config={'repositories': []},
+            config_paths=[],
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
+            repository={'path': 'repo'},
+        )
+    )
+
+
+def test_run_actions_with_skip_actions_skips_recreate():
+    flexmock(module).should_receive('add_custom_log_levels')
+    flexmock(module).should_receive('get_skip_actions').and_return(['recreate'])
+    flexmock(module.command).should_receive('Before_after_hooks').and_return(flexmock())
+    flexmock(borgmatic.actions.recreate).should_receive('run_recreate').never()
+
+    tuple(
+        module.run_actions(
+            arguments={'global': flexmock(dry_run=False, log_file='foo'), 'recreate': flexmock()},
+            config_filename=flexmock(),
+            config={'repositories': [], 'skip_actions': ['recreate']},
+            config_paths=[],
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
+            repository={'path': 'repo'},
+        )
+    )
+
+
 def test_run_actions_runs_prune():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('get_skip_actions').and_return([])