Pārlūkot izejas kodu

Optionally dump "all" PostgreSQL databases to separate files instead of one combined dump file (#438, #560).

Dan Helfman 2 gadi atpakaļ
vecāks
revīzija
0e6b2c6773
46 mainītis faili ar 2652 papildinājumiem un 945 dzēšanām
  1. 1 1
      NEWS
  2. 0 0
      borgmatic/actions/__init__.py
  3. 36 0
      borgmatic/actions/borg.py
  4. 21 0
      borgmatic/actions/break_lock.py
  5. 55 0
      borgmatic/actions/check.py
  6. 57 0
      borgmatic/actions/compact.py
  7. 90 0
      borgmatic/actions/create.py
  8. 48 0
      borgmatic/actions/export_tar.py
  9. 67 0
      borgmatic/actions/extract.py
  10. 41 0
      borgmatic/actions/info.py
  11. 43 0
      borgmatic/actions/list.py
  12. 42 0
      borgmatic/actions/mount.py
  13. 53 0
      borgmatic/actions/prune.py
  14. 34 0
      borgmatic/actions/rcreate.py
  15. 345 0
      borgmatic/actions/restore.py
  16. 32 0
      borgmatic/actions/rinfo.py
  17. 32 0
      borgmatic/actions/rlist.py
  18. 29 0
      borgmatic/actions/transfer.py
  19. 40 0
      borgmatic/borg/list.py
  20. 119 475
      borgmatic/commands/borgmatic.py
  21. 13 0
      borgmatic/config/schema.yaml
  22. 2 1
      borgmatic/execute.py
  23. 81 41
      borgmatic/hooks/mysql.py
  24. 7 3
      borgmatic/hooks/postgresql.py
  25. 10 0
      tests/end-to-end/test_database.py
  26. 0 0
      tests/unit/actions/__init__.py
  27. 22 0
      tests/unit/actions/test_borg.py
  28. 19 0
      tests/unit/actions/test_break_lock.py
  29. 31 0
      tests/unit/actions/test_check.py
  30. 29 0
      tests/unit/actions/test_compact.py
  31. 34 0
      tests/unit/actions/test_create.py
  32. 29 0
      tests/unit/actions/test_export_tar.py
  33. 33 0
      tests/unit/actions/test_extract.py
  34. 24 0
      tests/unit/actions/test_info.py
  35. 24 0
      tests/unit/actions/test_list.py
  36. 26 0
      tests/unit/actions/test_mount.py
  37. 26 0
      tests/unit/actions/test_prune.py
  38. 26 0
      tests/unit/actions/test_rcreate.py
  39. 495 0
      tests/unit/actions/test_restore.py
  40. 21 0
      tests/unit/actions/test_rinfo.py
  41. 21 0
      tests/unit/actions/test_rlist.py
  42. 20 0
      tests/unit/actions/test_transfer.py
  43. 13 0
      tests/unit/borg/test_list.py
  44. 250 318
      tests/unit/commands/test_borgmatic.py
  45. 185 105
      tests/unit/hooks/test_mysql.py
  46. 26 1
      tests/unit/hooks/test_postgresql.py

+ 1 - 1
NEWS

@@ -3,7 +3,7 @@
    dump file, allowing more convenient restores of individual databases. You can enable this by
    dump file, allowing more convenient restores of individual databases. You can enable this by
    specifying the database dump "format" option when the database is named "all".
    specifying the database dump "format" option when the database is named "all".
  * #602: Fix logs that interfere with JSON output by making warnings go to stderr instead of stdout.
  * #602: Fix logs that interfere with JSON output by making warnings go to stderr instead of stdout.
- * #622: Fix traceback when include merging on ARM64.
+ * #622: Fix traceback when include merging configuration files on ARM64.
  * #629: Skip warning about excluded special files when no special files have been excluded.
  * #629: Skip warning about excluded special files when no special files have been excluded.
 
 
 1.7.5
 1.7.5

+ 0 - 0
borgmatic/actions/__init__.py


+ 36 - 0
borgmatic/actions/borg.py

@@ -0,0 +1,36 @@
+import logging
+
+import borgmatic.borg.borg
+import borgmatic.borg.rlist
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_borg(
+    repository, storage, local_borg_version, borg_arguments, local_path, remote_path,
+):
+    '''
+    Run the "borg" action for the given repository.
+    '''
+    if borg_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, borg_arguments.repository
+    ):
+        logger.info('{}: Running arbitrary Borg command'.format(repository))
+        archive_name = borgmatic.borg.rlist.resolve_archive_name(
+            repository,
+            borg_arguments.archive,
+            storage,
+            local_borg_version,
+            local_path,
+            remote_path,
+        )
+        borgmatic.borg.borg.run_arbitrary_borg(
+            repository,
+            storage,
+            local_borg_version,
+            options=borg_arguments.options,
+            archive=archive_name,
+            local_path=local_path,
+            remote_path=remote_path,
+        )

+ 21 - 0
borgmatic/actions/break_lock.py

@@ -0,0 +1,21 @@
+import logging
+
+import borgmatic.borg.break_lock
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_break_lock(
+    repository, storage, local_borg_version, break_lock_arguments, local_path, remote_path,
+):
+    '''
+    Run the "break-lock" action for the given repository.
+    '''
+    if break_lock_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, break_lock_arguments.repository
+    ):
+        logger.info(f'{repository}: Breaking repository and cache locks')
+        borgmatic.borg.break_lock.break_lock(
+            repository, storage, local_borg_version, local_path=local_path, remote_path=remote_path,
+        )

+ 55 - 0
borgmatic/actions/check.py

@@ -0,0 +1,55 @@
+import logging
+
+import borgmatic.borg.check
+import borgmatic.hooks.command
+
+logger = logging.getLogger(__name__)
+
+
+def run_check(
+    config_filename,
+    repository,
+    location,
+    storage,
+    consistency,
+    hooks,
+    hook_context,
+    local_borg_version,
+    check_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "check" action for the given repository.
+    '''
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('before_check'),
+        hooks.get('umask'),
+        config_filename,
+        'pre-check',
+        global_arguments.dry_run,
+        **hook_context,
+    )
+    logger.info('{}: Running consistency checks'.format(repository))
+    borgmatic.borg.check.check_archives(
+        repository,
+        location,
+        storage,
+        consistency,
+        local_borg_version,
+        local_path=local_path,
+        remote_path=remote_path,
+        progress=check_arguments.progress,
+        repair=check_arguments.repair,
+        only_checks=check_arguments.only,
+        force=check_arguments.force,
+    )
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('after_check'),
+        hooks.get('umask'),
+        config_filename,
+        'post-check',
+        global_arguments.dry_run,
+        **hook_context,
+    )

+ 57 - 0
borgmatic/actions/compact.py

@@ -0,0 +1,57 @@
+import logging
+
+import borgmatic.borg.compact
+import borgmatic.borg.feature
+import borgmatic.hooks.command
+
+logger = logging.getLogger(__name__)
+
+
+def run_compact(
+    config_filename,
+    repository,
+    storage,
+    retention,
+    hooks,
+    hook_context,
+    local_borg_version,
+    compact_arguments,
+    global_arguments,
+    dry_run_label,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "compact" action for the given repository.
+    '''
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('before_compact'),
+        hooks.get('umask'),
+        config_filename,
+        'pre-compact',
+        global_arguments.dry_run,
+        **hook_context,
+    )
+    if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.COMPACT, local_borg_version):
+        logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
+        borgmatic.borg.compact.compact_segments(
+            global_arguments.dry_run,
+            repository,
+            storage,
+            local_borg_version,
+            local_path=local_path,
+            remote_path=remote_path,
+            progress=compact_arguments.progress,
+            cleanup_commits=compact_arguments.cleanup_commits,
+            threshold=compact_arguments.threshold,
+        )
+    else:  # pragma: nocover
+        logger.info('{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository))
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('after_compact'),
+        hooks.get('umask'),
+        config_filename,
+        'post-compact',
+        global_arguments.dry_run,
+        **hook_context,
+    )

+ 90 - 0
borgmatic/actions/create.py

@@ -0,0 +1,90 @@
+import json
+import logging
+
+import borgmatic.borg.create
+import borgmatic.hooks.command
+import borgmatic.hooks.dispatch
+import borgmatic.hooks.dump
+
+logger = logging.getLogger(__name__)
+
+
+def run_create(
+    config_filename,
+    repository,
+    location,
+    storage,
+    hooks,
+    hook_context,
+    local_borg_version,
+    create_arguments,
+    global_arguments,
+    dry_run_label,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "create" action for the given repository.
+
+    If create_arguments.json is True, yield the JSON output from creating the archive.
+    '''
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('before_backup'),
+        hooks.get('umask'),
+        config_filename,
+        'pre-backup',
+        global_arguments.dry_run,
+        **hook_context,
+    )
+    logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
+    borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
+        'remove_database_dumps',
+        hooks,
+        repository,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        global_arguments.dry_run,
+    )
+    active_dumps = borgmatic.hooks.dispatch.call_hooks(
+        'dump_databases',
+        hooks,
+        repository,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        global_arguments.dry_run,
+    )
+    stream_processes = [process for processes in active_dumps.values() for process in processes]
+
+    json_output = borgmatic.borg.create.create_archive(
+        global_arguments.dry_run,
+        repository,
+        location,
+        storage,
+        local_borg_version,
+        local_path=local_path,
+        remote_path=remote_path,
+        progress=create_arguments.progress,
+        stats=create_arguments.stats,
+        json=create_arguments.json,
+        list_files=create_arguments.list_files,
+        stream_processes=stream_processes,
+    )
+    if json_output:  # pragma: nocover
+        yield json.loads(json_output)
+
+    borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
+        'remove_database_dumps',
+        hooks,
+        config_filename,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        global_arguments.dry_run,
+    )
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('after_backup'),
+        hooks.get('umask'),
+        config_filename,
+        'post-backup',
+        global_arguments.dry_run,
+        **hook_context,
+    )

+ 48 - 0
borgmatic/actions/export_tar.py

@@ -0,0 +1,48 @@
+import logging
+
+import borgmatic.borg.export_tar
+import borgmatic.borg.rlist
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_export_tar(
+    repository,
+    storage,
+    local_borg_version,
+    export_tar_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "export-tar" action for the given repository.
+    '''
+    if export_tar_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, export_tar_arguments.repository
+    ):
+        logger.info(
+            '{}: Exporting archive {} as tar file'.format(repository, export_tar_arguments.archive)
+        )
+        borgmatic.borg.export_tar.export_tar_archive(
+            global_arguments.dry_run,
+            repository,
+            borgmatic.borg.rlist.resolve_archive_name(
+                repository,
+                export_tar_arguments.archive,
+                storage,
+                local_borg_version,
+                local_path,
+                remote_path,
+            ),
+            export_tar_arguments.paths,
+            export_tar_arguments.destination,
+            storage,
+            local_borg_version,
+            local_path=local_path,
+            remote_path=remote_path,
+            tar_filter=export_tar_arguments.tar_filter,
+            list_files=export_tar_arguments.list_files,
+            strip_components=export_tar_arguments.strip_components,
+        )

+ 67 - 0
borgmatic/actions/extract.py

