Преглед на файлове

Borg 2 changes: Default the "archive_name_format" option to just "{hostname}". Update the "--match-archives"/"--archive" flags to support series names / archive hashes. Add a "--match-archives" flag to the "prune" action.

Dan Helfman преди 7 месеца
родител
ревизия
83bc737185

+ 9 - 0
NEWS

@@ -21,6 +21,15 @@
    all repositories in the configuration file.
  * Add support for Borg 2's "rclone://" repository URLs, so you can backup to 70+ cloud storage
    services whether or not they support Borg explicitly.
+ * When using Borg 2, default the "archive_name_format" option to just "{hostname}", as Borg 2 does
+   not require unique archive names; identical archive names form a common "series" that can be
+   targeted together. See the Borg 2 documentation for more information:
+   https://borgbackup.readthedocs.io/en/2.0.0b12/changes.html#borg-1-2-x-1-4-x-to-borg-2-0
+ * Update the "--match-archives" flag in all actions (and the "--archive" flag in select actions) to
+   support a Borg 2 series name as its value.
+ * Update the "--match-archives" and "--archive" flags in all actions to support a Borg 2 archive
+   hash as its value.
+ * Add a "--match-archives" flag to the "prune" action.
 
 1.8.14
  * #896: Fix an error in borgmatic rcreate/init on an empty repository directory with Borg 1.4.

+ 3 - 1
borgmatic/borg/create.py

@@ -399,7 +399,9 @@ def make_base_create_command(
     lock_wait = config.get('lock_wait', None)
     list_filter_flags = make_list_filter_flags(local_borg_version, dry_run)
     files_cache = config.get('files_cache')
-    archive_name_format = config.get('archive_name_format', flags.DEFAULT_ARCHIVE_NAME_FORMAT)
+    archive_name_format = config.get(
+        'archive_name_format', flags.get_default_archive_name_format(local_borg_version)
+    )
     extra_borg_options = config.get('extra_borg_options', {}).get('create', '')
 
     if feature.available(feature.Feature.ATIME, local_borg_version):

+ 2 - 0
borgmatic/borg/feature.py

@@ -16,6 +16,7 @@ class Feature(Enum):
     REPO_DELETE = 10
     MATCH_ARCHIVES = 11
     EXCLUDED_FILES_MINUS = 12
+    ARCHIVE_SERIES = 13
 
 
 FEATURE_TO_MINIMUM_BORG_VERSION = {
@@ -31,6 +32,7 @@ FEATURE_TO_MINIMUM_BORG_VERSION = {
     Feature.REPO_DELETE: parse('2.0.0a2'),  # borg repo-delete
     Feature.MATCH_ARCHIVES: parse('2.0.0b3'),  # borg --match-archives
     Feature.EXCLUDED_FILES_MINUS: parse('2.0.0b5'),  # --list --filter uses "-" for excludes
+    Feature.ARCHIVE_SERIES: parse('2.0.0b11'),  # identically named archives form a series
 }
 
 

+ 39 - 4
borgmatic/borg/flags.py

@@ -50,6 +50,9 @@ def make_repository_flags(repository_path, local_borg_version):
     ) + (repository_path,)
 
 
+ARCHIVE_HASH_PATTERN = re.compile('[0-9a-fA-F]{8,}$')
+
+
 def make_repository_archive_flags(repository_path, archive, local_borg_version):
     '''
     Given the path of a Borg repository, an archive name or pattern, and the local Borg version,
@@ -57,20 +60,41 @@ def make_repository_archive_flags(repository_path, archive, local_borg_version):
     and archive.
     '''
     return (
-        ('--repo', repository_path, archive)
+        (
+            '--repo',
+            repository_path,
+            (
+                f'aid:{archive}'
+                if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
+                and ARCHIVE_HASH_PATTERN.match(archive)
+                and not archive.startswith('aid:')
+                else archive
+            ),
+        )
         if feature.available(feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version)
         else (f'{repository_path}::{archive}',)
     )
 
 
-DEFAULT_ARCHIVE_NAME_FORMAT = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'  # noqa: FS003
+DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES = '{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}'  # noqa: FS003
+DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES = '{hostname}'  # noqa: FS003
+
+
+def get_default_archive_name_format(local_borg_version):
+    '''
+    Given the local Borg version, return the corresponding default archive name format.
+    '''
+    if feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version):
+        return DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES
+
+    return DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
 
 
 def make_match_archives_flags(
     match_archives,
     archive_name_format,
     local_borg_version,
-    default_archive_name_format=DEFAULT_ARCHIVE_NAME_FORMAT,
+    default_archive_name_format=None,
 ):
     '''
     Return match archives flags based on the given match archives value, if any. If it isn't set,
@@ -83,12 +107,23 @@ def make_match_archives_flags(
             return ()
 
         if feature.available(feature.Feature.MATCH_ARCHIVES, local_borg_version):
+            if (
+                feature.available(feature.Feature.ARCHIVE_SERIES, local_borg_version)
+                and ARCHIVE_HASH_PATTERN.match(match_archives)
+                and not match_archives.startswith('aid:')
+            ):
+                return ('--match-archives', f'aid:{match_archives}')
+
             return ('--match-archives', match_archives)
         else:
             return ('--glob-archives', re.sub(r'^sh:', '', match_archives))
 
     derived_match_archives = re.sub(
-        r'\{(now|utcnow|pid)([:%\w\.-]*)\}', '*', archive_name_format or default_archive_name_format
+        r'\{(now|utcnow|pid)([:%\w\.-]*)\}',
+        '*',
+        archive_name_format
+        or default_archive_name_format
+        or get_default_archive_name_format(local_borg_version),
     )
 
     if derived_match_archives == '*':

+ 6 - 5
borgmatic/borg/prune.py

@@ -8,9 +8,10 @@ from borgmatic.execute import execute_command
 logger = logging.getLogger(__name__)
 
 
-def make_prune_flags(config, local_borg_version):
+def make_prune_flags(config, prune_arguments, local_borg_version):
     '''