@@ -0,0 +1,67 @@
+import logging
+
+import borgmatic.borg.extract
+import borgmatic.borg.rlist
+import borgmatic.config.validate
+import borgmatic.hooks.command
+
+logger = logging.getLogger(__name__)
+
+
+def run_extract(
+    config_filename,
+    repository,
+    location,
+    storage,
+    hooks,
+    hook_context,
+    local_borg_version,
+    extract_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "extract" action for the given repository.
+    '''
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('before_extract'),
+        hooks.get('umask'),
+        config_filename,
+        'pre-extract',
+        global_arguments.dry_run,
+        **hook_context,
+    )
+    if extract_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, extract_arguments.repository
+    ):
+        logger.info('{}: Extracting archive {}'.format(repository, extract_arguments.archive))
+        borgmatic.borg.extract.extract_archive(
+            global_arguments.dry_run,
+            repository,
+            borgmatic.borg.rlist.resolve_archive_name(
+                repository,
+                extract_arguments.archive,
+                storage,
+                local_borg_version,
+                local_path,
+                remote_path,
+            ),
+            extract_arguments.paths,
+            location,
+            storage,
+            local_borg_version,
+            local_path=local_path,
+            remote_path=remote_path,
+            destination_path=extract_arguments.destination,
+            strip_components=extract_arguments.strip_components,
+            progress=extract_arguments.progress,
+        )
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('after_extract'),
+        hooks.get('umask'),
+        config_filename,
+        'post-extract',
+        global_arguments.dry_run,
+        **hook_context,
+    )

+ 41 - 0
borgmatic/actions/info.py

@@ -0,0 +1,41 @@
+import json
+import logging
+
+import borgmatic.borg.info
+import borgmatic.borg.rlist
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_info(
+    repository, storage, local_borg_version, info_arguments, local_path, remote_path,
+):
+    '''
+    Run the "info" action for the given repository and archive.
+
+    If info_arguments.json is True, yield the JSON output from the info for the archive.
+    '''
+    if info_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, info_arguments.repository
+    ):
+        if not info_arguments.json:  # pragma: nocover
+            logger.answer(f'{repository}: Displaying archive summary information')
+        info_arguments.archive = borgmatic.borg.rlist.resolve_archive_name(
+            repository,
+            info_arguments.archive,
+            storage,
+            local_borg_version,
+            local_path,
+            remote_path,
+        )
+        json_output = borgmatic.borg.info.display_archives_info(
+            repository,
+            storage,
+            local_borg_version,
+            info_arguments=info_arguments,
+            local_path=local_path,
+            remote_path=remote_path,
+        )
+        if json_output:  # pragma: nocover
+            yield json.loads(json_output)

+ 43 - 0
borgmatic/actions/list.py

@@ -0,0 +1,43 @@
+import json
+import logging
+
+import borgmatic.borg.list
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_list(
+    repository, storage, local_borg_version, list_arguments, local_path, remote_path,
+):
+    '''
+    Run the "list" action for the given repository and archive.
+
+    If list_arguments.json is True, yield the JSON output from listing the archive.
+    '''
+    if list_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, list_arguments.repository
+    ):
+        if not list_arguments.json:  # pragma: nocover
+            if list_arguments.find_paths:
+                logger.answer(f'{repository}: Searching archives')
+            elif not list_arguments.archive:
+                logger.answer(f'{repository}: Listing archives')
+        list_arguments.archive = borgmatic.borg.rlist.resolve_archive_name(
+            repository,
+            list_arguments.archive,
+            storage,
+            local_borg_version,
+            local_path,
+            remote_path,
+        )
+        json_output = borgmatic.borg.list.list_archive(
+            repository,
+            storage,
+            local_borg_version,
+            list_arguments=list_arguments,
+            local_path=local_path,
+            remote_path=remote_path,
+        )
+        if json_output:  # pragma: nocover
+            yield json.loads(json_output)

+ 42 - 0
borgmatic/actions/mount.py

@@ -0,0 +1,42 @@
+import logging
+
+import borgmatic.borg.mount
+import borgmatic.borg.rlist
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_mount(
+    repository, storage, local_borg_version, mount_arguments, local_path, remote_path,
+):
+    '''
+    Run the "mount" action for the given repository.
+    '''
+    if mount_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, mount_arguments.repository
+    ):
+        if mount_arguments.archive:
+            logger.info('{}: Mounting archive {}'.format(repository, mount_arguments.archive))
+        else:  # pragma: nocover
+            logger.info('{}: Mounting repository'.format(repository))
+
+        borgmatic.borg.mount.mount_archive(
+            repository,
+            borgmatic.borg.rlist.resolve_archive_name(
+                repository,
+                mount_arguments.archive,
+                storage,
+                local_borg_version,
+                local_path,
+                remote_path,
+            ),
+            mount_arguments.mount_point,
+            mount_arguments.paths,
+            mount_arguments.foreground,
+            mount_arguments.options,
+            storage,
+            local_borg_version,
+            local_path=local_path,
+            remote_path=remote_path,
+        )

+ 53 - 0
borgmatic/actions/prune.py

@@ -0,0 +1,53 @@
+import logging
+
+import borgmatic.borg.prune
+import borgmatic.hooks.command
+
+logger = logging.getLogger(__name__)
+
+
+def run_prune(
+    config_filename,
+    repository,
+    storage,
+    retention,
+    hooks,
+    hook_context,
+    local_borg_version,
+    prune_arguments,
+    global_arguments,
+    dry_run_label,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "prune" action for the given repository.
+    '''
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('before_prune'),
+        hooks.get('umask'),
+        config_filename,
+        'pre-prune',
+        global_arguments.dry_run,
+        **hook_context,
+    )
+    logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
+    borgmatic.borg.prune.prune_archives(
+        global_arguments.dry_run,
+        repository,
+        storage,
+        retention,
+        local_borg_version,
+        local_path=local_path,
+        remote_path=remote_path,
+        stats=prune_arguments.stats,
+        list_archives=prune_arguments.list_archives,
+    )
+    borgmatic.hooks.command.execute_hook(
+        hooks.get('after_prune'),
+        hooks.get('umask'),
+        config_filename,
+        'post-prune',
+        global_arguments.dry_run,
+        **hook_context,
+    )

+ 34 - 0
borgmatic/actions/rcreate.py

@@ -0,0 +1,34 @@
+import logging
+
+import borgmatic.borg.rcreate
+
+logger = logging.getLogger(__name__)
+
+
+def run_rcreate(
+    repository,
+    storage,
+    local_borg_version,
+    rcreate_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "rcreate" action for the given repository.
+    '''
+    logger.info('{}: Creating repository'.format(repository))
+    borgmatic.borg.rcreate.create_repository(
+        global_arguments.dry_run,
+        repository,
+        storage,
+        local_borg_version,
+        rcreate_arguments.encryption_mode,
+        rcreate_arguments.source_repository,
+        rcreate_arguments.copy_crypt_key,
+        rcreate_arguments.append_only,
+        rcreate_arguments.storage_quota,
+        rcreate_arguments.make_parent_dirs,
+        local_path=local_path,
+        remote_path=remote_path,
+    )

+ 345 - 0
borgmatic/actions/restore.py

@@ -0,0 +1,345 @@
+import copy
+import logging
+import os
+
+import borgmatic.borg.extract
+import borgmatic.borg.list
+import borgmatic.borg.mount
+import borgmatic.borg.rlist
+import borgmatic.borg.state
+import borgmatic.config.validate
+import borgmatic.hooks.dispatch
+import borgmatic.hooks.dump
+
+logger = logging.getLogger(__name__)
+
+
+UNSPECIFIED_HOOK = object()
+
+
+def get_configured_database(
+    hooks, archive_database_names, hook_name, database_name, configuration_database_name=None
+):
+    '''
+    Find the first database with the given hook name and database name in the configured hooks
+    dict and the given archive database names dict (from hook name to database names contained in
+    a particular backup archive). If UNSPECIFIED_HOOK is given as the hook name, search all database
+    hooks for the named database. If a configuration database name is given, use that instead of the
+    database name to lookup the database in the given hooks configuration.
+
+    Return the found database as a tuple of (found hook name, database configuration dict).
+    '''
+    if not configuration_database_name:
+        configuration_database_name = database_name
+
+    if hook_name == UNSPECIFIED_HOOK:
+        hooks_to_search = hooks
+    else:
+        hooks_to_search = {hook_name: hooks[hook_name]}
+
+    return next(
+        (
+            (name, hook_database)
+            for (name, hook) in hooks_to_search.items()
+            for hook_database in hook
+            if hook_database['name'] == configuration_database_name
+            and database_name in archive_database_names.get(name, [])
+        ),
+        (None, None),
+    )
+
+
+def get_configured_hook_name_and_database(hooks, database_name):
+    '''
+    Find the hook name and first database dict with the given database name in the configured hooks
+    dict. This searches across all database hooks.
+    '''
+
+
+def restore_single_database(
+    repository,
+    location,
+    storage,
+    hooks,
+    local_borg_version,
+    global_arguments,
+    local_path,
+    remote_path,
+    archive_name,
+    hook_name,
+    database,
+):  # pragma: no cover
+    '''
+    Given (among other things) an archive name, a database hook name, and a configured database
+    configuration dict, restore that database from the archive.
+    '''
+    logger.info(f'{repository}: Restoring database {database["name"]}')
+
+    dump_pattern = borgmatic.hooks.dispatch.call_hooks(
+        'make_database_dump_pattern',
+        hooks,
+        repository,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        database['name'],
+    )[hook_name]
+
+    # Kick off a single database extract to stdout.
+    extract_process = borgmatic.borg.extract.extract_archive(
+        dry_run=global_arguments.dry_run,
+        repository=repository,
+        archive=archive_name,
+        paths=borgmatic.hooks.dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
+        location_config=location,
+        storage_config=storage,
+        local_borg_version=local_borg_version,
+        local_path=local_path,
+        remote_path=remote_path,
+        destination_path='/',
+        # A directory format dump isn't a single file, and therefore can't extract
+        # to stdout. In this case, the extract_process return value is None.
+        extract_to_stdout=bool(database.get('format') != 'directory'),
+    )
+
+    # Run a single database restore, consuming the extract stdout (if any).
+    borgmatic.hooks.dispatch.call_hooks(
+        'restore_database_dump',
+        {hook_name: [database]},
+        repository,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        global_arguments.dry_run,
+        extract_process,
+    )
+
+
+def collect_archive_database_names(
+    repository, archive, location, storage, local_borg_version, local_path, remote_path,
+):
+    '''
+    Given a local or remote repository path, a resolved archive name, a location configuration dict,
+    a storage configuration dict, the local Borg version, and local and remote Borg paths, query the
+    archive for the names of databases it contains and return them as a dict from hook name to a
+    sequence of database names.
+    '''
+    borgmatic_source_directory = os.path.expanduser(
+        location.get(
+            'borgmatic_source_directory', borgmatic.borg.state.DEFAULT_BORGMATIC_SOURCE_DIRECTORY
+        )
+    ).lstrip('/')
+    parent_dump_path = os.path.expanduser(
+        borgmatic.hooks.dump.make_database_dump_path(borgmatic_source_directory, '*_databases/*/*')
+    )
+    dump_paths = borgmatic.borg.list.capture_archive_listing(
+        repository,
+        archive,
+        storage,
+        local_borg_version,
+        list_path=parent_dump_path,
+        local_path=local_path,
+        remote_path=remote_path,
+    )
+
+    # Determine the database names corresponding to the dumps found in the archive and
+    # add them to restore_names.
+    archive_database_names = {}
+
+    for dump_path in dump_paths:
+        try:
+            (hook_name, _, database_name) = dump_path.split(
+                borgmatic_source_directory + os.path.sep, 1
+            )[1].split(os.path.sep)[0:3]
+        except (ValueError, IndexError):
+            logger.warning(
+                f'{repository}: Ignoring invalid database dump path "{dump_path}" in archive {archive}'
+            )
+        else:
+            if database_name not in archive_database_names.get(hook_name, []):
+                archive_database_names.setdefault(hook_name, []).extend([database_name])
+
+    return archive_database_names
+
+
+def find_databases_to_restore(requested_database_names, archive_database_names):
+    '''
+    Given a sequence of requested database names to restore and a dict of hook name to the names of
+    databases found in an archive, return an expanded sequence of database names to restore,
+    replacing "all" with actual database names as appropriate.
+
+    Raise ValueError if any of the requested database names cannot be found in the archive.
+    '''
+    # A map from database hook name to the database names to restore for that hook.
+    restore_names = (
+        {UNSPECIFIED_HOOK: requested_database_names}
+        if requested_database_names
+        else {UNSPECIFIED_HOOK: ['all']}
+    )
+
+    # If "all" is in restore_names, then replace it with the names of dumps found within the
+    # archive.
+    if 'all' in restore_names[UNSPECIFIED_HOOK]:
+        restore_names[UNSPECIFIED_HOOK].remove('all')
+
+        for (hook_name, database_names) in archive_database_names.items():
+            restore_names.setdefault(hook_name, []).extend(database_names)
+
+            # If a database is to be restored as part of "all", then remove it from restore names so
+            # it doesn't get restored twice.
+            for database_name in database_names:
+                if database_name in restore_names[UNSPECIFIED_HOOK]:
+                    restore_names[UNSPECIFIED_HOOK].remove(database_name)
+
+    if not restore_names[UNSPECIFIED_HOOK]:
+        restore_names.pop(UNSPECIFIED_HOOK)
+
+    combined_restore_names = set(
+        name for database_names in restore_names.values() for name in database_names
+    )
+    combined_archive_database_names = set(
+        name for database_names in archive_database_names.values() for name in database_names
+    )
+
+    missing_names = sorted(set(combined_restore_names) - combined_archive_database_names)
+    if missing_names:
+        joined_names = ', '.join(f'"{name}"' for name in missing_names)
+        raise ValueError(
+            f"Cannot restore database{'s' if len(missing_names) > 1 else ''} {joined_names} missing from archive"
+        )
+
+    return restore_names
+
+
+def ensure_databases_found(restore_names, remaining_restore_names, found_names):
+    '''
+    Given a dict from hook name to database names to restore, a dict from hook name to remaining
+    database names to restore, and a sequence of found (actually restored) database names, raise
+    ValueError if requested databases to restore were missing from the archive and/or configuration.
+    '''
+    combined_restore_names = set(
+        name
+        for database_names in tuple(restore_names.values())
+        + tuple(remaining_restore_names.values())
+        for name in database_names
+    )
+
+    if not combined_restore_names and not found_names:
+        raise ValueError('No databases were found to restore')
+
+    missing_names = sorted(set(combined_restore_names) - set(found_names))
+    if missing_names:
+        joined_names = ', '.join(f'"{name}"' for name in missing_names)
+        raise ValueError(
+            f"Cannot restore database{'s' if len(missing_names) > 1 else ''} {joined_names} missing from borgmatic's configuration"
+        )
+
+
+def run_restore(
+    repository,
+    location,
+    storage,
+    hooks,
+    local_borg_version,
+    restore_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "restore" action for the given repository, but only if the repository matches the
+    requested repository in restore arguments.
+
+    Raise ValueError if a configured database could not be found to restore.
+    '''
+    if restore_arguments.repository and not borgmatic.config.validate.repositories_match(
+        repository, restore_arguments.repository
+    ):
+        return
+
+    logger.info(
+        '{}: Restoring databases from archive {}'.format(repository, restore_arguments.archive)
+    )
+    borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
+        'remove_database_dumps',
+        hooks,
+        repository,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        global_arguments.dry_run,
+    )
+
+    archive_name = borgmatic.borg.rlist.resolve_archive_name(
+        repository, restore_arguments.archive, storage, local_borg_version, local_path, remote_path,
+    )
+    archive_database_names = collect_archive_database_names(
+        repository, archive_name, location, storage, local_borg_version, local_path, remote_path,
+    )
+    restore_names = find_databases_to_restore(restore_arguments.databases, archive_database_names)
+    found_names = set()
+    remaining_restore_names = {}
+
+    for hook_name, database_names in restore_names.items():
+        for database_name in database_names:
+            found_hook_name, found_database = get_configured_database(
+                hooks, archive_database_names, hook_name, database_name
+            )
+
+            if not found_database:
+                remaining_restore_names.setdefault(found_hook_name or hook_name, []).append(
+                    database_name
+                )
+                continue
+
+            found_names.add(database_name)
+            restore_single_database(
+                repository,
+                location,
+                storage,
+                hooks,
+                local_borg_version,
+                global_arguments,
+                local_path,
+                remote_path,
+                archive_name,
+                found_hook_name or hook_name,
+                found_database,
+            )
+
+    # For any database that weren't found via exact matches in the hooks configuration, try to
+    # fallback to "all" entries.
+    for hook_name, database_names in remaining_restore_names.items():
+        for database_name in database_names:
+            found_hook_name, found_database = get_configured_database(
+                hooks, archive_database_names, hook_name, database_name, 'all'
+            )
+
+            if not found_database:
+                continue
+
+            found_names.add(database_name)
+            database = copy.copy(found_database)
+            database['name'] = database_name
+
+            restore_single_database(
+                repository,
+                location,
+                storage,
+                hooks,
+                local_borg_version,
+                global_arguments,
+                local_path,
+                remote_path,
+                archive_name,
+                found_hook_name or hook_name,
+                database,
+            )
+
+    borgmatic.hooks.dispatch.call_hooks_even_if_unconfigured(
+        'remove_database_dumps',
+        hooks,
+        repository,
+        borgmatic.hooks.dump.DATABASE_HOOK_NAMES,
+        location,
+        global_arguments.dry_run,
+    )
+
+    ensure_databases_found(restore_names, remaining_restore_names, found_names)

+ 32 - 0
borgmatic/actions/rinfo.py

@@ -0,0 +1,32 @@
+import json
+import logging
+
+import borgmatic.borg.rinfo
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_rinfo(
+    repository, storage, local_borg_version, rinfo_arguments, local_path, remote_path,
+):
+    '''
+    Run the "rinfo" action for the given repository.
+
+    If rinfo_arguments.json is True, yield the JSON output from the info for the repository.
+    '''
+    if rinfo_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, rinfo_arguments.repository
+    ):
+        if not rinfo_arguments.json:  # pragma: nocover
+            logger.answer('{}: Displaying repository summary information'.format(repository))
+        json_output = borgmatic.borg.rinfo.display_repository_info(
+            repository,
+            storage,
+            local_borg_version,
+            rinfo_arguments=rinfo_arguments,
+            local_path=local_path,
+            remote_path=remote_path,
+        )
+        if json_output:  # pragma: nocover
+            yield json.loads(json_output)

+ 32 - 0
borgmatic/actions/rlist.py

@@ -0,0 +1,32 @@
+import json
+import logging
+
+import borgmatic.borg.rlist
+import borgmatic.config.validate
+
+logger = logging.getLogger(__name__)
+
+
+def run_rlist(
+    repository, storage, local_borg_version, rlist_arguments, local_path, remote_path,
+):
+    '''
+    Run the "rlist" action for the given repository.
+
+    If rlist_arguments.json is True, yield the JSON output from listing the repository.
+    '''
+    if rlist_arguments.repository is None or borgmatic.config.validate.repositories_match(
+        repository, rlist_arguments.repository
+    ):
+        if not rlist_arguments.json:  # pragma: nocover
+            logger.answer('{}: Listing repository'.format(repository))
+        json_output = borgmatic.borg.rlist.list_repository(
+            repository,
+            storage,
+            local_borg_version,
+            rlist_arguments=rlist_arguments,
+            local_path=local_path,
+            remote_path=remote_path,
+        )
+        if json_output:  # pragma: nocover
+            yield json.loads(json_output)

+ 29 - 0
borgmatic/actions/transfer.py

@@ -0,0 +1,29 @@
+import logging
+
+import borgmatic.borg.transfer
+
+logger = logging.getLogger(__name__)
+
+
+def run_transfer(
+    repository,
+    storage,
+    local_borg_version,
+    transfer_arguments,
+    global_arguments,
+    local_path,
+    remote_path,
+):
+    '''
+    Run the "transfer" action for the given repository.
+    '''
+    logger.info(f'{repository}: Transferring archives to repository')
+    borgmatic.borg.transfer.transfer_archives(
+        global_arguments.dry_run,
+        repository,
+        storage,
+        local_borg_version,
+        transfer_arguments,
+        local_path=local_path,
+        remote_path=remote_path,
+    )

+ 40 - 0
borgmatic/borg/list.py

@@ -85,6 +85,46 @@ def make_find_paths(find_paths):
     )
     )
 
 
 
 