-    Given a configuration dict mapping from option name to value, transform it into an sequence of
+    Given a configuration dict mapping from option name to value, prune arguments as an
+    argparse.Namespace instance, and the local Borg version, produce a corresponding sequence of
     command-line flags.
 
     For example, given a retention config of:
@@ -40,7 +41,7 @@ def make_prune_flags(config, local_borg_version):
         if prefix
         else (
             flags.make_match_archives_flags(
-                config.get('match_archives'),
+                prune_arguments.match_archives or config.get('match_archives'),
                 config.get('archive_name_format'),
                 local_borg_version,
             )
@@ -69,7 +70,7 @@ def prune_archives(
 
     full_command = (
         (local_path, 'prune')
-        + make_prune_flags(config, local_borg_version)
+        + make_prune_flags(config, prune_arguments, local_borg_version)
         + (('--remote-path', remote_path) if remote_path else ())
         + (('--umask', str(umask)) if umask else ())
         + (('--log-json',) if global_arguments.log_json else ())
@@ -78,7 +79,7 @@ def prune_archives(
         + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ())
         + flags.make_flags_from_arguments(
             prune_arguments,
-            excludes=('repository', 'stats', 'list_archives'),
+            excludes=('repository', 'match_archives', 'stats', 'list_archives'),
         )
         + (('--list',) if prune_arguments.list_archives else ())
         + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ())

+ 32 - 15
borgmatic/commands/arguments.py

@@ -469,7 +469,7 @@ def make_parsers():
     )
     transfer_group.add_argument(
         '--archive',
-        help='Name of single archive to transfer (or "latest"), defaults to transferring all archives',
+        help='Name or hash of a single archive to transfer (or "latest"), defaults to transferring all archives',
     )
     transfer_group.add_argument(
         '--upgrader',
@@ -486,7 +486,7 @@ def make_parsers():
         '--match-archives',
         '--glob-archives',
         metavar='PATTERN',
-        help='Only transfer archives with names matching this pattern',
+        help='Only transfer archives with names, hashes, or series matching this pattern',
     )
     transfer_group.add_argument(
         '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
@@ -535,6 +535,13 @@ def make_parsers():
         '--repository',
         help='Path of specific existing repository to prune (must be already specified in a borgmatic configuration file), quoted globs supported',
     )
+    prune_group.add_argument(
+        '-a',
+        '--match-archives',
+        '--glob-archives',
+        metavar='PATTERN',
+        help='When pruning, only consider archives with names, hashes, or series matching this pattern',
+    )
     prune_group.add_argument(
         '--stats',
         dest='stats',
@@ -673,7 +680,7 @@ def make_parsers():
         '--match-archives',
         '--glob-archives',
         metavar='PATTERN',
-        help='Only check archives with names matching this pattern',
+        help='Only check archives with names, hashes, or series matching this pattern',
     )
     check_group.add_argument(
         '--only',
@@ -705,7 +712,7 @@ def make_parsers():
     )
     delete_group.add_argument(
         '--archive',
-        help='Archive to delete',
+        help='Archive name, hash, or series to delete',
     )
     delete_group.add_argument(
         '--list',
@@ -749,7 +756,7 @@ def make_parsers():
         '--match-archives',
         '--glob-archives',
         metavar='PATTERN',
-        help='Only delete archives matching this pattern',
+        help='Only delete archives with names, hashes, or series matching this pattern',
     )
     delete_group.add_argument(
         '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
@@ -795,7 +802,7 @@ def make_parsers():
         help='Path of repository to extract, defaults to the configured repository if there is only one, quoted globs supported',
     )
     extract_group.add_argument(
-        '--archive', help='Name of archive to extract (or "latest")', required=True
+        '--archive', help='Name or hash of a single archive to extract (or "latest")', required=True
     )
     extract_group.add_argument(
         '--path',
@@ -863,7 +870,7 @@ def make_parsers():
     )
     config_bootstrap_group.add_argument(
         '--archive',
-        help='Name of archive to extract config files from, defaults to "latest"',
+        help='Name or hash of a single archive to extract config files from, defaults to "latest"',
         default='latest',
     )
     config_bootstrap_group.add_argument(
@@ -955,7 +962,7 @@ def make_parsers():
         help='Path of repository to export from, defaults to the configured repository if there is only one, quoted globs supported',
     )
     export_tar_group.add_argument(
-        '--archive', help='Name of archive to export (or "latest")', required=True
+        '--archive', help='Name or hash of a single archive to export (or "latest")', required=True
     )
     export_tar_group.add_argument(
         '--path',
@@ -1000,7 +1007,9 @@ def make_parsers():
         '--repository',
         help='Path of repository to use, defaults to the configured repository if there is only one, quoted globs supported',
     )
-    mount_group.add_argument('--archive', help='Name of archive to mount (or "latest")')
+    mount_group.add_argument(
+        '--archive', help='Name or hash of a single archive to mount (or "latest")'
+    )
     mount_group.add_argument(
         '--mount-point',
         metavar='PATH',
@@ -1120,7 +1129,9 @@ def make_parsers():
         help='Path of repository to restore from, defaults to the configured repository if there is only one, quoted globs supported',
     )
     restore_group.add_argument(
-        '--archive', help='Name of archive to restore from (or "latest")', required=True
+        '--archive',
+        help='Name or hash of a single archive to restore from (or "latest")',
+        required=True,
     )
     restore_group.add_argument(
         '--data-source',
@@ -1188,7 +1199,7 @@ def make_parsers():
         '--match-archives',
         '--glob-archives',
         metavar='PATTERN',
-        help='Only list archive names matching this pattern',
+        help='Only list archive names, hashes, or series matching this pattern',
     )
     repo_list_group.add_argument(
         '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
@@ -1235,7 +1246,9 @@ def make_parsers():
         '--repository',
         help='Path of repository containing archive to list, defaults to the configured repositories, quoted globs supported',
     )
-    list_group.add_argument('--archive', help='Name of the archive to list (or "latest")')
+    list_group.add_argument(
+        '--archive', help='Name or hash of a single archive to list (or "latest")'
+    )
     list_group.add_argument(
         '--path',
         metavar='PATH',
@@ -1321,7 +1334,9 @@ def make_parsers():
         '--repository',
         help='Path of repository containing archive to show info for, defaults to the configured repository if there is only one, quoted globs supported',
     )
-    info_group.add_argument('--archive', help='Name of archive to show info for (or "latest")')
+    info_group.add_argument(
+        '--archive', help='Archive name, hash, or series to show info for (or "latest")'
+    )
     info_group.add_argument(
         '--json', dest='json', default=False, action='store_true', help='Output results as JSON'
     )
@@ -1335,7 +1350,7 @@ def make_parsers():
         '--match-archives',
         '--glob-archives',
         metavar='PATTERN',
-        help='Only show info for archive names matching this pattern',
+        help='Only show info for archive names, hashes, or series matching this pattern',
     )
     info_group.add_argument(
         '--sort-by', metavar='KEYS', help='Comma-separated list of sorting keys'
@@ -1460,7 +1475,9 @@ def make_parsers():
         '--repository',
         help='Path of repository to pass to Borg, defaults to the configured repositories, quoted globs supported',
     )