+def capture_archive_listing(
+    repository,
+    archive,
+    storage_config,
+    local_borg_version,
+    list_path=None,
+    local_path='borg',
+    remote_path=None,
+):
+    '''
+    Given a local or remote repository path, an archive name, a storage config dict, the local Borg
+    version, the archive path in which to list files, and local and remote Borg paths, capture the
+    output of listing that archive and return it as a list of file paths.
+    '''
+    borg_environment = environment.make_environment(storage_config)
+
+    return tuple(
+        execute_command_and_capture_output(
+            make_list_command(
+                repository,
+                storage_config,
+                local_borg_version,
+                argparse.Namespace(
+                    repository=repository,
+                    archive=archive,
+                    paths=[f'sh:{list_path}'],
+                    find_paths=None,
+                    json=None,
+                    format='{path}{NL}',
+                ),
+                local_path,
+                remote_path,
+            ),
+            extra_environment=borg_environment,
+        )
+        .strip('\n')
+        .split('\n')
+    )
+
+
 def list_archive(
 def list_archive(
     repository,
     repository,
     storage_config,
     storage_config,

+ 119 - 475
borgmatic/commands/borgmatic.py

@@ -1,5 +1,4 @@
 import collections
 import collections
-import copy
 import json
 import json
 import logging
 import logging
 import os
 import os
@@ -11,28 +10,28 @@ from subprocess import CalledProcessError
 import colorama
 import colorama
 import pkg_resources
 import pkg_resources
 
 
+import borgmatic.actions.borg
+import borgmatic.actions.break_lock
+import borgmatic.actions.check
+import borgmatic.actions.compact
+import borgmatic.actions.create
+import borgmatic.actions.export_tar
+import borgmatic.actions.extract
+import borgmatic.actions.info
+import borgmatic.actions.list
+import borgmatic.actions.mount
+import borgmatic.actions.prune
+import borgmatic.actions.rcreate
+import borgmatic.actions.restore
+import borgmatic.actions.rinfo
+import borgmatic.actions.rlist
+import borgmatic.actions.transfer
 import borgmatic.commands.completion
 import borgmatic.commands.completion
-from borgmatic.borg import borg as borg_borg
-from borgmatic.borg import break_lock as borg_break_lock
-from borgmatic.borg import check as borg_check
-from borgmatic.borg import compact as borg_compact
-from borgmatic.borg import create as borg_create
-from borgmatic.borg import export_tar as borg_export_tar
-from borgmatic.borg import extract as borg_extract
-from borgmatic.borg import feature as borg_feature
-from borgmatic.borg import info as borg_info
-from borgmatic.borg import list as borg_list
-from borgmatic.borg import mount as borg_mount
-from borgmatic.borg import prune as borg_prune
-from borgmatic.borg import rcreate as borg_rcreate
-from borgmatic.borg import rinfo as borg_rinfo
-from borgmatic.borg import rlist as borg_rlist
-from borgmatic.borg import transfer as borg_transfer
 from borgmatic.borg import umount as borg_umount
 from borgmatic.borg import umount as borg_umount
 from borgmatic.borg import version as borg_version
 from borgmatic.borg import version as borg_version
 from borgmatic.commands.arguments import parse_arguments
 from borgmatic.commands.arguments import parse_arguments
 from borgmatic.config import checks, collect, convert, validate
 from borgmatic.config import checks, collect, convert, validate
-from borgmatic.hooks import command, dispatch, dump, monitor
+from borgmatic.hooks import command, dispatch, monitor
 from borgmatic.logger import add_custom_log_levels, configure_logging, should_do_markup
 from borgmatic.logger import add_custom_log_levels, configure_logging, should_do_markup
 from borgmatic.signals import configure_signals
 from borgmatic.signals import configure_signals
 from borgmatic.verbosity import verbosity_to_log_level
 from borgmatic.verbosity import verbosity_to_log_level
@@ -264,509 +263,154 @@ def run_actions(
     )
     )
 
 
     if 'rcreate' in arguments:
     if 'rcreate' in arguments:
-        logger.info('{}: Creating repository'.format(repository))
-        borg_rcreate.create_repository(
-            global_arguments.dry_run,
+        borgmatic.actions.rcreate.run_rcreate(
             repository,
             repository,
             storage,
             storage,
             local_borg_version,
             local_borg_version,
-            arguments['rcreate'].encryption_mode,
-            arguments['rcreate'].source_repository,
-            arguments['rcreate'].copy_crypt_key,
-            arguments['rcreate'].append_only,
-            arguments['rcreate'].storage_quota,
-            arguments['rcreate'].make_parent_dirs,
-            local_path=local_path,
-            remote_path=remote_path,
+            arguments['rcreate'],
+            global_arguments,
+            local_path,
+            remote_path,
         )
         )
     if 'transfer' in arguments:
     if 'transfer' in arguments:
-        logger.info(f'{repository}: Transferring archives to repository')
-        borg_transfer.transfer_archives(
-            global_arguments.dry_run,
+        borgmatic.actions.transfer.run_transfer(
             repository,
             repository,
             storage,
             storage,
             local_borg_version,
             local_borg_version,
-            transfer_arguments=arguments['transfer'],
-            local_path=local_path,
-            remote_path=remote_path,
+            arguments['transfer'],
+            global_arguments,
+            local_path,
+            remote_path,
         )
         )
     if 'prune' in arguments:
     if 'prune' in arguments:
-        command.execute_hook(
-            hooks.get('before_prune'),
-            hooks.get('umask'),
+        borgmatic.actions.prune.run_prune(
             config_filename,
             config_filename,
-            'pre-prune',
-            global_arguments.dry_run,
-            **hook_context,
-        )
-        logger.info('{}: Pruning archives{}'.format(repository, dry_run_label))
-        borg_prune.prune_archives(
-            global_arguments.dry_run,
             repository,
             repository,
             storage,
             storage,
             retention,
             retention,
+            hooks,
+            hook_context,
             local_borg_version,
             local_borg_version,
-            local_path=local_path,
-            remote_path=remote_path,
-            stats=arguments['prune'].stats,
-            list_archives=arguments['prune'].list_archives,
-        )
-        command.execute_hook(
-            hooks.get('after_prune'),
-            hooks.get('umask'),
-            config_filename,
-            'post-prune',
-            global_arguments.dry_run,
-            **hook_context,
+            arguments['prune'],
+            global_arguments,
+            dry_run_label,
+            local_path,
+            remote_path,
         )
         )
     if 'compact' in arguments:
     if 'compact' in arguments:
-        command.execute_hook(
-            hooks.get('before_compact'),
-            hooks.get('umask'),
+        borgmatic.actions.compact.run_compact(
             config_filename,
             config_filename,
-            'pre-compact',
-            global_arguments.dry_run,
-        )
-        if borg_feature.available(borg_feature.Feature.COMPACT, local_borg_version):
-            logger.info('{}: Compacting segments{}'.format(repository, dry_run_label))
-            borg_compact.compact_segments(
-                global_arguments.dry_run,
-                repository,
-                storage,
-                local_borg_version,
-                local_path=local_path,
-                remote_path=remote_path,
-                progress=arguments['compact'].progress,
-                cleanup_commits=arguments['compact'].cleanup_commits,
-                threshold=arguments['compact'].threshold,
-            )
-        else:  # pragma: nocover
-            logger.info(
-                '{}: Skipping compact (only available/needed in Borg 1.2+)'.format(repository)
-            )
-        command.execute_hook(
-            hooks.get('after_compact'),
-            hooks.get('umask'),
-            config_filename,
-            'post-compact',
-            global_arguments.dry_run,
+            repository,
+            storage,
+            retention,
+            hooks,
+            hook_context,
+            local_borg_version,
+            arguments['compact'],
+            global_arguments,
+            dry_run_label,
+            local_path,
+            remote_path,
         )
         )
     if 'create' in arguments:
     if 'create' in arguments:
-        command.execute_hook(
-            hooks.get('before_backup'),
-            hooks.get('umask'),
+        yield from borgmatic.actions.create.run_create(
             config_filename,
             config_filename,
-            'pre-backup',
-            global_arguments.dry_run,
-            **hook_context,
-        )
-        logger.info('{}: Creating archive{}'.format(repository, dry_run_label))
-        dispatch.call_hooks_even_if_unconfigured(
-            'remove_database_dumps',
-            hooks,
             repository,
             repository,
-            dump.DATABASE_HOOK_NAMES,
             location,
             location,
-            global_arguments.dry_run,
-        )
-        active_dumps = dispatch.call_hooks(
-            'dump_databases',
+            storage,
             hooks,
             hooks,
-            repository,
-            dump.DATABASE_HOOK_NAMES,
-            location,
-            global_arguments.dry_run,
+            hook_context,
+            local_borg_version,
+            arguments['create'],
+            global_arguments,
+            dry_run_label,
+            local_path,
+            remote_path,
         )
         )
-        stream_processes = [process for processes in active_dumps.values() for process in processes]
-
-        json_output = borg_create.create_archive(
-            global_arguments.dry_run,
+    if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
+        borgmatic.actions.check.run_check(
+            config_filename,
             repository,
             repository,
             location,
             location,
             storage,
             storage,
+            consistency,
+            hooks,
+            hook_context,
             local_borg_version,
             local_borg_version,
-            local_path=local_path,
-            remote_path=remote_path,
-            progress=arguments['create'].progress,
-            stats=arguments['create'].stats,
-            json=arguments['create'].json,
-            list_files=arguments['create'].list_files,
-            stream_processes=stream_processes,
+            arguments['check'],
+            global_arguments,
+            local_path,
+            remote_path,
         )
         )
-        if json_output:  # pragma: nocover
-            yield json.loads(json_output)
-
-        dispatch.call_hooks_even_if_unconfigured(
-            'remove_database_dumps',
-            hooks,
+    if 'extract' in arguments:
+        borgmatic.actions.extract.run_extract(
             config_filename,
             config_filename,
-            dump.DATABASE_HOOK_NAMES,
+            repository,
             location,
             location,
-            global_arguments.dry_run,
+            storage,
+            hooks,
+            hook_context,
+            local_borg_version,
+            arguments['extract'],
+            global_arguments,
+            local_path,
+            remote_path,
         )
         )
-        command.execute_hook(
-            hooks.get('after_backup'),
-            hooks.get('umask'),
-            config_filename,
-            'post-backup',
-            global_arguments.dry_run,
-            **hook_context,
+    if 'export-tar' in arguments:
+        borgmatic.actions.export_tar.run_export_tar(
+            repository,
+            storage,
+            local_borg_version,
+            arguments['export-tar'],
+            global_arguments,
+            local_path,
+            remote_path,
         )
         )
-
-    if 'check' in arguments and checks.repository_enabled_for_checks(repository, consistency):
-        command.execute_hook(
-            hooks.get('before_check'),
-            hooks.get('umask'),
-            config_filename,
-            'pre-check',
-            global_arguments.dry_run,
-            **hook_context,
+    if 'mount' in arguments:
+        borgmatic.actions.mount.run_mount(
+            repository, storage, local_borg_version, arguments['mount'], local_path, remote_path,
         )
         )
-        logger.info('{}: Running consistency checks'.format(repository))
-        borg_check.check_archives(
+    if 'restore' in arguments:
+        borgmatic.actions.restore.run_restore(
             repository,
             repository,
             location,
             location,
             storage,
             storage,
-            consistency,
+            hooks,
             local_borg_version,
             local_borg_version,
-            local_path=local_path,
-            remote_path=remote_path,
-            progress=arguments['check'].progress,
-            repair=arguments['check'].repair,
-            only_checks=arguments['check'].only,
-            force=arguments['check'].force,
-        )
-        command.execute_hook(
-            hooks.get('after_check'),
-            hooks.get('umask'),
-            config_filename,
-            'post-check',
-            global_arguments.dry_run,
-            **hook_context,
-        )
-    if 'extract' in arguments:
-        command.execute_hook(
-            hooks.get('before_extract'),
-            hooks.get('umask'),
-            config_filename,
-            'pre-extract',
-            global_arguments.dry_run,
-            **hook_context,
+            arguments['restore'],
+            global_arguments,
+            local_path,
+            remote_path,
         )
         )
-        if arguments['extract'].repository is None or validate.repositories_match(
-            repository, arguments['extract'].repository
-        ):
-            logger.info(
-                '{}: Extracting archive {}'.format(repository, arguments['extract'].archive)
-            )
-            borg_extract.extract_archive(
-                global_arguments.dry_run,
-                repository,
-                borg_rlist.resolve_archive_name(
-                    repository,
-                    arguments['extract'].archive,
-                    storage,
-                    local_borg_version,
-                    local_path,
-                    remote_path,
-                ),
-                arguments['extract'].paths,
-                location,
-                storage,
-                local_borg_version,
-                local_path=local_path,
-                remote_path=remote_path,
-                destination_path=arguments['extract'].destination,
-                strip_components=arguments['extract'].strip_components,
-                progress=arguments['extract'].progress,
-            )
-        command.execute_hook(
-            hooks.get('after_extract'),
-            hooks.get('umask'),
-            config_filename,
-            'post-extract',
-            global_arguments.dry_run,
-            **hook_context,
-        )
-    if 'export-tar' in arguments:
-        if arguments['export-tar'].repository is None or validate.repositories_match(
-            repository, arguments['export-tar'].repository
-        ):
-            logger.info(
-                '{}: Exporting archive {} as tar file'.format(
-                    repository, arguments['export-tar'].archive
-                )
-            )
-            borg_export_tar.export_tar_archive(
-                global_arguments.dry_run,
-                repository,
-                borg_rlist.resolve_archive_name(
-                    repository,
-                    arguments['export-tar'].archive,
-                    storage,
-                    local_borg_version,
-                    local_path,
-                    remote_path,
-                ),
-                arguments['export-tar'].paths,
-                arguments['export-tar'].destination,
-                storage,
-                local_borg_version,
-                local_path=local_path,
-                remote_path=remote_path,
-                tar_filter=arguments['export-tar'].tar_filter,
-                list_files=arguments['export-tar'].list_files,
-                strip_components=arguments['export-tar'].strip_components,
-            )
-    if 'mount' in arguments:
-        if arguments['mount'].repository is None or validate.repositories_match(
-            repository, arguments['mount'].repository
-        ):
-            if arguments['mount'].archive:
-                logger.info(
-                    '{}: Mounting archive {}'.format(repository, arguments['mount'].archive)
-                )
-            else:  # pragma: nocover
-                logger.info('{}: Mounting repository'.format(repository))
-
-            borg_mount.mount_archive(
-                repository,
-                borg_rlist.resolve_archive_name(
-                    repository,
-                    arguments['mount'].archive,
-                    storage,
-                    local_borg_version,
-                    local_path,
-                    remote_path,
-                ),
-                arguments['mount'].mount_point,
-                arguments['mount'].paths,
-                arguments['mount'].foreground,
-                arguments['mount'].options,
-                storage,
-                local_borg_version,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
-    if 'restore' in arguments:  # pragma: nocover
-        if arguments['restore'].repository is None or validate.repositories_match(
-            repository, arguments['restore'].repository
-        ):
-            logger.info(
-                '{}: Restoring databases from archive {}'.format(
-                    repository, arguments['restore'].archive
-                )
-            )
-            dispatch.call_hooks_even_if_unconfigured(
-                'remove_database_dumps',
-                hooks,
-                repository,
-                dump.DATABASE_HOOK_NAMES,
-                location,
-                global_arguments.dry_run,
-            )
-
-            restore_names = arguments['restore'].databases or []
-            if 'all' in restore_names:
-                restore_names = []
-
-            archive_name = borg_rlist.resolve_archive_name(
-                repository,
-                arguments['restore'].archive,
-                storage,
-                local_borg_version,
-                local_path,
-                remote_path,
-            )
-            found_names = set()
-
-            for hook_name, per_hook_restore_databases in hooks.items():
-                if hook_name not in dump.DATABASE_HOOK_NAMES:
-                    continue
-
-                for restore_database in per_hook_restore_databases:
-                    database_name = restore_database['name']
-                    if restore_names and database_name not in restore_names:
-                        continue
-
-                    found_names.add(database_name)
-                    dump_pattern = dispatch.call_hooks(
-                        'make_database_dump_pattern',
-                        hooks,
-                        repository,
-                        dump.DATABASE_HOOK_NAMES,
-                        location,
-                        database_name,
-                    )[hook_name]
-
-                    # Kick off a single database extract to stdout.
-                    extract_process = borg_extract.extract_archive(
-                        dry_run=global_arguments.dry_run,
-                        repository=repository,
-                        archive=archive_name,
-                        paths=dump.convert_glob_patterns_to_borg_patterns([dump_pattern]),
-                        location_config=location,
-                        storage_config=storage,
-                        local_borg_version=local_borg_version,
-                        local_path=local_path,
-                        remote_path=remote_path,
-                        destination_path='/',
-                        # A directory format dump isn't a single file, and therefore can't extract
-                        # to stdout. In this case, the extract_process return value is None.
-                        extract_to_stdout=bool(restore_database.get('format') != 'directory'),
-                    )
-
-                    # Run a single database restore, consuming the extract stdout (if any).
-                    dispatch.call_hooks(
-                        'restore_database_dump',
-                        {hook_name: [restore_database]},
-                        repository,
-                        dump.DATABASE_HOOK_NAMES,
-                        location,
-                        global_arguments.dry_run,
-                        extract_process,
-                    )
-
-            dispatch.call_hooks_even_if_unconfigured(
-                'remove_database_dumps',
-                hooks,
-                repository,
-                dump.DATABASE_HOOK_NAMES,
-                location,
-                global_arguments.dry_run,
-            )
-
-            if not restore_names and not found_names:
-                raise ValueError('No databases were found to restore')
-
-            missing_names = sorted(set(restore_names) - found_names)
-            if missing_names:
-                raise ValueError(
-                    'Cannot restore database(s) {} missing from borgmatic\'s configuration'.format(
-                        ', '.join(missing_names)
-                    )
-                )
     if 'rlist' in arguments:
     if 'rlist' in arguments:
-        if arguments['rlist'].repository is None or validate.repositories_match(
-            repository, arguments['rlist'].repository
-        ):
-            rlist_arguments = copy.copy(arguments['rlist'])
-            if not rlist_arguments.json:  # pragma: nocover
-                logger.answer('{}: Listing repository'.format(repository))
-            json_output = borg_rlist.list_repository(
-                repository,
-                storage,
-                local_borg_version,
-                rlist_arguments=rlist_arguments,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
-            if json_output:  # pragma: nocover
-                yield json.loads(json_output)
+        yield from borgmatic.actions.rlist.run_rlist(
+            repository, storage, local_borg_version, arguments['rlist'], local_path, remote_path,
+        )
     if 'list' in arguments:
     if 'list' in arguments:
-        if arguments['list'].repository is None or validate.repositories_match(
-            repository, arguments['list'].repository
-        ):
-            list_arguments = copy.copy(arguments['list'])
-            if not list_arguments.json:  # pragma: nocover
-                if list_arguments.find_paths:
-                    logger.answer('{}: Searching archives'.format(repository))
-                elif not list_arguments.archive:
-                    logger.answer('{}: Listing archives'.format(repository))
-            list_arguments.archive = borg_rlist.resolve_archive_name(
-                repository,
-                list_arguments.archive,
-                storage,
-                local_borg_version,
-                local_path,
-                remote_path,
-            )
-            json_output = borg_list.list_archive(
-                repository,
-                storage,
-                local_borg_version,
-                list_arguments=list_arguments,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
-            if json_output:  # pragma: nocover
-                yield json.loads(json_output)
+        yield from borgmatic.actions.list.run_list(
+            repository, storage, local_borg_version, arguments['list'], local_path, remote_path,
+        )
     if 'rinfo' in arguments:
     if 'rinfo' in arguments:
-        if arguments['rinfo'].repository is None or validate.repositories_match(
-            repository, arguments['rinfo'].repository
-        ):
-            rinfo_arguments = copy.copy(arguments['rinfo'])
-            if not rinfo_arguments.json:  # pragma: nocover
-                logger.answer('{}: Displaying repository summary information'.format(repository))
-            json_output = borg_rinfo.display_repository_info(
-                repository,
-                storage,
-                local_borg_version,
-                rinfo_arguments=rinfo_arguments,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
-            if json_output:  # pragma: nocover
-                yield json.loads(json_output)
+        yield from borgmatic.actions.rinfo.run_rinfo(
+            repository, storage, local_borg_version, arguments['rinfo'], local_path, remote_path,
+        )
     if 'info' in arguments:
     if 'info' in arguments:
-        if arguments['info'].repository is None or validate.repositories_match(
-            repository, arguments['info'].repository
-        ):
-            info_arguments = copy.copy(arguments['info'])
-            if not info_arguments.json:  # pragma: nocover
-                logger.answer('{}: Displaying archive summary information'.format(repository))
-            info_arguments.archive = borg_rlist.resolve_archive_name(
-                repository,
-                info_arguments.archive,
-                storage,
-                local_borg_version,
-                local_path,
-                remote_path,
-            )
-            json_output = borg_info.display_archives_info(
-                repository,
-                storage,
-                local_borg_version,
-                info_arguments=info_arguments,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
-            if json_output:  # pragma: nocover
-                yield json.loads(json_output)
+        yield from borgmatic.actions.info.run_info(
+            repository, storage, local_borg_version, arguments['info'], local_path, remote_path,
+        )
     if 'break-lock' in arguments:
     if 'break-lock' in arguments:
-        if arguments['break-lock'].repository is None or validate.repositories_match(
-            repository, arguments['break-lock'].repository
-        ):
-            logger.info(f'{repository}: Breaking repository and cache locks')
-            borg_break_lock.break_lock(
-                repository,
-                storage,
-                local_borg_version,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
+        borgmatic.actions.break_lock.run_break_lock(
+            repository,
+            storage,
+            local_borg_version,
+            arguments['break-lock'],
+            local_path,
+            remote_path,
+        )
     if 'borg' in arguments:
     if 'borg' in arguments:
-        if arguments['borg'].repository is None or validate.repositories_match(
-            repository, arguments['borg'].repository
-        ):
-            logger.info('{}: Running arbitrary Borg command'.format(repository))
-            archive_name = borg_rlist.resolve_archive_name(
-                repository,
-                arguments['borg'].archive,
-                storage,
-                local_borg_version,
-                local_path,
-                remote_path,
-            )
-            borg_borg.run_arbitrary_borg(
-                repository,
-                storage,
-                local_borg_version,
-                options=arguments['borg'].options,
-                archive=archive_name,
-                local_path=local_path,
-                remote_path=remote_path,
-            )
+        borgmatic.actions.borg.run_borg(
+            repository, storage, local_borg_version, arguments['borg'], local_path, remote_path,
+        )
 
 
     command.execute_hook(
     command.execute_hook(
         hooks.get('after_actions'),
         hooks.get('after_actions'),

+ 13 - 0
borgmatic/config/schema.yaml

@@ -855,6 +855,19 @@ properties:
                                 configured to trust the configured username
                                 configured to trust the configured username
                                 without a password.
                                 without a password.
                             example: trustsome1
                             example: trustsome1
+                        format:
+                            type: string
+                            enum: ['sql']
+                            description: |
+                                Database dump output format. Currenly only "sql"
+                                is supported. Defaults to "sql" for a single
+                                database. Or, when database name is "all" and
+                                format is blank, dumps all databases to a single
+                                file. But if a format is specified with an "all"
+                                database name, dumps each database to a separate
+                                file of that format, allowing more convenient
+                                restores of individual databases.
+                            example: directory
                         list_options:
                         list_options:
                             type: string
                             type: string
                             description: |
                             description: |

+ 2 - 1
borgmatic/execute.py

@@ -49,7 +49,8 @@ def log_outputs(processes, exclude_stdouts, output_log_level, borg_local_path):
     '''
     '''
     Given a sequence of subprocess.Popen() instances for multiple processes, log the output for each
     Given a sequence of subprocess.Popen() instances for multiple processes, log the output for each
     process with the requested log level. Additionally, raise a CalledProcessError if a process
     process with the requested log level. Additionally, raise a CalledProcessError if a process
-    exits with an error (or a warning for exit code 1, if that process matches the Borg local path).
+    exits with an error (or a warning for exit code 1, if that process does not match the Borg local
+    path).
 
 
     If output log level is None, then instead of logging, capture output for each process and return
     If output log level is None, then instead of logging, capture output for each process and return
     it as a dict from the process to its output.
     it as a dict from the process to its output.

+ 81 - 41
borgmatic/hooks/mysql.py

@@ -1,4 +1,6 @@
+import copy
 import logging
 import logging
+import os
 
 
 from borgmatic.execute import (
 from borgmatic.execute import (
     execute_command,
     execute_command,
@@ -28,10 +30,8 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run_labe
     In the case of "all", query for the names of databases on the configured host and return them,
     In the case of "all", query for the names of databases on the configured host and return them,
     excluding any system databases that will cause problems during restore.
     excluding any system databases that will cause problems during restore.
     '''
     '''
-    requested_name = database['name']
-
-    if requested_name != 'all':
-        return (requested_name,)
+    if database['name'] != 'all':
+        return (database['name'],)
 
 
     show_command = (
     show_command = (
         ('mysql',)
         ('mysql',)
@@ -57,6 +57,55 @@ def database_names_to_dump(database, extra_environment, log_prefix, dry_run_labe
     )
     )
 
 
 
 
+def execute_dump_command(
+    database, log_prefix, dump_path, database_names, extra_environment, dry_run, dry_run_label
+):
+    '''
+    Kick off a dump for the given MySQL/MariaDB database (provided as a configuration dict) to a
+    named pipe constructed from the given dump path and database names. Use the given log prefix in
+    any log entries.
+
+    Return a subprocess.Popen instance for the dump process ready to spew to a named pipe. But if
+    this is a dry run, then don't actually dump anything and return None.
+    '''
+    database_name = database['name']
+    dump_filename = dump.make_database_dump_filename(
+        dump_path, database['name'], database.get('hostname')
+    )
+    if os.path.exists(dump_filename):
+        logger.warning(
+            f'{log_prefix}: Skipping duplicate dump of MySQL database "{database_name}" to {dump_filename}'
+        )
+        return None
+
+    dump_command = (
+        ('mysqldump',)
+        + (tuple(database['options'].split(' ')) if 'options' in database else ())
+        + ('--add-drop-database',)
+        + (('--host', database['hostname']) if 'hostname' in database else ())
+        + (('--port', str(database['port'])) if 'port' in database else ())
+        + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
+        + (('--user', database['username']) if 'username' in database else ())
+        + ('--databases',)
+        + database_names
+        # Use shell redirection rather than execute_command(output_file=open(...)) to prevent
+        # the open() call on a named pipe from hanging the main borgmatic process.
+        + ('>', dump_filename)
+    )
+
+    logger.debug(
+        f'{log_prefix}: Dumping MySQL database "{database_name}" to {dump_filename}{dry_run_label}'
+    )
+    if dry_run:
+        return None
+
+    dump.create_named_pipe_for_dump(dump_filename)
+
+    return execute_command(
+        dump_command, shell=True, extra_environment=extra_environment, run_to_completion=False,
+    )
+
+
 def dump_databases(databases, log_prefix, location_config, dry_run):
 def dump_databases(databases, log_prefix, location_config, dry_run):
     '''
     '''
     Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
     Dump the given MySQL/MariaDB databases to a named pipe. The databases are supplied as a sequence
@@ -73,10 +122,7 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
     logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label))
     logger.info('{}: Dumping MySQL databases{}'.format(log_prefix, dry_run_label))
 
 
     for database in databases:
     for database in databases:
-        requested_name = database['name']
-        dump_filename = dump.make_database_dump_filename(
-            make_dump_path(location_config), requested_name, database.get('hostname')
-        )
+        dump_path = make_dump_path(location_config)
         extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
         extra_environment = {'MYSQL_PWD': database['password']} if 'password' in database else None
         dump_database_names = database_names_to_dump(
         dump_database_names = database_names_to_dump(
             database, extra_environment, log_prefix, dry_run_label
             database, extra_environment, log_prefix, dry_run_label
@@ -84,41 +130,35 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
         if not dump_database_names:
         if not dump_database_names:
             raise ValueError('Cannot find any MySQL databases to dump.')
             raise ValueError('Cannot find any MySQL databases to dump.')
 
 
-        dump_command = (
-            ('mysqldump',)
-            + (tuple(database['options'].split(' ')) if 'options' in database else ())
-            + ('--add-drop-database',)
-            + (('--host', database['hostname']) if 'hostname' in database else ())
-            + (('--port', str(database['port'])) if 'port' in database else ())
-            + (('--protocol', 'tcp') if 'hostname' in database or 'port' in database else ())
-            + (('--user', database['username']) if 'username' in database else ())
-            + ('--databases',)
-            + dump_database_names
-            # Use shell redirection rather than execute_command(output_file=open(...)) to prevent
-            # the open() call on a named pipe from hanging the main borgmatic process.
-            + ('>', dump_filename)
-        )
-
-        logger.debug(
-            '{}: Dumping MySQL database {} to {}{}'.format(
-                log_prefix, requested_name, dump_filename, dry_run_label
+        if database['name'] == 'all' and database.get('format'):
+            for dump_name in dump_database_names:
+                renamed_database = copy.copy(database)
+                renamed_database['name'] = dump_name
+                processes.append(
+                    execute_dump_command(
+                        renamed_database,
+                        log_prefix,
+                        dump_path,
+                        (dump_name,),
+                        extra_environment,
+                        dry_run,
+                        dry_run_label,
+                    )
+                )
+        else:
+            processes.append(
+                execute_dump_command(
+                    database,
+                    log_prefix,
+                    dump_path,
+                    dump_database_names,
+                    extra_environment,
+                    dry_run,
+                    dry_run_label,
+                )
             )
             )
-        )
-        if dry_run:
-            continue
-
-        dump.create_named_pipe_for_dump(dump_filename)
-
-        processes.append(
-            execute_command(
-                dump_command,
-                shell=True,
-                extra_environment=extra_environment,
-                run_to_completion=False,
-            )
-        )
 
 
-    return processes
+    return [process for process in processes if process]
 
 
 
 
 def remove_database_dumps(databases, log_prefix, location_config, dry_run):  # pragma: no cover
 def remove_database_dumps(databases, log_prefix, location_config, dry_run):  # pragma: no cover

+ 7 - 3
borgmatic/hooks/postgresql.py

@@ -1,5 +1,6 @@
 import csv
 import csv
 import logging
 import logging
+import os
 
 
 from borgmatic.execute import (
 from borgmatic.execute import (
     execute_command,
     execute_command,
@@ -111,6 +112,11 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
             dump_filename = dump.make_database_dump_filename(
             dump_filename = dump.make_database_dump_filename(
                 dump_path, database_name, database.get('hostname')
                 dump_path, database_name, database.get('hostname')
             )
             )
+            if os.path.exists(dump_filename):
+                logger.warning(
+                    f'{log_prefix}: Skipping duplicate dump of PostgreSQL database "{database_name}" to {dump_filename}'
+                )
+                continue
 
 
             command = (
             command = (
                 (dump_command, '--no-password', '--clean', '--if-exists',)
                 (dump_command, '--no-password', '--clean', '--if-exists',)
@@ -128,9 +134,7 @@ def dump_databases(databases, log_prefix, location_config, dry_run):
             )
             )
 
 
             logger.debug(
             logger.debug(
-                '{}: Dumping PostgreSQL database "{}" to {}{}'.format(
-                    log_prefix, database_name, dump_filename, dry_run_label
-                )
+                f'{log_prefix}: Dumping PostgreSQL database "{database_name}" to {dump_filename}{dry_run_label}'
             )
             )
             if dry_run:
             if dry_run:
                 continue
                 continue

+ 10 - 0
tests/end-to-end/test_database.py

@@ -42,6 +42,11 @@ hooks:
           hostname: postgresql
           hostname: postgresql
           username: postgres
           username: postgres
           password: test
           password: test
+        - name: all
+          format: custom
+          hostname: postgresql
+          username: postgres
+          password: test
     mysql_databases:
     mysql_databases:
         - name: test
         - name: test
           hostname: mysql
           hostname: mysql
@@ -51,6 +56,11 @@ hooks:
           hostname: mysql
           hostname: mysql
           username: root
           username: root
           password: test
           password: test
+        - name: all
+          format: sql
+          hostname: mysql
+          username: root
+          password: test
     mongodb_databases:
     mongodb_databases:
         - name: test
         - name: test
           hostname: mongodb
           hostname: mongodb

+ 0 - 0
tests/unit/actions/__init__.py


+ 22 - 0
tests/unit/actions/test_borg.py

@@ -0,0 +1,22 @@
+from flexmock import flexmock
+
+from borgmatic.actions import borg as module
+
+
+def test_run_borg_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.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module.borgmatic.borg.borg).should_receive('run_arbitrary_borg')
+    borg_arguments = flexmock(repository=flexmock(), archive=flexmock(), options=flexmock())
+
+    module.run_borg(
+        repository='repo',
+        storage={},
+        local_borg_version=None,
+        borg_arguments=borg_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 19 - 0
tests/unit/actions/test_break_lock.py

@@ -0,0 +1,19 @@
+from flexmock import flexmock
+
+from borgmatic.actions import break_lock as module
+
+
+def test_run_break_lock_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.break_lock).should_receive('break_lock')
+    break_lock_arguments = flexmock(repository=flexmock())
+
+    module.run_break_lock(
+        repository='repo',
+        storage={},
+        local_borg_version=None,
+        break_lock_arguments=break_lock_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 31 - 0
tests/unit/actions/test_check.py

@@ -0,0 +1,31 @@
+from flexmock import flexmock
+
+from borgmatic.actions import check as module
+
+
+def test_run_check_calls_hooks():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.checks).should_receive(
+        'repository_enabled_for_checks'
+    ).and_return(True)
+    flexmock(module.borgmatic.borg.check).should_receive('check_archives')
+    flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
+    check_arguments = flexmock(
+        progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_check(
+        config_filename='test.yaml',
+        repository='repo',
+        location={'repositories': ['repo']},
+        storage={},
+        consistency={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        check_arguments=check_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 29 - 0
tests/unit/actions/test_compact.py

@@ -0,0 +1,29 @@
+from flexmock import flexmock
+
+from borgmatic.actions import compact as module
+
+
+def test_compact_actions_calls_hooks():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True)
+    flexmock(module.borgmatic.borg.compact).should_receive('compact_segments')
+    flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
+    compact_arguments = flexmock(
+        progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_compact(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        compact_arguments=compact_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )

+ 34 - 0
tests/unit/actions/test_create.py

@@ -0,0 +1,34 @@
+from flexmock import flexmock
+
+from borgmatic.actions import create as module
+
+
+def test_run_create_executes_and_calls_hooks():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.create).should_receive('create_archive')
+    flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks').and_return({})
+    flexmock(module.borgmatic.hooks.dispatch).should_receive(
+        'call_hooks_even_if_unconfigured'
+    ).and_return({})
+    create_arguments = flexmock(
+        progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock()
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    list(
+        module.run_create(
+            config_filename='test.yaml',
+            repository='repo',
+            location={},
+            storage={},
+            hooks={},
+            hook_context={},
+            local_borg_version=None,
+            create_arguments=create_arguments,
+            global_arguments=global_arguments,
+            dry_run_label='',
+            local_path=None,
+            remote_path=None,
+        )
+    )

+ 29 - 0
tests/unit/actions/test_export_tar.py

@@ -0,0 +1,29 @@
+from flexmock import flexmock
+
+from borgmatic.actions import export_tar as module
+
+
+def test_run_export_tar_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.export_tar).should_receive('export_tar_archive')
+    export_tar_arguments = flexmock(
+        repository=flexmock(),
+        archive=flexmock(),
+        paths=flexmock(),
+        destination=flexmock(),
+        tar_filter=flexmock(),
+        list_files=flexmock(),
+        strip_components=flexmock(),
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_export_tar(
+        repository='repo',
+        storage={},
+        local_borg_version=None,
+        export_tar_arguments=export_tar_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 33 - 0
tests/unit/actions/test_extract.py

@@ -0,0 +1,33 @@
+from flexmock import flexmock
+
+from borgmatic.actions import extract as module
+
+
+def test_run_extract_calls_hooks():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.borg.extract).should_receive('extract_archive')
+    flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
+    extract_arguments = flexmock(
+        paths=flexmock(),
+        progress=flexmock(),
+        destination=flexmock(),
+        strip_components=flexmock(),
+        archive=flexmock(),
+        repository='repo',
+    )
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_extract(
+        config_filename='test.yaml',
+        repository='repo',
+        location={'repositories': ['repo']},
+        storage={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        extract_arguments=extract_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 24 - 0
tests/unit/actions/test_info.py

@@ -0,0 +1,24 @@
+from flexmock import flexmock
+
+from borgmatic.actions import info as module
+
+
+def test_run_info_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.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module.borgmatic.borg.info).should_receive('display_archives_info')
+    info_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=flexmock())
+
+    list(
+        module.run_info(
+            repository='repo',
+            storage={},
+            local_borg_version=None,
+            info_arguments=info_arguments,
+            local_path=None,
+            remote_path=None,
+        )
+    )

+ 24 - 0
tests/unit/actions/test_list.py

@@ -0,0 +1,24 @@
+from flexmock import flexmock
+
+from borgmatic.actions import list as module
+
+
+def test_run_list_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.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module.borgmatic.borg.list).should_receive('list_archive')
+    list_arguments = flexmock(repository=flexmock(), archive=flexmock(), json=flexmock())
+
+    list(
+        module.run_list(
+            repository='repo',
+            storage={},
+            local_borg_version=None,
+            list_arguments=list_arguments,
+            local_path=None,
+            remote_path=None,
+        )
+    )

+ 26 - 0
tests/unit/actions/test_mount.py

@@ -0,0 +1,26 @@
+from flexmock import flexmock
+
+from borgmatic.actions import mount as module
+
+
+def test_run_mount_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.mount).should_receive('mount_archive')
+    mount_arguments = flexmock(
+        repository=flexmock(),
+        archive=flexmock(),
+        mount_point=flexmock(),
+        paths=flexmock(),
+        foreground=flexmock(),
+        options=flexmock(),
+    )
+
+    module.run_mount(
+        repository='repo',
+        storage={},
+        local_borg_version=None,
+        mount_arguments=mount_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 26 - 0
tests/unit/actions/test_prune.py

@@ -0,0 +1,26 @@
+from flexmock import flexmock
+
+from borgmatic.actions import prune as module
+
+
+def test_run_prune_calls_hooks():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.prune).should_receive('prune_archives')
+    flexmock(module.borgmatic.hooks.command).should_receive('execute_hook').times(2)
+    prune_arguments = flexmock(stats=flexmock(), list_archives=flexmock())
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_prune(
+        config_filename='test.yaml',
+        repository='repo',
+        storage={},
+        retention={},
+        hooks={},
+        hook_context={},
+        local_borg_version=None,
+        prune_arguments=prune_arguments,
+        global_arguments=global_arguments,
+        dry_run_label='',
+        local_path=None,
+        remote_path=None,
+    )

+ 26 - 0
tests/unit/actions/test_rcreate.py

@@ -0,0 +1,26 @@
+from flexmock import flexmock
+
+from borgmatic.actions import rcreate as module
+
+
+def test_run_rcreate_does_not_raise():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.rcreate).should_receive('create_repository')
+    arguments = flexmock(
+        encryption_mode=flexmock(),
+        source_repository=flexmock(),
+        copy_crypt_key=flexmock(),
+        append_only=flexmock(),
+        storage_quota=flexmock(),
+        make_parent_dirs=flexmock(),
+    )
+
+    module.run_rcreate(
+        repository='repo',
+        storage={},
+        local_borg_version=None,
+        rcreate_arguments=arguments,
+        global_arguments=flexmock(dry_run=False),
+        local_path=None,
+        remote_path=None,
+    )

+ 495 - 0
tests/unit/actions/test_restore.py

@@ -0,0 +1,495 @@
+import pytest
+from flexmock import flexmock
+
+import borgmatic.actions.restore as module
+
+
+def test_get_configured_database_matches_database_by_name():
+    assert module.get_configured_database(
+        hooks={
+            'other_databases': [{'name': 'other'}],
+            'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}],
+        },
+        archive_database_names={'postgresql_databases': ['other', 'foo', 'bar']},
+        hook_name='postgresql_databases',
+        database_name='bar',
+    ) == ('postgresql_databases', {'name': 'bar'})
+
+
+def test_get_configured_database_matches_nothing_when_database_name_not_configured():
+    assert module.get_configured_database(
+        hooks={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]},
+        archive_database_names={'postgresql_databases': ['foo']},
+        hook_name='postgresql_databases',
+        database_name='quux',
+    ) == (None, None)
+
+
+def test_get_configured_database_matches_nothing_when_database_name_not_in_archive():
+    assert module.get_configured_database(
+        hooks={'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}]},
+        archive_database_names={'postgresql_databases': ['bar']},
+        hook_name='postgresql_databases',
+        database_name='foo',
+    ) == (None, None)
+
+
+def test_get_configured_database_matches_database_by_configuration_database_name():
+    assert module.get_configured_database(
+        hooks={'postgresql_databases': [{'name': 'all'}, {'name': 'bar'}]},
+        archive_database_names={'postgresql_databases': ['foo']},
+        hook_name='postgresql_databases',
+        database_name='foo',
+        configuration_database_name='all',
+    ) == ('postgresql_databases', {'name': 'all'})
+
+
+def test_get_configured_database_with_unspecified_hook_matches_database_by_name():
+    assert module.get_configured_database(
+        hooks={
+            'other_databases': [{'name': 'other'}],
+            'postgresql_databases': [{'name': 'foo'}, {'name': 'bar'}],
+        },
+        archive_database_names={'postgresql_databases': ['other', 'foo', 'bar']},
+        hook_name=module.UNSPECIFIED_HOOK,
+        database_name='bar',
+    ) == ('postgresql_databases', {'name': 'bar'})
+
+
+def test_collect_archive_database_names_parses_archive_paths():
+    flexmock(module.borgmatic.hooks.dump).should_receive('make_database_dump_path').and_return('')
+    flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        [
+            '.borgmatic/postgresql_databases/localhost/foo',
+            '.borgmatic/postgresql_databases/localhost/bar',
+            '.borgmatic/mysql_databases/localhost/quux',
+        ]
+    )
+
+    archive_database_names = module.collect_archive_database_names(
+        repository='repo',
+        archive='archive',
+        location={'borgmatic_source_directory': '.borgmatic'},
+        storage=flexmock(),
+        local_borg_version=flexmock(),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+    assert archive_database_names == {
+        'postgresql_databases': ['foo', 'bar'],
+        'mysql_databases': ['quux'],
+    }
+
+
+def test_collect_archive_database_names_parses_directory_format_archive_paths():
+    flexmock(module.borgmatic.hooks.dump).should_receive('make_database_dump_path').and_return('')
+    flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        [
+            '.borgmatic/postgresql_databases/localhost/foo/table1',
+            '.borgmatic/postgresql_databases/localhost/foo/table2',
+        ]
+    )
+
+    archive_database_names = module.collect_archive_database_names(
+        repository='repo',
+        archive='archive',
+        location={'borgmatic_source_directory': '.borgmatic'},
+        storage=flexmock(),
+        local_borg_version=flexmock(),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+    assert archive_database_names == {
+        'postgresql_databases': ['foo'],
+    }
+
+
+def test_collect_archive_database_names_skips_bad_archive_paths():
+    flexmock(module.borgmatic.hooks.dump).should_receive('make_database_dump_path').and_return('')
+    flexmock(module.borgmatic.borg.list).should_receive('capture_archive_listing').and_return(
+        ['.borgmatic/postgresql_databases/localhost/foo', '.borgmatic/invalid', 'invalid/as/well']
+    )
+
+    archive_database_names = module.collect_archive_database_names(
+        repository='repo',
+        archive='archive',
+        location={'borgmatic_source_directory': '.borgmatic'},
+        storage=flexmock(),
+        local_borg_version=flexmock(),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+    assert archive_database_names == {
+        'postgresql_databases': ['foo'],
+    }
+
+
+def test_find_databases_to_restore_passes_through_requested_names_found_in_archive():
+    restore_names = module.find_databases_to_restore(
+        requested_database_names=['foo', 'bar'],
+        archive_database_names={'postresql_databases': ['foo', 'bar', 'baz']},
+    )
+
+    assert restore_names == {module.UNSPECIFIED_HOOK: ['foo', 'bar']}
+
+
+def test_find_databases_to_restore_raises_for_requested_names_missing_from_archive():
+    with pytest.raises(ValueError):
+        module.find_databases_to_restore(
+            requested_database_names=['foo', 'bar'],
+            archive_database_names={'postresql_databases': ['foo']},
+        )
+
+
+def test_find_databases_to_restore_without_requested_names_finds_all_archive_databases():
+    archive_database_names = {'postresql_databases': ['foo', 'bar']}
+
+    restore_names = module.find_databases_to_restore(
+        requested_database_names=[], archive_database_names=archive_database_names,
+    )
+
+    assert restore_names == archive_database_names
+
+
+def test_find_databases_to_restore_with_all_in_requested_names_finds_all_archive_databases():
+    archive_database_names = {'postresql_databases': ['foo', 'bar']}
+
+    restore_names = module.find_databases_to_restore(
+        requested_database_names=['all'], archive_database_names=archive_database_names,
+    )
+
+    assert restore_names == archive_database_names
+
+
+def test_find_databases_to_restore_with_all_in_requested_names_plus_additional_requested_names_omits_duplicates():
+    archive_database_names = {'postresql_databases': ['foo', 'bar']}
+
+    restore_names = module.find_databases_to_restore(
+        requested_database_names=['all', 'foo', 'bar'],
+        archive_database_names=archive_database_names,
+    )
+
+    assert restore_names == archive_database_names
+
+
+def test_find_databases_to_restore_raises_for_all_in_requested_names_and_requested_named_missing_from_archives():
+    with pytest.raises(ValueError):
+        module.find_databases_to_restore(
+            requested_database_names=['all', 'foo', 'bar'],
+            archive_database_names={'postresql_databases': ['foo']},
+        )
+
+
+def test_ensure_databases_found_with_all_databases_found_does_not_raise():
+    module.ensure_databases_found(
+        restore_names={'postgresql_databases': ['foo']},
+        remaining_restore_names={'postgresql_databases': ['bar']},
+        found_names=['foo', 'bar'],
+    )
+
+
+def test_ensure_databases_found_with_no_databases_raises():
+    with pytest.raises(ValueError):
+        module.ensure_databases_found(
+            restore_names={'postgresql_databases': []}, remaining_restore_names={}, found_names=[],
+        )
+
+
+def test_ensure_databases_found_with_missing_databases_raises():
+    with pytest.raises(ValueError):
+        module.ensure_databases_found(
+            restore_names={'postgresql_databases': ['foo']},
+            remaining_restore_names={'postgresql_databases': ['bar']},
+            found_names=['foo'],
+        )
+
+
+def test_run_restore_restores_each_database():
+    restore_names = {
+        'postgresql_databases': ['foo', 'bar'],
+    }
+
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured')
+    flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
+    flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
+    flexmock(module).should_receive('get_configured_database').and_return(
+        ('postgresql_databases', {'name': 'foo'})
+    ).and_return(('postgresql_databases', {'name': 'bar'}))
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'foo'},
+    ).once()
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'bar'},
+    ).once()
+    flexmock(module).should_receive('ensure_databases_found')
+
+    module.run_restore(
+        repository='repo',
+        location=flexmock(),
+        storage=flexmock(),
+        hooks=flexmock(),
+        local_borg_version=flexmock(),
+        restore_arguments=flexmock(repository='repo', archive='archive', databases=flexmock()),
+        global_arguments=flexmock(dry_run=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+
+def test_run_restore_bails_for_non_matching_repository():
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(
+        False
+    )
+    flexmock(module.borgmatic.hooks.dispatch).should_receive(
+        'call_hooks_even_if_unconfigured'
+    ).never()
+    flexmock(module).should_receive('restore_single_database').never()
+
+    module.run_restore(
+        repository='repo',
+        location=flexmock(),
+        storage=flexmock(),
+        hooks=flexmock(),
+        local_borg_version=flexmock(),
+        restore_arguments=flexmock(repository='repo', archive='archive', databases=flexmock()),
+        global_arguments=flexmock(dry_run=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+
+def test_run_restore_restores_database_configured_with_all_name():
+    restore_names = {
+        'postgresql_databases': ['foo', 'bar'],
+    }
+
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured')
+    flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
+    flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='foo',
+    ).and_return(('postgresql_databases', {'name': 'foo'}))
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='bar',
+    ).and_return((None, None))
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='bar',
+        configuration_database_name='all',
+    ).and_return(('postgresql_databases', {'name': 'bar'}))
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'foo'},
+    ).once()
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'bar'},
+    ).once()
+    flexmock(module).should_receive('ensure_databases_found')
+
+    module.run_restore(
+        repository='repo',
+        location=flexmock(),
+        storage=flexmock(),
+        hooks=flexmock(),
+        local_borg_version=flexmock(),
+        restore_arguments=flexmock(repository='repo', archive='archive', databases=flexmock()),
+        global_arguments=flexmock(dry_run=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+
+def test_run_restore_skips_missing_database():
+    restore_names = {
+        'postgresql_databases': ['foo', 'bar'],
+    }
+
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured')
+    flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
+    flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='foo',
+    ).and_return(('postgresql_databases', {'name': 'foo'}))
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='bar',
+    ).and_return((None, None))
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='bar',
+        configuration_database_name='all',
+    ).and_return((None, None))
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'foo'},
+    ).once()
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'bar'},
+    ).never()
+    flexmock(module).should_receive('ensure_databases_found')
+
+    module.run_restore(
+        repository='repo',
+        location=flexmock(),
+        storage=flexmock(),
+        hooks=flexmock(),
+        local_borg_version=flexmock(),
+        restore_arguments=flexmock(repository='repo', archive='archive', databases=flexmock()),
+        global_arguments=flexmock(dry_run=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )
+
+
+def test_run_restore_restores_databases_from_different_hooks():
+    restore_names = {
+        'postgresql_databases': ['foo'],
+        'mysql_databases': ['bar'],
+    }
+
+    flexmock(module.borgmatic.config.validate).should_receive('repositories_match').and_return(True)
+    flexmock(module.borgmatic.hooks.dispatch).should_receive('call_hooks_even_if_unconfigured')
+    flexmock(module.borgmatic.borg.rlist).should_receive('resolve_archive_name').and_return(
+        flexmock()
+    )
+    flexmock(module).should_receive('collect_archive_database_names').and_return(flexmock())
+    flexmock(module).should_receive('find_databases_to_restore').and_return(restore_names)
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='postgresql_databases',
+        database_name='foo',
+    ).and_return(('postgresql_databases', {'name': 'foo'}))
+    flexmock(module).should_receive('get_configured_database').with_args(
+        hooks=object,
+        archive_database_names=object,
+        hook_name='mysql_databases',
+        database_name='bar',
+    ).and_return(('mysql_databases', {'name': 'bar'}))
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='postgresql_databases',
+        database={'name': 'foo'},
+    ).once()
+    flexmock(module).should_receive('restore_single_database').with_args(
+        repository=object,
+        location=object,
+        storage=object,
+        hooks=object,
+        local_borg_version=object,
+        global_arguments=object,
+        local_path=object,
+        remote_path=object,
+        archive_name=object,
+        hook_name='mysql_databases',
+        database={'name': 'bar'},
+    ).once()
+    flexmock(module).should_receive('ensure_databases_found')
+
+    module.run_restore(
+        repository='repo',
+        location=flexmock(),
+        storage=flexmock(),
+        hooks=flexmock(),
+        local_borg_version=flexmock(),
+        restore_arguments=flexmock(repository='repo', archive='archive', databases=flexmock()),
+        global_arguments=flexmock(dry_run=False),
+        local_path=flexmock(),
+        remote_path=flexmock(),
+    )

+ 21 - 0
tests/unit/actions/test_rinfo.py

@@ -0,0 +1,21 @@
+from flexmock import flexmock
+
+from borgmatic.actions import rinfo as module
+
+
+def test_run_rinfo_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.rinfo).should_receive('display_repository_info')
+    rinfo_arguments = flexmock(repository=flexmock(), json=flexmock())
+
+    list(
+        module.run_rinfo(
+            repository='repo',
+            storage={},
+            local_borg_version=None,
+            rinfo_arguments=rinfo_arguments,
+            local_path=None,
+            remote_path=None,
+        )
+    )

+ 21 - 0
tests/unit/actions/test_rlist.py

@@ -0,0 +1,21 @@
+from flexmock import flexmock
+
+from borgmatic.actions import rlist as module
+
+
+def test_run_rlist_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.rlist).should_receive('list_repository')
+    rlist_arguments = flexmock(repository=flexmock(), json=flexmock())
+
+    list(
+        module.run_rlist(
+            repository='repo',
+            storage={},
+            local_borg_version=None,
+            rlist_arguments=rlist_arguments,
+            local_path=None,
+            remote_path=None,
+        )
+    )

+ 20 - 0
tests/unit/actions/test_transfer.py

@@ -0,0 +1,20 @@
+from flexmock import flexmock
+
+from borgmatic.actions import transfer as module
+
+
+def test_run_transfer_does_not_raise():
+    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.borgmatic.borg.transfer).should_receive('transfer_archives')
+    transfer_arguments = flexmock()
+    global_arguments = flexmock(monitoring_verbosity=1, dry_run=False)
+
+    module.run_transfer(
+        repository='repo',
+        storage={},
+        local_borg_version=None,
+        transfer_arguments=transfer_arguments,
+        global_arguments=global_arguments,
+        local_path=None,
+        remote_path=None,
+    )

+ 13 - 0
tests/unit/borg/test_list.py