-    borg_group.add_argument('--archive', help='Name of archive to pass to Borg (or "latest")')
+    borg_group.add_argument(
+        '--archive', help='Archive name, hash, or series to pass to Borg (or "latest")'
+    )
     borg_group.add_argument(
         '--',
         metavar='OPTION',

+ 8 - 5
borgmatic/config/schema.yaml

@@ -397,11 +397,14 @@ properties:
     archive_name_format:
         type: string
         description: |
-            Name of the archive. Borg placeholders can be used. See the output
-            of "borg help placeholders" for details. Defaults to
-            "{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}". When running actions like
-            repo-list, info, or check, borgmatic automatically tries to match
-            only archives created with this name format.
+            Name of the archive to create. Borg placeholders can be used. See
+            the output of "borg help placeholders" for details. Defaults to
+            "{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}" with Borg 1 and
+            "{hostname}" with Borg 2, as Borg 2 does not require unique
+            archive names; identical archive names form a common "series" that
+            can be targeted together. When running actions like repo-list,
+            info, or check, borgmatic automatically tries to match only
+            archives created with this name format.
         example: "{hostname}-documents-{now}"
     match_archives:
         type: string

+ 1 - 1
docs/docker-compose.yaml

@@ -11,7 +11,7 @@ services:
         ENVIRONMENT: development
   message:
     image: alpine
-    container_name: message
+    container_name: borgmatic-docs-message
     command:
       - sh
       - -c

+ 4 - 4
docs/how-to/backup-your-databases.md

@@ -336,15 +336,15 @@ borgmatic restore --archive host-2023-01-02T04:06:07.080910
 
 (No borgmatic `restore` action? Upgrade borgmatic!)
 
-With newer versions of borgmatic, you can simplify this to:
+Or you can simplify this to:
 
 ```bash
 borgmatic restore --archive latest
 ```
 
-The `--archive` value is the name of the archive to restore from. This
-restores all databases dumps that borgmatic originally backed up to that
-archive.
+The `--archive` value is the name of the archive or archive hash to restore
+from. This restores all databases dumps that borgmatic originally backed up to
+that archive.
 
 This is a destructive action! `borgmatic restore` replaces live databases by
 restoring dumps from the selected archive. So be very careful when and where

+ 12 - 4
docs/how-to/extract-a-backup.md

@@ -40,10 +40,10 @@ Or simplify this to:
 borgmatic extract --archive latest
 ```
 
-The `--archive` value is the name of the archive to extract. This extracts the
-entire contents of the archive to the current directory, so make sure you're
-in the right place before running the command—or see below about the
-`--destination` flag.
+The `--archive` value is the name of the archive or archive hash to extract.
+This extracts the entire contents of the archive to the current directory, so
+make sure you're in the right place before running the command—or see below
+about the `--destination` flag.
 
 ## Repository selection
 
@@ -131,6 +131,14 @@ Or use the "latest" value for the archive to mount the latest archive:
 borgmatic mount --archive latest --mount-point /mnt
 ```
 
+<span class="minilink minilink-addedin">With Borg version 2.x</span>You can
+provide a series name for the `--archive` value to mount multiple archives in
+that series:
+
+```bash
+borgmatic mount --archive seriesname --mount-point /mnt
+```
+
 If you'd like to restrict the mounted filesystem to only particular paths from
 your archive, use the `--path` flag, similar to the `extract` action above.
 For instance:

+ 11 - 4
docs/how-to/make-per-application-backups.md

@@ -82,10 +82,14 @@ this option in the `storage:` section of your configuration.
 
 This example means that when borgmatic creates an archive, its name will start
 with the string `home-directories-` and end with a timestamp for its creation
-time. If `archive_name_format` is unspecified, the default is
+time. If `archive_name_format` is unspecified, the default with Borg 1 is
 `{hostname}-{now:%Y-%m-%dT%H:%M:%S.%f}`, meaning your system hostname plus a
 timestamp in a particular format.
 
+<span class="minilink minilink-addedin">With Borg version 2.x</span>The default
+is just `{hostname}`, as Borg 2 does not require unique archive names; identical
+archive names form a common "series" that can be targeted together.
+
 
 ### Archive filtering
 
@@ -129,10 +133,13 @@ archive_name_format: {hostname}-user-data-{now}
 match_archives: sh:myhost-user-data-*        
 ```
 
-For Borg 1.x, use a shell pattern for the `match_archives` value and see the
-[Borg patterns
+<span class="minilink minilink-addedin">With Borg version 1.x</span>Use a shell
+pattern for the `match_archives` value and see the [Borg patterns
 documentation](https://borgbackup.readthedocs.io/en/stable/usage/help.html#borg-help-patterns)
-for more information. For Borg 2.x, see the [match archives
+for more information.
+
+<span class="minilink minilink-addedin">With Borg version 2.x</span>See the
+[match archives
 documentation](https://borgbackup.readthedocs.io/en/2.0.0b12/usage/help.html#borg-help-match-archives).
 
 Some borgmatic command-line actions also have a `--match-archives` flag that

+ 101 - 2
tests/unit/borg/test_create.py

@@ -581,6 +581,9 @@ def test_make_base_create_command_includes_patterns_file_in_borg_command():
         None
     )
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     pattern_flags = ('--patterns-from', mock_pattern_file.name)
@@ -631,6 +634,9 @@ def test_make_base_create_command_includes_sources_and_config_paths_in_borg_comm
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -676,6 +682,9 @@ def test_make_base_create_command_with_store_config_false_omits_config_files():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -719,6 +728,9 @@ def test_make_base_create_command_includes_exclude_patterns_in_borg_command():
     mock_exclude_file = flexmock(name='/tmp/excludes')
     flexmock(module).should_receive('write_pattern_file').and_return(mock_exclude_file)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -795,6 +807,9 @@ def test_make_base_create_command_includes_configuration_option_as_command_flag(
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(feature_available)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -837,6 +852,9 @@ def test_make_base_create_command_includes_dry_run_in_borg_command():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -879,6 +897,9 @@ def test_make_base_create_command_includes_local_path_in_borg_command():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -921,6 +942,9 @@ def test_make_base_create_command_includes_remote_path_in_borg_command():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -963,6 +987,9 @@ def test_make_base_create_command_includes_log_json_in_borg_command():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1004,6 +1031,9 @@ def test_make_base_create_command_includes_list_flags_in_borg_command():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1046,6 +1076,9 @@ def test_make_base_create_command_with_stream_processes_ignores_read_special_fal
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1095,6 +1128,9 @@ def test_make_base_create_command_with_stream_processes_and_read_special_true_sk
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1141,6 +1177,9 @@ def test_make_base_create_command_with_non_matching_source_directories_glob_pass
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1183,6 +1222,9 @@ def test_make_base_create_command_expands_glob_in_source_directories():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1225,6 +1267,9 @@ def test_make_base_create_command_includes_archive_name_format_in_borg_command()
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1255,7 +1300,52 @@ def test_make_base_create_command_includes_archive_name_format_in_borg_command()
     assert not exclude_file
 
 
-def test_base_create_command_includes_archive_name_format_with_placeholders_in_borg_command():
+def test_make_base_create_command_includes_default_archive_name_format_in_borg_command():
+    flexmock(module.borgmatic.config.options).should_receive('get_working_directory').and_return(
+        None
+    )
+    flexmock(module).should_receive('collect_borgmatic_source_directories').and_return([])
+    flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
+    flexmock(module).should_receive('map_directories_to_devices').and_return({})
+    flexmock(module).should_receive('expand_directories').and_return(())
+    flexmock(module).should_receive('pattern_root_directories').and_return([])
+    flexmock(module.os.path).should_receive('expanduser').and_raise(TypeError)
+    flexmock(module).should_receive('expand_home_directories').and_return(())
+    flexmock(module).should_receive('write_pattern_file').and_return(None)
+    flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module).should_receive('ensure_files_readable')
+    flexmock(module).should_receive('make_pattern_flags').and_return(())
+    flexmock(module).should_receive('make_exclude_flags').and_return(())
+    flexmock(module.flags).should_receive('make_repository_archive_flags').and_return(
+        ('repo::{hostname}',)
+    )
+
+    (create_flags, create_positional_arguments, pattern_file, exclude_file) = (
+        module.make_base_create_command(
+            dry_run=False,
+            repository_path='repo',
+            config={
+                'source_directories': ['foo', 'bar'],
+                'repositories': ['repo'],
+            },
+            config_paths=['/tmp/test.yaml'],
+            local_borg_version='1.2.3',
+            global_arguments=flexmock(log_json=False),
+            borgmatic_source_directories=(),
+        )
+    )
+
+    assert create_flags == ('borg', 'create')
+    assert create_positional_arguments == ('repo::{hostname}', 'foo', 'bar')
+    assert not pattern_file
+    assert not exclude_file
+
+
+def test_make_base_create_command_includes_archive_name_format_with_placeholders_in_borg_command():
     repository_archive_pattern = 'repo::Documents_{hostname}-{now}'  # noqa: FS003
     flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
     flexmock(module).should_receive('map_directories_to_devices').and_return({})
@@ -1265,6 +1355,9 @@ def test_base_create_command_includes_archive_name_format_with_placeholders_in_b
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1295,7 +1388,7 @@ def test_base_create_command_includes_archive_name_format_with_placeholders_in_b
     assert not exclude_file
 
 
-def test_base_create_command_includes_repository_and_archive_name_format_with_placeholders_in_borg_command():
+def test_make_base_create_command_includes_repository_and_archive_name_format_with_placeholders_in_borg_command():
     repository_archive_pattern = '{fqdn}::Documents_{hostname}-{now}'  # noqa: FS003
     flexmock(module).should_receive('deduplicate_directories').and_return(('foo', 'bar'))
     flexmock(module).should_receive('map_directories_to_devices').and_return({})
@@ -1305,6 +1398,9 @@ def test_base_create_command_includes_repository_and_archive_name_format_with_pl
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())
@@ -1347,6 +1443,9 @@ def test_make_base_create_command_includes_extra_borg_options_in_borg_command():
     flexmock(module).should_receive('expand_home_directories').and_return(())
     flexmock(module).should_receive('write_pattern_file').and_return(None)
     flexmock(module).should_receive('make_list_filter_flags').and_return('FOO')
+    flexmock(module.flags).should_receive('get_default_archive_name_format').and_return(
+        '{hostname}'
+    )
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module).should_receive('ensure_files_readable')
     flexmock(module).should_receive('make_pattern_flags').and_return(())

+ 33 - 0
tests/unit/borg/test_flags.py

@@ -85,6 +85,24 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a
     ) == ('repo::archive',)
 
 
+def test_get_default_archive_name_format_with_archive_series_feature_uses_series_archive_name_format():
+    flexmock(module.feature).should_receive('available').and_return(True)
+
+    assert (
+        module.get_default_archive_name_format(local_borg_version='1.2.3')
+        == module.DEFAULT_ARCHIVE_NAME_FORMAT_WITH_SERIES
+    )
+
+
+def test_get_default_archive_name_format_without_archive_series_feature_uses_non_series_archive_name_format():
+    flexmock(module.feature).should_receive('available').and_return(False)
+
+    assert (
+        module.get_default_archive_name_format(local_borg_version='1.2.3')
+        == module.DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
+    )
+
+
 @pytest.mark.parametrize(
     'match_archives,archive_name_format,feature_available,expected_result',
     (
@@ -175,12 +193,27 @@ def test_make_repository_archive_flags_with_borg_features_joins_repository_and_a
             True,
             (),
         ),
+        (
+            'abcdefabcdef',
+            None,
+            True,
+            ('--match-archives', 'aid:abcdefabcdef'),
+        ),
+        (
+            'aid:abcdefabcdef',
+            None,
+            True,
+            ('--match-archives', 'aid:abcdefabcdef'),
+        ),
     ),
 )
 def test_make_match_archives_flags_makes_flags_with_globs(
     match_archives, archive_name_format, feature_available, expected_result
 ):
     flexmock(module.feature).should_receive('available').and_return(feature_available)
+    flexmock(module).should_receive('get_default_archive_name_format').and_return(
+        module.DEFAULT_ARCHIVE_NAME_FORMAT_WITHOUT_SERIES
+    )
 
     assert (
         module.make_match_archives_flags(

+ 71 - 7
tests/unit/borg/test_prune.py

@@ -36,7 +36,9 @@ def test_make_prune_flags_returns_flags_from_config():
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
 
-    result = module.make_prune_flags(config, local_borg_version='1.2.3')
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
 
     assert result == BASE_PRUNE_FLAGS
 
@@ -49,7 +51,9 @@ def test_make_prune_flags_accepts_prefix_with_placeholders():
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
 
-    result = module.make_prune_flags(config, local_borg_version='1.2.3')
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
 
     expected = (
         '--keep-daily',
@@ -69,7 +73,9 @@ def test_make_prune_flags_with_prefix_without_borg_features_uses_glob_archives()
     flexmock(module.feature).should_receive('available').and_return(False)
     flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
 
-    result = module.make_prune_flags(config, local_borg_version='1.2.3')
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
 
     expected = (
         '--keep-daily',
@@ -90,7 +96,9 @@ def test_make_prune_flags_prefers_prefix_to_archive_name_format():
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module.flags).should_receive('make_match_archives_flags').never()
 
-    result = module.make_prune_flags(config, local_borg_version='1.2.3')
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
 
     expected = (
         '--keep-daily',
@@ -111,9 +119,63 @@ def test_make_prune_flags_without_prefix_uses_archive_name_format_instead():
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
         None, 'bar-{now}', '1.2.3'  # noqa: FS003
-    ).and_return(('--match-archives', 'sh:bar-*'))
+    ).and_return(('--match-archives', 'sh:bar-*')).once()
+
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
+
+    expected = (
+        '--keep-daily',
+        '1',
+        '--match-archives',
+        'sh:bar-*',  # noqa: FS003
+    )
+
+    assert result == expected
+
+
+def test_make_prune_flags_without_prefix_uses_match_archives_flag_instead_of_option():
+    config = {
+        'archive_name_format': 'bar-{now}',  # noqa: FS003
+        'match_archives': 'foo*',
+        'keep_daily': 1,
+        'prefix': None,
+    }
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
+        'baz*', 'bar-{now}', '1.2.3'  # noqa: FS003
+    ).and_return(('--match-archives', 'sh:bar-*')).once()
 
-    result = module.make_prune_flags(config, local_borg_version='1.2.3')
+    result = module.make_prune_flags(
+        config, flexmock(match_archives='baz*'), local_borg_version='1.2.3'
+    )
+
+    expected = (
+        '--keep-daily',
+        '1',
+        '--match-archives',
+        'sh:bar-*',  # noqa: FS003
+    )
+
+    assert result == expected
+
+
+def test_make_prune_flags_without_prefix_uses_match_archives_option():
+    config = {
+        'archive_name_format': 'bar-{now}',  # noqa: FS003
+        'match_archives': 'foo*',
+        'keep_daily': 1,
+        'prefix': None,
+    }
+    flexmock(module.feature).should_receive('available').and_return(True)
+    flexmock(module.flags).should_receive('make_match_archives_flags').with_args(
+        'foo*', 'bar-{now}', '1.2.3'  # noqa: FS003
+    ).and_return(('--match-archives', 'sh:bar-*')).once()
+
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
 
     expected = (
         '--keep-daily',
@@ -133,7 +195,9 @@ def test_make_prune_flags_ignores_keep_exclude_tags_in_config():
     flexmock(module.feature).should_receive('available').and_return(True)
     flexmock(module.flags).should_receive('make_match_archives_flags').and_return(())
 
-    result = module.make_prune_flags(config, local_borg_version='1.2.3')
+    result = module.make_prune_flags(
+        config, flexmock(match_archives=None), local_borg_version='1.2.3'
+    )
 
     assert result == ('--keep-daily', '1')