@@ -253,6 +253,19 @@ def test_make_find_paths_adds_globs_to_path_fragments():
     assert module.make_find_paths(('foo.txt',)) == ('sh:**/*foo.txt*/**',)
     assert module.make_find_paths(('foo.txt',)) == ('sh:**/*foo.txt*/**',)
 
 
 
 
+def test_capture_archive_listing_does_not_raise():
+    flexmock(module.environment).should_receive('make_environment')
+    flexmock(module).should_receive('execute_command_and_capture_output').and_return('')
+    flexmock(module).should_receive('make_list_command')
+
+    module.capture_archive_listing(
+        repository='repo',
+        archive='archive',
+        storage_config=flexmock(),
+        local_borg_version=flexmock(),
+    )
+
+
 def test_list_archive_calls_borg_with_parameters():
 def test_list_archive_calls_borg_with_parameters():
     flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
     flexmock(module.borgmatic.logger).should_receive('add_custom_log_levels')
     flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER
     flexmock(module.logging).ANSWER = module.borgmatic.logger.ANSWER

+ 250 - 318
tests/unit/commands/test_borgmatic.py

@@ -357,455 +357,387 @@ def test_run_configuration_retries_timeout_multiple_repos():
     assert results == error_logs
     assert results == error_logs
 
 
 
 
-def test_run_actions_does_not_raise_for_rcreate_action():
+def test_run_actions_runs_rcreate():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borg_rcreate).should_receive('create_repository')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'rcreate': flexmock(
-            encryption_mode=flexmock(),
-            source_repository=flexmock(),
-            copy_crypt_key=flexmock(),
-            append_only=flexmock(),
-            storage_quota=flexmock(),
-            make_parent_dirs=flexmock(),
-        ),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.rcreate).should_receive('run_rcreate').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'rcreate': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_does_not_raise_for_transfer_action():
+def test_run_actions_runs_transfer():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borg_transfer).should_receive('transfer_archives')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'transfer': flexmock(),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.transfer).should_receive('run_transfer').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'transfer': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_calls_hooks_for_prune_action():
+def test_run_actions_runs_prune():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borg_prune).should_receive('prune_archives')
-    flexmock(module.command).should_receive('execute_hook').times(
-        4
-    )  # Before/after extract and before/after actions.
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'prune': flexmock(stats=flexmock(), list_archives=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.prune).should_receive('run_prune').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'prune': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_calls_hooks_for_compact_action():
+def test_run_actions_runs_compact():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borg_feature).should_receive('available').and_return(True)
-    flexmock(module.borg_compact).should_receive('compact_segments')
-    flexmock(module.command).should_receive('execute_hook').times(
-        4
-    )  # Before/after extract and before/after actions.
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'compact': flexmock(progress=flexmock(), cleanup_commits=flexmock(), threshold=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.compact).should_receive('run_compact').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'compact': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_executes_and_calls_hooks_for_create_action():
+def test_run_actions_runs_create():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.borg_create).should_receive('create_archive')
-    flexmock(module.command).should_receive('execute_hook').times(
-        4
-    )  # Before/after extract and before/after actions.
-    flexmock(module.dispatch).should_receive('call_hooks').and_return({})
-    flexmock(module.dispatch).should_receive('call_hooks_even_if_unconfigured').and_return({})
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'create': flexmock(
-            progress=flexmock(), stats=flexmock(), json=flexmock(), list_files=flexmock()
-        ),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    expected = flexmock()
+    flexmock(borgmatic.actions.create).should_receive('run_create').and_yield(expected).once()
 
 
-    list(
+    result = tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'create': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
+    assert result == (expected,)
 
 
 
 
-def test_run_actions_calls_hooks_for_check_action():
+def test_run_actions_runs_check_when_repository_enabled_for_checks():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
+    flexmock(module.command).should_receive('execute_hook')
     flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
     flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(True)
-    flexmock(module.borg_check).should_receive('check_archives')
-    flexmock(module.command).should_receive('execute_hook').times(
-        4
-    )  # Before/after extract and before/after actions.
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'check': flexmock(
-            progress=flexmock(), repair=flexmock(), only=flexmock(), force=flexmock()
-        ),
-    }
+    flexmock(borgmatic.actions.check).should_receive('run_check').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'check': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_calls_hooks_for_extract_action():
+def test_run_actions_skips_check_when_repository_not_enabled_for_checks():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_extract).should_receive('extract_archive')
-    flexmock(module.command).should_receive('execute_hook').times(
-        4
-    )  # Before/after extract and before/after actions.
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'extract': flexmock(
-            paths=flexmock(),
-            progress=flexmock(),
-            destination=flexmock(),
-            strip_components=flexmock(),
-            archive=flexmock(),
-            repository='repo',
-        ),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(module.checks).should_receive('repository_enabled_for_checks').and_return(False)
+    flexmock(borgmatic.actions.check).should_receive('run_check').never()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'check': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_does_not_raise_for_export_tar_action():
+def test_run_actions_runs_extract():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_export_tar).should_receive('export_tar_archive')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'export-tar': flexmock(
-            repository=flexmock(),
-            archive=flexmock(),
-            paths=flexmock(),
-            destination=flexmock(),
-            tar_filter=flexmock(),
-            list_files=flexmock(),
-            strip_components=flexmock(),
-        ),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.extract).should_receive('run_extract').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'extract': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_does_not_raise_for_mount_action():
+def test_run_actions_runs_export_tar():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_mount).should_receive('mount_archive')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'mount': flexmock(
-            repository=flexmock(),
-            archive=flexmock(),
-            mount_point=flexmock(),
-            paths=flexmock(),
-            foreground=flexmock(),
-            options=flexmock(),
-        ),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.export_tar).should_receive('run_export_tar').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'export-tar': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_does_not_raise_for_rlist_action():
+def test_run_actions_runs_mount():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_rlist).should_receive('list_repository')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'rlist': flexmock(repository=flexmock(), json=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.mount).should_receive('run_mount').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'mount': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_does_not_raise_for_list_action():
+def test_run_actions_runs_restore():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_rlist).should_receive('resolve_archive_name').and_return(flexmock())
-    flexmock(module.borg_list).should_receive('list_archive')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'list': flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.restore).should_receive('run_restore').once()
 
 
-    list(
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'restore': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
 
 
 
 
-def test_run_actions_does_not_raise_for_rinfo_action():
+def test_run_actions_runs_rlist():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_rinfo).should_receive('display_repository_info')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'rinfo': flexmock(repository=flexmock(), json=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    expected = flexmock()
+    flexmock(borgmatic.actions.rlist).should_receive('run_rlist').and_yield(expected).once()
 
 
-    list(
+    result = tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'rlist': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
+    assert result == (expected,)
 
 
 
 
-def test_run_actions_does_not_raise_for_info_action():
+def test_run_actions_runs_list():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_rlist).should_receive('resolve_archive_name').and_return(flexmock())
-    flexmock(module.borg_info).should_receive('display_archives_info')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'info': flexmock(repository=flexmock(), archive=flexmock(), json=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    expected = flexmock()
+    flexmock(borgmatic.actions.list).should_receive('run_list').and_yield(expected).once()
 
 
-    list(
+    result = tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'list': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
+    assert result == (expected,)
 
 
 
 
-def test_run_actions_does_not_raise_for_break_lock_action():
+def test_run_actions_runs_rinfo():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_break_lock).should_receive('break_lock')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'break-lock': flexmock(repository=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    expected = flexmock()
+    flexmock(borgmatic.actions.rinfo).should_receive('run_rinfo').and_yield(expected).once()
 
 
-    list(
+    result = tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'rinfo': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )
+    assert result == (expected,)
 
 
 
 
-def test_run_actions_does_not_raise_for_borg_action():
+def test_run_actions_runs_info():
     flexmock(module).should_receive('add_custom_log_levels')
     flexmock(module).should_receive('add_custom_log_levels')
-    flexmock(module.logger).answer = lambda message: None
-    flexmock(module.validate).should_receive('repositories_match').and_return(True)
-    flexmock(module.borg_rlist).should_receive('resolve_archive_name').and_return(flexmock())
-    flexmock(module.borg_borg).should_receive('run_arbitrary_borg')
-    arguments = {
-        'global': flexmock(monitoring_verbosity=1, dry_run=False),
-        'borg': flexmock(repository=flexmock(), archive=flexmock(), options=flexmock()),
-    }
+    flexmock(module.command).should_receive('execute_hook')
+    expected = flexmock()
+    flexmock(borgmatic.actions.info).should_receive('run_info').and_yield(expected).once()
+
+    result = tuple(
+        module.run_actions(
+            arguments={'global': flexmock(dry_run=False), 'info': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
+            hooks={},
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
+            repository_path='repo',
+        )
+    )
+    assert result == (expected,)
+
 
 
-    list(
+def test_run_actions_runs_break_lock():
+    flexmock(module).should_receive('add_custom_log_levels')
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.break_lock).should_receive('run_break_lock').once()
+
+    tuple(
+        module.run_actions(
+            arguments={'global': flexmock(dry_run=False), 'break-lock': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
+            hooks={},
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
+            repository_path='repo',
+        )
+    )
+
+
+def test_run_actions_runs_borg():
+    flexmock(module).should_receive('add_custom_log_levels')
+    flexmock(module.command).should_receive('execute_hook')
+    flexmock(borgmatic.actions.borg).should_receive('run_borg').once()
+
+    tuple(
         module.run_actions(
         module.run_actions(
-            arguments=arguments,
-            config_filename='test.yaml',
-            location={'repositories': ['repo']},
-            storage={},
-            retention={},
-            consistency={},
+            arguments={'global': flexmock(dry_run=False), 'borg': flexmock()},
+            config_filename=flexmock(),
+            location={'repositories': []},
+            storage=flexmock(),
+            retention=flexmock(),
+            consistency=flexmock(),
             hooks={},
             hooks={},
-            local_path=None,
-            remote_path=None,
-            local_borg_version=None,
+            local_path=flexmock(),
+            remote_path=flexmock(),
+            local_borg_version=flexmock(),
             repository_path='repo',
             repository_path='repo',
         )
         )
     )
     )

+ 185 - 105
tests/unit/hooks/test_mysql.py

@@ -34,59 +34,135 @@ def test_database_names_to_dump_queries_mysql_for_database_names():
     assert names == ('foo', 'bar')
     assert names == ('foo', 'bar')
 
 
 
 
-def test_dump_databases_runs_mysqldump_for_each_database():
+def test_dump_databases_dumps_each_database():
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     processes = [flexmock(), flexmock()]
     processes = [flexmock(), flexmock()]
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
-    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
-        'databases/localhost/foo'
-    ).and_return('databases/localhost/bar')
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
         ('bar',)
         ('bar',)
     )
     )
-    flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     for name, process in zip(('foo', 'bar'), processes):
     for name, process in zip(('foo', 'bar'), processes):
-        flexmock(module).should_receive('execute_command').with_args(
-            (
-                'mysqldump',
-                '--add-drop-database',
-                '--databases',
-                name,
-                '>',
-                'databases/localhost/{}'.format(name),
-            ),
-            shell=True,
-            extra_environment=None,
-            run_to_completion=False,
+        flexmock(module).should_receive('execute_dump_command').with_args(
+            database={'name': name},
+            log_prefix=object,
+            dump_path=object,
+            database_names=(name,),
+            extra_environment=object,
+            dry_run=object,
+            dry_run_label=object,
         ).and_return(process).once()
         ).and_return(process).once()
 
 
     assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
     assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
 
 
 
 
-def test_dump_databases_with_dry_run_skips_mysqldump():
-    databases = [{'name': 'foo'}, {'name': 'bar'}]
+def test_dump_databases_dumps_with_password():
+    database = {'name': 'foo', 'username': 'root', 'password': 'trustsome1'}
+    process = flexmock()
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
-    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
-        'databases/localhost/foo'
-    ).and_return('databases/localhost/bar')
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
     flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
         ('bar',)
         ('bar',)
     )
     )
-    flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
-    flexmock(module).should_receive('execute_command').never()
 
 
-    module.dump_databases(databases, 'test.yaml', {}, dry_run=True)
+    flexmock(module).should_receive('execute_dump_command').with_args(
+        database=database,
+        log_prefix=object,
+        dump_path=object,
+        database_names=('foo',),
+        extra_environment={'MYSQL_PWD': 'trustsome1'},
+        dry_run=object,
+        dry_run_label=object,
+    ).and_return(process).once()
+
+    assert module.dump_databases([database], 'test.yaml', {}, dry_run=False) == [process]
 
 
 
 
-def test_dump_databases_runs_mysqldump_with_hostname_and_port():
-    databases = [{'name': 'foo', 'hostname': 'database.example.org', 'port': 5433}]
+def test_dump_databases_dumps_all_databases_at_once():
+    databases = [{'name': 'all'}]
     process = flexmock()
     process = flexmock()
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
-    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
-        'databases/database.example.org/foo'
+    flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
+    flexmock(module).should_receive('execute_dump_command').with_args(
+        database={'name': 'all'},
+        log_prefix=object,
+        dump_path=object,
+        database_names=('foo', 'bar'),
+        extra_environment=object,
+        dry_run=object,
+        dry_run_label=object,
+    ).and_return(process).once()
+
+    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
+
+
+def test_dump_databases_dumps_all_databases_separately_when_format_configured():
+    databases = [{'name': 'all', 'format': 'sql'}]
+    processes = [flexmock(), flexmock()]
+    flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
+
+    for name, process in zip(('foo', 'bar'), processes):
+        flexmock(module).should_receive('execute_dump_command').with_args(
+            database={'name': name, 'format': 'sql'},
+            log_prefix=object,
+            dump_path=object,
+            database_names=(name,),
+            extra_environment=object,
+            dry_run=object,
+            dry_run_label=object,
+        ).and_return(process).once()
+
+    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
+
+
+def test_database_names_to_dump_runs_mysql_with_list_options():
+    database = {'name': 'all', 'list_options': '--defaults-extra-file=my.cnf'}
+    flexmock(module).should_receive('execute_command_and_capture_output').with_args(
+        (
+            'mysql',
+            '--defaults-extra-file=my.cnf',
+            '--skip-column-names',
+            '--batch',
+            '--execute',
+            'show schemas',
+        ),
+        extra_environment=None,
+    ).and_return(('foo\nbar')).once()
+
+    assert module.database_names_to_dump(database, None, 'test.yaml', '') == ('foo', 'bar')
+
+
+def test_execute_dump_command_runs_mysqldump():
+    process = flexmock()
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.dump).should_receive('create_named_pipe_for_dump')
+
+    flexmock(module).should_receive('execute_command').with_args(
+        ('mysqldump', '--add-drop-database', '--databases', 'foo', '>', 'dump',),
+        shell=True,
+        extra_environment=None,
+        run_to_completion=False,
+    ).and_return(process).once()
+
+    assert (
+        module.execute_dump_command(
+            database={'name': 'foo'},
+            log_prefix='log',
+            dump_path=flexmock(),
+            database_names=('foo',),
+            extra_environment=None,
+            dry_run=False,
+            dry_run_label='',
+        )
+        == process
     )
     )
-    flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
+
+
+def test_execute_dump_command_runs_mysqldump_with_hostname_and_port():
+    process = flexmock()
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -102,116 +178,120 @@ def test_dump_databases_runs_mysqldump_with_hostname_and_port():
             '--databases',
             '--databases',
             'foo',
             'foo',
             '>',
             '>',
-            'databases/database.example.org/foo',
+            'dump',
         ),
         ),
         shell=True,
         shell=True,
         extra_environment=None,
         extra_environment=None,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
 
 
-    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
+    assert (
+        module.execute_dump_command(
+            database={'name': 'foo', 'hostname': 'database.example.org', 'port': 5433},
+            log_prefix='log',
+            dump_path=flexmock(),
+            database_names=('foo',),
+            extra_environment=None,
+            dry_run=False,
+            dry_run_label='',
+        )
+        == process
+    )
 
 
 
 
-def test_dump_databases_runs_mysqldump_with_username_and_password():
-    databases = [{'name': 'foo', 'username': 'root', 'password': 'trustsome1'}]
+def test_execute_dump_command_runs_mysqldump_with_username_and_password():
     process = flexmock()
     process = flexmock()
-    flexmock(module).should_receive('make_dump_path').and_return('')
-    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
-        'databases/localhost/foo'
-    )
-    flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
-        (
-            'mysqldump',
-            '--add-drop-database',
-            '--user',
-            'root',
-            '--databases',
-            'foo',
-            '>',
-            'databases/localhost/foo',
-        ),
+        ('mysqldump', '--add-drop-database', '--user', 'root', '--databases', 'foo', '>', 'dump',),
         shell=True,
         shell=True,
         extra_environment={'MYSQL_PWD': 'trustsome1'},
         extra_environment={'MYSQL_PWD': 'trustsome1'},
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
 
 
-    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
+    assert (
+        module.execute_dump_command(
+            database={'name': 'foo', 'username': 'root', 'password': 'trustsome1'},
+            log_prefix='log',
+            dump_path=flexmock(),
+            database_names=('foo',),
+            extra_environment={'MYSQL_PWD': 'trustsome1'},
+            dry_run=False,
+            dry_run_label='',
+        )
+        == process
+    )
 
 
 
 
-def test_dump_databases_runs_mysqldump_with_options():
-    databases = [{'name': 'foo', 'options': '--stuff=such'}]
+def test_execute_dump_command_runs_mysqldump_with_options():
     process = flexmock()
     process = flexmock()
-    flexmock(module).should_receive('make_dump_path').and_return('')
-    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
-        'databases/localhost/foo'
-    )
-    flexmock(module).should_receive('database_names_to_dump').and_return(('foo',))
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
-        (
-            'mysqldump',
-            '--stuff=such',
-            '--add-drop-database',
-            '--databases',
-            'foo',
-            '>',
-            'databases/localhost/foo',
-        ),
+        ('mysqldump', '--stuff=such', '--add-drop-database', '--databases', 'foo', '>', 'dump',),
         shell=True,
         shell=True,
         extra_environment=None,
         extra_environment=None,
         run_to_completion=False,
         run_to_completion=False,
     ).and_return(process).once()
     ).and_return(process).once()
 
 
-    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
+    assert (
+        module.execute_dump_command(
+            database={'name': 'foo', 'options': '--stuff=such'},
+            log_prefix='log',
+            dump_path=flexmock(),
+            database_names=('foo',),
+            extra_environment=None,
+            dry_run=False,
+            dry_run_label='',
+        )
+        == process
+    )
 
 
 
 
-def test_dump_databases_runs_mysqldump_for_all_databases():
-    databases = [{'name': 'all'}]
-    process = flexmock()
-    flexmock(module).should_receive('make_dump_path').and_return('')
-    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
-        'databases/localhost/all'
-    )
-    flexmock(module).should_receive('database_names_to_dump').and_return(('foo', 'bar'))
-    flexmock(module.dump).should_receive('create_named_pipe_for_dump')
+def test_execute_dump_command_with_duplicate_dump_skips_mysqldump():
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
+    flexmock(module).should_receive('execute_command').never()
 
 
-    flexmock(module).should_receive('execute_command').with_args(
-        (
-            'mysqldump',
-            '--add-drop-database',
-            '--databases',
-            'foo',
-            'bar',
-            '>',
-            'databases/localhost/all',
-        ),
-        shell=True,
-        extra_environment=None,
-        run_to_completion=False,
-    ).and_return(process).once()
+    assert (
+        module.execute_dump_command(
+            database={'name': 'foo'},
+            log_prefix='log',
+            dump_path=flexmock(),
+            database_names=('foo',),
+            extra_environment=None,
+            dry_run=True,
+            dry_run_label='SO DRY',
+        )
+        is None
+    )
 
 
-    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == [process]
 
 
+def test_execute_dump_command_with_dry_run_skips_mysqldump():
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return('dump')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
+    flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
-def test_database_names_to_dump_runs_mysql_with_list_options():
-    database = {'name': 'all', 'list_options': '--defaults-extra-file=my.cnf'}
-    flexmock(module).should_receive('execute_command_and_capture_output').with_args(
-        (
-            'mysql',
-            '--defaults-extra-file=my.cnf',
-            '--skip-column-names',
-            '--batch',
-            '--execute',
-            'show schemas',
-        ),
-        extra_environment=None,
-    ).and_return(('foo\nbar')).once()
+    flexmock(module).should_receive('execute_command').never()
 
 
-    assert module.database_names_to_dump(database, None, 'test.yaml', '') == ('foo', 'bar')
+    assert (
+        module.execute_dump_command(
+            database={'name': 'foo'},
+            log_prefix='log',
+            dump_path=flexmock(),
+            database_names=('foo',),
+            extra_environment=None,
+            dry_run=True,
+            dry_run_label='SO DRY',
+        )
+        is None
+    )
 
 
 
 
 def test_dump_databases_errors_for_missing_all_databases():
 def test_dump_databases_errors_for_missing_all_databases():

+ 26 - 1
tests/unit/hooks/test_postgresql.py

@@ -56,6 +56,7 @@ def test_dump_databases_runs_pg_dump_for_each_database():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     ).and_return('databases/localhost/bar')
     ).and_return('databases/localhost/bar')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     for name, process in zip(('foo', 'bar'), processes):
     for name, process in zip(('foo', 'bar'), processes):
@@ -79,7 +80,7 @@ def test_dump_databases_runs_pg_dump_for_each_database():
     assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
     assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == processes
 
 
 
 
-def test_dump_databases_runs_raises_when_no_database_names_to_dump():
+def test_dump_databases_raises_when_no_database_names_to_dump():
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_dump_path').and_return('')
     flexmock(module).should_receive('make_dump_path').and_return('')
@@ -89,6 +90,23 @@ def test_dump_databases_runs_raises_when_no_database_names_to_dump():
         module.dump_databases(databases, 'test.yaml', {}, dry_run=False)
         module.dump_databases(databases, 'test.yaml', {}, dry_run=False)
 
 
 
 
+def test_dump_databases_with_dupliate_dump_skips_pg_dump():
+    databases = [{'name': 'foo'}, {'name': 'bar'}]
+    flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
+    flexmock(module).should_receive('make_dump_path').and_return('')
+    flexmock(module).should_receive('database_names_to_dump').and_return(('foo',)).and_return(
+        ('bar',)
+    )
+    flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
+        'databases/localhost/foo'
+    ).and_return('databases/localhost/bar')
+    flexmock(module.os.path).should_receive('exists').and_return(True)
+    flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
+    flexmock(module).should_receive('execute_command').never()
+
+    assert module.dump_databases(databases, 'test.yaml', {}, dry_run=False) == []
+
+
 def test_dump_databases_with_dry_run_skips_pg_dump():
 def test_dump_databases_with_dry_run_skips_pg_dump():
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     databases = [{'name': 'foo'}, {'name': 'bar'}]
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
     flexmock(module).should_receive('make_extra_environment').and_return({'PGSSLMODE': 'disable'})
@@ -99,6 +117,7 @@ def test_dump_databases_with_dry_run_skips_pg_dump():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     ).and_return('databases/localhost/bar')
     ).and_return('databases/localhost/bar')
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module).should_receive('execute_command').never()
     flexmock(module).should_receive('execute_command').never()
 
 
@@ -114,6 +133,7 @@ def test_dump_databases_runs_pg_dump_with_hostname_and_port():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/database.example.org/foo'
         'databases/database.example.org/foo'
     )
     )
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -151,6 +171,7 @@ def test_dump_databases_runs_pg_dump_with_username_and_password():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -207,6 +228,7 @@ def test_dump_databases_runs_pg_dump_with_directory_format():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_parent_directory_for_dump')
     flexmock(module.dump).should_receive('create_parent_directory_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
     flexmock(module.dump).should_receive('create_named_pipe_for_dump').never()
 
 
@@ -239,6 +261,7 @@ def test_dump_databases_runs_pg_dump_with_options():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -271,6 +294,7 @@ def test_dump_databases_runs_pg_dumpall_for_all_databases():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/all'
         'databases/localhost/all'
     )
     )
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(
@@ -292,6 +316,7 @@ def test_dump_databases_runs_non_default_pg_dump():
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
     flexmock(module.dump).should_receive('make_database_dump_filename').and_return(
         'databases/localhost/foo'
         'databases/localhost/foo'
     )
     )
+    flexmock(module.os.path).should_receive('exists').and_return(False)
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
     flexmock(module.dump).should_receive('create_named_pipe_for_dump')
 
 
     flexmock(module).should_receive('execute_command').with_args(
     flexmock(module).should_receive('execute_command').with_args(