From de35ed82bed570e0cd41b64155f62861435ca35f Mon Sep 17 00:00:00 2001 From: lingfish Date: Thu, 29 Jan 2026 14:35:20 +1100 Subject: [PATCH 01/15] Initial commit (unfinished) to address #1243. --- borgmatic/actions/diff.py | 53 ++++++++++++++++++++ borgmatic/borg/diff.py | 88 +++++++++++++++++++++++++++++++++ borgmatic/commands/arguments.py | 18 +++++++ borgmatic/commands/borgmatic.py | 11 +++++ 4 files changed, 170 insertions(+) create mode 100644 borgmatic/actions/diff.py create mode 100644 borgmatic/borg/diff.py diff --git a/borgmatic/actions/diff.py b/borgmatic/actions/diff.py new file mode 100644 index 00000000..c6d3bb8b --- /dev/null +++ b/borgmatic/actions/diff.py @@ -0,0 +1,53 @@ +import logging + +import borgmatic.actions.pattern +import borgmatic.borg.pattern +import borgmatic.borg.diff + +logger = logging.getLogger(__name__) + + + +def run_diff( + repository, + config, + local_borg_version, + diff_arguments, + global_arguments, + local_path, + remote_path, +): + ''' + Run the "diff" action for the given repository. + ''' + + # Collect and process patterns. + processed_patterns = borgmatic.actions.pattern.process_patterns( + ( + *borgmatic.actions.pattern.collect_patterns(config), + ), + config, + borgmatic.config.paths.get_working_directory(config), + ) + + archive = borgmatic.borg.repo_list.resolve_archive_name( + repository['path'], + diff_arguments.archive, + config, + local_borg_version, + global_arguments, + local_path, + remote_path, + ) + + borgmatic.borg.diff.diff( + repository['path'], + archive, + config, + local_borg_version, + diff_arguments, + global_arguments, + local_path=local_path, + remote_path=remote_path, + patterns=processed_patterns, + ) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py new file mode 100644 index 00000000..52b92104 --- /dev/null +++ b/borgmatic/borg/diff.py @@ -0,0 +1,88 @@ +import logging +import shlex + +import borgmatic.borg.environment +import borgmatic.borg.feature +import borgmatic.config.paths +import borgmatic.execute +from borgmatic.borg import flags +from borgmatic.borg.pattern import write_patterns_file + +logger = logging.getLogger(__name__) + + +def diff( + repository, + archive, + config, + local_borg_version, + diff_arguments, + global_arguments, + local_path, + remote_path=None, + patterns=None, +): + ''' + Given a local or remote repository path, an archive name, a configuration dict, the local Borg + version string, an argparse.Namespace of diff arguments, an argparse.Namespace of global + arguments, optional local and remote Borg paths, executes the diff command with the given + arguments. + ''' + lock_wait = config.get('lock_wait', None) + exclude_flags = flags.make_exclude_flags(config) + extra_borg_options = config.get('extra_borg_options', {}).get('diff', '') + + # Write patterns to a temporary file and use that file with --patterns-from. + patterns_file = write_patterns_file( + patterns, + borgmatic.config.paths.get_working_directory(config), + ) + + diff_command = ( + (local_path, 'diff') + + (('--remote-path', remote_path) if remote_path else ()) + + ('--log-json',) + + (('--lock-wait', str(lock_wait)) if lock_wait is not None else ()) + + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + + (('--patterns-from', patterns_file.name) if patterns_file else ()) + + ( + ( + '--list', + '--filter', + flags.make_list_filter_flags(local_borg_version, global_arguments.dry_run), + ) + if config.get('list_details') + else () + ) + + exclude_flags + + (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ()) + + ( + ( + flags.make_repository_flags(repository, local_borg_version) + + flags.make_match_archives_flags( + archive or config.get('match_archives'), + config.get('archive_name_format'), + local_borg_version, + ) + ) + if borgmatic.borg.feature.available( + borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, + local_borg_version, + ) + else ( + flags.make_repository_archive_flags(repository, archive, local_borg_version) + if archive + else flags.make_repository_flags(repository, local_borg_version) + ) + ) + ) + + borgmatic.execute.execute_command( + full_command=diff_command, + output_log_level=logging.INFO, + environment=borgmatic.borg.environment.make_environment(config), + working_directory=borgmatic.config.paths.get_working_directory(config), + borg_local_path=local_path, + borg_exit_codes=config.get('borg_exit_codes'), + ) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 58c3a632..0b7df4e4 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -33,6 +33,7 @@ ACTION_ALIASES = { 'key': [], 'borg': [], 'recreate': [], + 'diff': [], } @@ -1970,6 +1971,23 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 help='Show this help message and exit', ) + diff_parser = action_parsers.add_parser( + 'diff', + aliases=ACTION_ALIASES['diff'], + help='This command finds differences (file contents, user/group/mode) between archives', + description='This command finds differences (file contents, user/group/mode) between archives', + add_help=False, + ) + diff_group = diff_parser.add_argument_group('diff arguments') + diff_group.add_argument( + '--repository', + help='Path of repository containing archive to diff, defaults to the configured repository if there is only one, quoted globs supported', + ) + diff_group.add_argument( + '--archive', + help='Archive name, hash, or series to diff, defaults to all archives in the repository (if specified)', + ) + borg_parser = action_parsers.add_parser( 'borg', aliases=ACTION_ALIASES['borg'], diff --git a/borgmatic/commands/borgmatic.py b/borgmatic/commands/borgmatic.py index 2954ac72..ffab7219 100644 --- a/borgmatic/commands/borgmatic.py +++ b/borgmatic/commands/borgmatic.py @@ -20,6 +20,7 @@ import borgmatic.actions.config.generate import borgmatic.actions.config.validate import borgmatic.actions.create import borgmatic.actions.delete +import borgmatic.actions.diff import borgmatic.actions.export_key import borgmatic.actions.export_tar import borgmatic.actions.extract @@ -622,6 +623,16 @@ def run_actions( # noqa: PLR0912, PLR0915 local_path, remote_path, ) + elif action_name == 'diff': + borgmatic.actions.diff.run_diff( + repository, + config, + local_borg_version, + action_arguments, + global_arguments, + local_path, + remote_path, + ) elif action_name == 'borg': borgmatic.actions.borg.run_borg( repository, From 794e7eadab388209e6f5e7b701ffe9a80ac23326 Mon Sep 17 00:00:00 2001 From: lingfish Date: Thu, 29 Jan 2026 17:38:06 +1100 Subject: [PATCH 02/15] Make the action actually work. PR still pending a bunch of stuff. --- borgmatic/actions/diff.py | 7 ++----- borgmatic/borg/diff.py | 1 + borgmatic/commands/arguments.py | 5 ++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/borgmatic/actions/diff.py b/borgmatic/actions/diff.py index c6d3bb8b..9d51982e 100644 --- a/borgmatic/actions/diff.py +++ b/borgmatic/actions/diff.py @@ -1,13 +1,12 @@ import logging import borgmatic.actions.pattern -import borgmatic.borg.pattern import borgmatic.borg.diff +import borgmatic.borg.pattern logger = logging.getLogger(__name__) - def run_diff( repository, config, @@ -23,9 +22,7 @@ def run_diff( # Collect and process patterns. processed_patterns = borgmatic.actions.pattern.process_patterns( - ( - *borgmatic.actions.pattern.collect_patterns(config), - ), + (*borgmatic.actions.pattern.collect_patterns(config),), config, borgmatic.config.paths.get_working_directory(config), ) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index 52b92104..bf89ab5b 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -76,6 +76,7 @@ def diff( else flags.make_repository_flags(repository, local_borg_version) ) ) + + (diff_arguments.second_archive, ) ) borgmatic.execute.execute_command( diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 0b7df4e4..9150f91c 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1976,7 +1976,6 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 aliases=ACTION_ALIASES['diff'], help='This command finds differences (file contents, user/group/mode) between archives', description='This command finds differences (file contents, user/group/mode) between archives', - add_help=False, ) diff_group = diff_parser.add_argument_group('diff arguments') diff_group.add_argument( @@ -1987,6 +1986,10 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 '--archive', help='Archive name, hash, or series to diff, defaults to all archives in the repository (if specified)', ) + diff_group.add_argument( + '--second-archive', + help='Second archive name, hash, or series to diff', + ) borg_parser = action_parsers.add_parser( 'borg', From 38cc5d4ee417996dcfe81b40ba0c37653cbecc90 Mon Sep 17 00:00:00 2001 From: lingfish Date: Fri, 30 Jan 2026 11:52:47 +1100 Subject: [PATCH 03/15] Half clueless attempt at fixing test_schema.py. --- borgmatic/config/schema.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/borgmatic/config/schema.yaml b/borgmatic/config/schema.yaml index e29c1e58..4ddb1358 100644 --- a/borgmatic/config/schema.yaml +++ b/borgmatic/config/schema.yaml @@ -1115,6 +1115,7 @@ properties: - break-lock - key - borg + - diff description: | List of one or more actions to skip running for this configuration file, even if specified on the command-line (explicitly or @@ -1331,6 +1332,7 @@ properties: - break-lock - key - borg + - diff description: | List of actions for which the commands will be run. Defaults to running for all actions. @@ -1396,6 +1398,7 @@ properties: - break-lock - key - borg + - diff description: | Only trigger the hook when borgmatic is run with particular actions listed here. Defaults to From 06ebff878b8fd538bbcbed78d036997a3ecfab20 Mon Sep 17 00:00:00 2001 From: lingfish Date: Fri, 30 Jan 2026 13:27:09 +1100 Subject: [PATCH 04/15] Add the other supported args. --- borgmatic/borg/diff.py | 12 +++--------- borgmatic/commands/arguments.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index bf89ab5b..cadceed2 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -46,15 +46,6 @@ def diff( + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--patterns-from', patterns_file.name) if patterns_file else ()) - + ( - ( - '--list', - '--filter', - flags.make_list_filter_flags(local_borg_version, global_arguments.dry_run), - ) - if config.get('list_details') - else () - ) + exclude_flags + (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ()) + ( @@ -76,6 +67,9 @@ def diff( else flags.make_repository_flags(repository, local_borg_version) ) ) + + (('--same-chunker-params', diff_arguments.timestamp) if diff_arguments.same_chunker_params else ()) + + (('--sort-by', diff_arguments.sort_by) if diff_arguments.sort_by else ()) + + (('--content-only', diff_arguments.content_only) if diff_arguments.content_only else ()) + (diff_arguments.second_archive, ) ) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 9150f91c..4f733cce 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1990,7 +1990,23 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 '--second-archive', help='Second archive name, hash, or series to diff', ) - + diff_group.add_argument( + '--numeric-ids', + help='Only consider numeric user and group identifiers', + ) + diff_group.add_argument( + '--same-chunker-params', + help='Override check of chunker parameters' + ) + diff_group.add_argument( + '--sort-by', + metavar='FIELD[,FIELD...]', + help='Advanced sorting: specify field(s) to sort by. Accepts a comma-separated list. Prefix with > for descending or < for ascending (default).' + ) + diff_group.add_argument( + '--content-only', + help='Only compare differences in content (exclude metadata differences)' + ) borg_parser = action_parsers.add_parser( 'borg', aliases=ACTION_ALIASES['borg'], From 6e611f9b389dec3aade7763716a164a68ada398e Mon Sep 17 00:00:00 2001 From: lingfish Date: Fri, 30 Jan 2026 13:57:14 +1100 Subject: [PATCH 05/15] arg fixes. --- borgmatic/borg/diff.py | 4 ++-- borgmatic/commands/arguments.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index cadceed2..7c0a62c0 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -67,9 +67,9 @@ def diff( else flags.make_repository_flags(repository, local_borg_version) ) ) - + (('--same-chunker-params', diff_arguments.timestamp) if diff_arguments.same_chunker_params else ()) + + (('--same-chunker-params',) if diff_arguments.same_chunker_params else ()) + (('--sort-by', diff_arguments.sort_by) if diff_arguments.sort_by else ()) - + (('--content-only', diff_arguments.content_only) if diff_arguments.content_only else ()) + + (('--content-only',) if diff_arguments.content_only else ()) + (diff_arguments.second_archive, ) ) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 4f733cce..e84277fd 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1992,19 +1992,22 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 ) diff_group.add_argument( '--numeric-ids', + action='store_true', help='Only consider numeric user and group identifiers', ) diff_group.add_argument( '--same-chunker-params', + action='store_true', help='Override check of chunker parameters' ) diff_group.add_argument( '--sort-by', - metavar='FIELD[,FIELD...]', + metavar='KEYS', help='Advanced sorting: specify field(s) to sort by. Accepts a comma-separated list. Prefix with > for descending or < for ascending (default).' ) diff_group.add_argument( '--content-only', + action='store_true', help='Only compare differences in content (exclude metadata differences)' ) borg_parser = action_parsers.add_parser( From 6501bd9823c4ad423e4dd722d674e7b388739be8 Mon Sep 17 00:00:00 2001 From: lingfish Date: Fri, 13 Feb 2026 16:02:14 +1100 Subject: [PATCH 06/15] A small raft of changes to address comments raised. --- borgmatic/borg/diff.py | 17 +++++++++++------ borgmatic/commands/arguments.py | 15 +++++++-------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index 7c0a62c0..11997c0b 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -28,6 +28,8 @@ def diff( arguments, optional local and remote Borg paths, executes the diff command with the given arguments. ''' + borgmatic.logger.add_custom_log_levels() + lock_wait = config.get('lock_wait', None) exclude_flags = flags.make_exclude_flags(config) extra_borg_options = config.get('extra_borg_options', {}).get('diff', '') @@ -38,6 +40,11 @@ def diff( borgmatic.config.paths.get_working_directory(config), ) + if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.NUMERIC_IDS, local_borg_version): + numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else () + else: + numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else () + diff_command = ( (local_path, 'diff') + (('--remote-path', remote_path) if remote_path else ()) @@ -47,6 +54,7 @@ def diff( + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) + (('--patterns-from', patterns_file.name) if patterns_file else ()) + exclude_flags + + numeric_ids_flags + (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ()) + ( ( @@ -61,21 +69,18 @@ def diff( borgmatic.borg.feature.Feature.SEPARATE_REPOSITORY_ARCHIVE, local_borg_version, ) - else ( - flags.make_repository_archive_flags(repository, archive, local_borg_version) - if archive - else flags.make_repository_flags(repository, local_borg_version) - ) + else flags.make_repository_archive_flags(repository, archive, local_borg_version) ) + (('--same-chunker-params',) if diff_arguments.same_chunker_params else ()) + (('--sort-by', diff_arguments.sort_by) if diff_arguments.sort_by else ()) + + (tuple(diff_arguments.sort_keys) if diff_arguments.sort_keys else ()) + (('--content-only',) if diff_arguments.content_only else ()) + (diff_arguments.second_archive, ) ) borgmatic.execute.execute_command( full_command=diff_command, - output_log_level=logging.INFO, + output_log_level=logging.ANSWER, environment=borgmatic.borg.environment.make_environment(config), working_directory=borgmatic.config.paths.get_working_directory(config), borg_local_path=local_path, diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index e84277fd..9fe6bdf5 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1984,16 +1984,13 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 ) diff_group.add_argument( '--archive', - help='Archive name, hash, or series to diff, defaults to all archives in the repository (if specified)', + help='Archive name, hash, or series to diff', + required=True, ) diff_group.add_argument( '--second-archive', help='Second archive name, hash, or series to diff', - ) - diff_group.add_argument( - '--numeric-ids', - action='store_true', - help='Only consider numeric user and group identifiers', + required=True, ) diff_group.add_argument( '--same-chunker-params', @@ -2002,8 +1999,10 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 ) diff_group.add_argument( '--sort-by', - metavar='KEYS', - help='Advanced sorting: specify field(s) to sort by. Accepts a comma-separated list. Prefix with > for descending or < for ascending (default).' + metavar='KEY', + dest='sort_keys', + action='append', + help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default).' ) diff_group.add_argument( '--content-only', From f5ff2f9ae32b7511444f5dd2ef8e630c8930144a Mon Sep 17 00:00:00 2001 From: lingfish Date: Fri, 20 Feb 2026 12:29:13 +1100 Subject: [PATCH 07/15] Make the default to diff the full archives, and add a "--only-patterns" flag if a diff with patterns is desired. --- borgmatic/borg/diff.py | 15 +++++++++------ borgmatic/commands/arguments.py | 7 ++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index 11997c0b..245f6b56 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -34,11 +34,14 @@ def diff( exclude_flags = flags.make_exclude_flags(config) extra_borg_options = config.get('extra_borg_options', {}).get('diff', '') - # Write patterns to a temporary file and use that file with --patterns-from. - patterns_file = write_patterns_file( - patterns, - borgmatic.config.paths.get_working_directory(config), - ) + if diff_arguments.only_patterns: + # Write patterns to a temporary file and use that file with --patterns-from. + patterns_file = write_patterns_file( + patterns, + borgmatic.config.paths.get_working_directory(config), + ) + else: + patterns_file = None if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.NUMERIC_IDS, local_borg_version): numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else () @@ -52,7 +55,7 @@ def diff( + (('--lock-wait', str(lock_wait)) if lock_wait is not None else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) - + (('--patterns-from', patterns_file.name) if patterns_file else ()) + + (('--patterns-from', patterns_file.name) if patterns_file and diff_arguments.only_patterns else ()) + exclude_flags + numeric_ids_flags + (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ()) diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index 9fe6bdf5..d4105b10 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -2002,13 +2002,18 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 metavar='KEY', dest='sort_keys', action='append', - help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default).' + help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default)' ) diff_group.add_argument( '--content-only', action='store_true', help='Only compare differences in content (exclude metadata differences)' ) + diff_group.add_argument( + '--only-patterns', + action='store_true', + help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)' + ) borg_parser = action_parsers.add_parser( 'borg', aliases=ACTION_ALIASES['borg'], From 394f33f28afe66a19a66e4c33587f70efb848bf8 Mon Sep 17 00:00:00 2001 From: lingfish Date: Fri, 20 Feb 2026 14:32:52 +1100 Subject: [PATCH 08/15] Make sort-by a comma separated list. --- borgmatic/borg/diff.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index 245f6b56..3de9f9c0 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -75,8 +75,7 @@ def diff( else flags.make_repository_archive_flags(repository, archive, local_borg_version) ) + (('--same-chunker-params',) if diff_arguments.same_chunker_params else ()) - + (('--sort-by', diff_arguments.sort_by) if diff_arguments.sort_by else ()) - + (tuple(diff_arguments.sort_keys) if diff_arguments.sort_keys else ()) + + (('--sort-by', ','.join(diff_arguments.sort_keys)) if diff_arguments.sort_keys else ()) + (('--content-only',) if diff_arguments.content_only else ()) + (diff_arguments.second_archive, ) ) From 4f25581a12060266526e73dd519a544183018117 Mon Sep 17 00:00:00 2001 From: lingfish Date: Sat, 21 Feb 2026 13:38:49 +1100 Subject: [PATCH 09/15] A bit of ruff, and some WIP tests. --- borgmatic/actions/diff.py | 1 - borgmatic/borg/diff.py | 12 +- borgmatic/commands/arguments.py | 10 +- tests/unit/actions/test_diff.py | 30 ++++ tests/unit/borg/test_diff.py | 136 +++++++++++++++++++ tests/unit/commands/test_borgmatic_diff.py | 62 +++++++++ tests/unit/commands/test_run_actions_diff.py | 66 +++++++++ 7 files changed, 307 insertions(+), 10 deletions(-) create mode 100644 tests/unit/actions/test_diff.py create mode 100644 tests/unit/borg/test_diff.py create mode 100644 tests/unit/commands/test_borgmatic_diff.py create mode 100644 tests/unit/commands/test_run_actions_diff.py diff --git a/borgmatic/actions/diff.py b/borgmatic/actions/diff.py index 9d51982e..119d7bd7 100644 --- a/borgmatic/actions/diff.py +++ b/borgmatic/actions/diff.py @@ -2,7 +2,6 @@ import logging import borgmatic.actions.pattern import borgmatic.borg.diff -import borgmatic.borg.pattern logger = logging.getLogger(__name__) diff --git a/borgmatic/borg/diff.py b/borgmatic/borg/diff.py index 3de9f9c0..27b68d8b 100644 --- a/borgmatic/borg/diff.py +++ b/borgmatic/borg/diff.py @@ -43,7 +43,9 @@ def diff( else: patterns_file = None - if borgmatic.borg.feature.available(borgmatic.borg.feature.Feature.NUMERIC_IDS, local_borg_version): + if borgmatic.borg.feature.available( + borgmatic.borg.feature.Feature.NUMERIC_IDS, local_borg_version + ): numeric_ids_flags = ('--numeric-ids',) if config.get('numeric_ids') else () else: numeric_ids_flags = ('--numeric-owner',) if config.get('numeric_ids') else () @@ -55,7 +57,11 @@ def diff( + (('--lock-wait', str(lock_wait)) if lock_wait is not None else ()) + (('--info',) if logger.getEffectiveLevel() == logging.INFO else ()) + (('--debug', '--show-rc') if logger.isEnabledFor(logging.DEBUG) else ()) - + (('--patterns-from', patterns_file.name) if patterns_file and diff_arguments.only_patterns else ()) + + ( + ('--patterns-from', patterns_file.name) + if patterns_file and diff_arguments.only_patterns + else () + ) + exclude_flags + numeric_ids_flags + (tuple(shlex.split(extra_borg_options)) if extra_borg_options else ()) @@ -77,7 +83,7 @@ def diff( + (('--same-chunker-params',) if diff_arguments.same_chunker_params else ()) + (('--sort-by', ','.join(diff_arguments.sort_keys)) if diff_arguments.sort_keys else ()) + (('--content-only',) if diff_arguments.content_only else ()) - + (diff_arguments.second_archive, ) + + (diff_arguments.second_archive,) ) borgmatic.execute.execute_command( diff --git a/borgmatic/commands/arguments.py b/borgmatic/commands/arguments.py index d4105b10..b7ac3891 100644 --- a/borgmatic/commands/arguments.py +++ b/borgmatic/commands/arguments.py @@ -1993,26 +1993,24 @@ def make_parsers(schema, unparsed_arguments): # noqa: PLR0915 required=True, ) diff_group.add_argument( - '--same-chunker-params', - action='store_true', - help='Override check of chunker parameters' + '--same-chunker-params', action='store_true', help='Override check of chunker parameters' ) diff_group.add_argument( '--sort-by', metavar='KEY', dest='sort_keys', action='append', - help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default)' + help='Advanced sorting: specify field(s) to sort by. Prefix with > for descending or < for ascending (default)', ) diff_group.add_argument( '--content-only', action='store_true', - help='Only compare differences in content (exclude metadata differences)' + help='Only compare differences in content (exclude metadata differences)', ) diff_group.add_argument( '--only-patterns', action='store_true', - help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)' + help='Run the diff according to borgmatic configured patterns (ie do not diff entire archives)', ) borg_parser = action_parsers.add_parser( 'borg', diff --git a/tests/unit/actions/test_diff.py b/tests/unit/actions/test_diff.py new file mode 100644 index 00000000..da6bf0b7 --- /dev/null +++ b/tests/unit/actions/test_diff.py @@ -0,0 +1,30 @@ +from flexmock import flexmock + +import borgmatic.actions.diff +import borgmatic.actions.pattern +import borgmatic.borg.diff +import borgmatic.borg.repo_list + + +def test_run_diff_calls_borg_diff(): + flexmock(borgmatic.actions.pattern).should_receive('process_patterns').and_return([]) + flexmock(borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return('archive') + flexmock(borgmatic.borg.diff).should_receive('diff').once() + + borgmatic.actions.diff.run_diff( + repository={'path': 'repo'}, + config={}, + local_borg_version=None, + diff_arguments=flexmock( + archive='archive', + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + global_arguments=flexmock(), + local_path=None, + remote_path=None, + ) diff --git a/tests/unit/borg/test_diff.py b/tests/unit/borg/test_diff.py new file mode 100644 index 00000000..3488f913 --- /dev/null +++ b/tests/unit/borg/test_diff.py @@ -0,0 +1,136 @@ +from flexmock import flexmock + +import borgmatic.borg.diff +import borgmatic.borg.environment +import borgmatic.borg.feature +import borgmatic.config.paths +import borgmatic.execute +from borgmatic.borg import flags + + +def test_diff_calls_execute_command(): + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.feature).should_receive('available').and_return(False) + flexmock(flags).should_receive('make_repository_flags').and_return(()) + flexmock(flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) + flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(name='test') + ) + + borgmatic.borg.diff.diff( + repository='repo', + archive='archive', + config={}, + local_borg_version=None, + diff_arguments=flexmock( + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + global_arguments=flexmock(), + local_path='borg', + remote_path=None, + patterns=[], + ) + + +def test_diff_with_numeric_ids_flag(): + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) + flexmock(flags).should_receive('make_repository_flags').and_return(()) + flexmock(flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) + flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(name='test') + ) + + borgmatic.borg.diff.diff( + repository='repo', + archive='archive', + config={'numeric_ids': True}, + local_borg_version=None, + diff_arguments=flexmock( + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + global_arguments=flexmock(), + local_path='borg', + remote_path=None, + patterns=[], + ) + + +def test_diff_with_numeric_ids_flag_false(): + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) + flexmock(flags).should_receive('make_repository_flags').and_return(()) + flexmock(flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) + flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(name='test') + ) + + borgmatic.borg.diff.diff( + repository='repo', + archive='archive', + config={'numeric_ids': False}, + local_borg_version=None, + diff_arguments=flexmock( + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + global_arguments=flexmock(), + local_path='borg', + remote_path=None, + patterns=[], + ) + + +def test_diff_with_only_patterns(): + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) + flexmock(flags).should_receive('make_repository_flags').and_return(()) + flexmock(flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) + flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(name='test') + ) + + borgmatic.borg.diff.diff( + repository='repo', + archive='archive', + config={'numeric_ids': True}, + local_borg_version=None, + diff_arguments=flexmock( + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=True, + ), + global_arguments=flexmock(), + local_path='borg', + remote_path=None, + patterns=[], + ) diff --git a/tests/unit/commands/test_borgmatic_diff.py b/tests/unit/commands/test_borgmatic_diff.py new file mode 100644 index 00000000..5bf443c4 --- /dev/null +++ b/tests/unit/commands/test_borgmatic_diff.py @@ -0,0 +1,62 @@ +from flexmock import flexmock + +import borgmatic.actions.diff +import borgmatic.commands.borgmatic as module + + +def test_run_configuration_with_diff_action_calls_run_diff(): + config = {'repositories': [{'path': 'foo'}]} + arguments = { + 'global': flexmock(dry_run=False, monitoring_verbosity=0), + 'diff': flexmock( + archive='archive1', + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + } + flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) + flexmock(module.dispatch).should_receive('call_hooks') + flexmock(borgmatic.actions.diff).should_receive('run_diff').once() + + list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) + + +def test_run_configuration_with_diff_action_and_verbose_calls_run_diff(): + config = {'repositories': [{'path': 'foo'}]} + arguments = { + 'global': flexmock(dry_run=False, monitoring_verbosity=1), + 'diff': flexmock( + archive='archive1', + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + } + flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) + flexmock(borgmatic.actions.diff).should_receive('run_diff').once() + + list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) + config = {'repositories': [{'path': 'foo'}]} + arguments = { + 'global': flexmock(dry_run=False, monitoring_verbosity=1), + 'diff': flexmock( + archive='archive1', + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + } + flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) + flexmock(borgmatic.actions.diff).should_receive('run_diff').once() + + list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) diff --git a/tests/unit/commands/test_run_actions_diff.py b/tests/unit/commands/test_run_actions_diff.py new file mode 100644 index 00000000..2c4b2962 --- /dev/null +++ b/tests/unit/commands/test_run_actions_diff.py @@ -0,0 +1,66 @@ +from flexmock import flexmock + +import borgmatic.actions.diff +import borgmatic.commands.borgmatic as module + + +def test_run_actions_with_diff_calls_diff_action(): + config = {'repositories': [{'path': 'foo'}]} + arguments = { + 'global': flexmock(dry_run=False), + 'diff': flexmock( + archive='archive1', + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + } + flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) + flexmock(borgmatic.actions.diff).should_receive('run_diff').once() + + list( + module.run_actions( + arguments=arguments, + config_filename='test.yaml', + config=config, + config_paths=[], + local_path=None, + remote_path=None, + local_borg_version=None, + repository={'path': 'foo'}, + ) + ) + + +def test_run_actions_with_diff_and_dry_run_calls_diff_action(): + config = {'repositories': [{'path': 'foo'}]} + arguments = { + 'global': flexmock(dry_run=True), + 'diff': flexmock( + archive='archive1', + same_chunker_params=False, + sort_by=None, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=False, + ), + } + flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) + flexmock(borgmatic.actions.diff).should_receive('run_diff').once() + + list( + module.run_actions( + arguments=arguments, + config_filename='test.yaml', + config=config, + config_paths=[], + local_path=None, + remote_path=None, + local_borg_version=None, + repository={'path': 'foo'}, + ) + ) From 42c006cf1d2748d7e51d9dad595befdb430e8fe8 Mon Sep 17 00:00:00 2001 From: lingfish Date: Mon, 23 Feb 2026 11:03:25 +1100 Subject: [PATCH 10/15] Next raft of suggested fixes. --- borgmatic/actions/diff.py | 15 +++--- tests/unit/actions/test_diff.py | 51 ++++++++++++++++---- tests/unit/borg/test_diff.py | 27 ++++------- tests/unit/commands/test_borgmatic_diff.py | 3 -- tests/unit/commands/test_run_actions_diff.py | 2 - 5 files changed, 60 insertions(+), 38 deletions(-) diff --git a/borgmatic/actions/diff.py b/borgmatic/actions/diff.py index 119d7bd7..a40c4a5a 100644 --- a/borgmatic/actions/diff.py +++ b/borgmatic/actions/diff.py @@ -19,12 +19,15 @@ def run_diff( Run the "diff" action for the given repository. ''' - # Collect and process patterns. - processed_patterns = borgmatic.actions.pattern.process_patterns( - (*borgmatic.actions.pattern.collect_patterns(config),), - config, - borgmatic.config.paths.get_working_directory(config), - ) + # Only process patterns if only_patterns flag is set + if diff_arguments.only_patterns: + processed_patterns = borgmatic.actions.pattern.process_patterns( + (*borgmatic.actions.pattern.collect_patterns(config),), + config, + borgmatic.config.paths.get_working_directory(config), + ) + else: + processed_patterns = None archive = borgmatic.borg.repo_list.resolve_archive_name( repository['path'], diff --git a/tests/unit/actions/test_diff.py b/tests/unit/actions/test_diff.py index da6bf0b7..ab1bf71e 100644 --- a/tests/unit/actions/test_diff.py +++ b/tests/unit/actions/test_diff.py @@ -1,24 +1,24 @@ from flexmock import flexmock -import borgmatic.actions.diff -import borgmatic.actions.pattern -import borgmatic.borg.diff -import borgmatic.borg.repo_list +from borgmatic.actions import diff as module def test_run_diff_calls_borg_diff(): - flexmock(borgmatic.actions.pattern).should_receive('process_patterns').and_return([]) - flexmock(borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return('archive') - flexmock(borgmatic.borg.diff).should_receive('diff').once() + flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( + 'archive' + ) + flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( + flexmock(), + ) + flexmock(module.borgmatic.borg.diff).should_receive('diff').once() - borgmatic.actions.diff.run_diff( + module.borgmatic.actions.diff.run_diff( repository={'path': 'repo'}, config={}, local_borg_version=None, diff_arguments=flexmock( archive='archive', same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -28,3 +28,36 @@ def test_run_diff_calls_borg_diff(): local_path=None, remote_path=None, ) + + +def test_run_diff_with_only_patterns(): + flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( + flexmock(), + ) + flexmock(module.borgmatic.actions.pattern).should_receive('collect_patterns').once().and_return( + [] + ) + flexmock(module.borgmatic.actions.pattern).should_receive('process_patterns').once().and_return( + [] + ) + flexmock(module.borgmatic.borg.repo_list).should_receive('resolve_archive_name').and_return( + 'archive' + ) + flexmock(module.borgmatic.borg.diff).should_receive('diff').once() + + module.borgmatic.actions.diff.run_diff( + repository={'path': 'repo'}, + config={}, + local_borg_version=None, + diff_arguments=flexmock( + archive='archive', + same_chunker_params=False, + sort_keys=[], + content_only=False, + second_archive=None, + only_patterns=True, + ), + global_arguments=flexmock(), + local_path=None, + remote_path=None, + ) diff --git a/tests/unit/borg/test_diff.py b/tests/unit/borg/test_diff.py index 3488f913..92c908ef 100644 --- a/tests/unit/borg/test_diff.py +++ b/tests/unit/borg/test_diff.py @@ -3,15 +3,11 @@ from flexmock import flexmock import borgmatic.borg.diff import borgmatic.borg.environment import borgmatic.borg.feature -import borgmatic.config.paths import borgmatic.execute from borgmatic.borg import flags def test_diff_calls_execute_command(): - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) - flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) - flexmock(borgmatic.execute).should_receive('execute_command').once() flexmock(borgmatic.borg.feature).should_receive('available').and_return(False) flexmock(flags).should_receive('make_repository_flags').and_return(()) flexmock(flags).should_receive('make_match_archives_flags').and_return(()) @@ -19,6 +15,8 @@ def test_diff_calls_execute_command(): flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) borgmatic.borg.diff.diff( repository='repo', @@ -27,7 +25,6 @@ def test_diff_calls_execute_command(): local_borg_version=None, diff_arguments=flexmock( same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -41,9 +38,6 @@ def test_diff_calls_execute_command(): def test_diff_with_numeric_ids_flag(): - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) - flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) - flexmock(borgmatic.execute).should_receive('execute_command').once() flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(flags).should_receive('make_repository_flags').and_return(()) flexmock(flags).should_receive('make_match_archives_flags').and_return(()) @@ -51,6 +45,8 @@ def test_diff_with_numeric_ids_flag(): flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) borgmatic.borg.diff.diff( repository='repo', @@ -59,7 +55,6 @@ def test_diff_with_numeric_ids_flag(): local_borg_version=None, diff_arguments=flexmock( same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -73,9 +68,6 @@ def test_diff_with_numeric_ids_flag(): def test_diff_with_numeric_ids_flag_false(): - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) - flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) - flexmock(borgmatic.execute).should_receive('execute_command').once() flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(flags).should_receive('make_repository_flags').and_return(()) flexmock(flags).should_receive('make_match_archives_flags').and_return(()) @@ -83,6 +75,8 @@ def test_diff_with_numeric_ids_flag_false(): flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) borgmatic.borg.diff.diff( repository='repo', @@ -91,7 +85,6 @@ def test_diff_with_numeric_ids_flag_false(): local_borg_version=None, diff_arguments=flexmock( same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -105,9 +98,6 @@ def test_diff_with_numeric_ids_flag_false(): def test_diff_with_only_patterns(): - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) - flexmock(borgmatic.config.paths).should_receive('get_working_directory').and_return(None) - flexmock(borgmatic.execute).should_receive('execute_command').once() flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) flexmock(flags).should_receive('make_repository_flags').and_return(()) flexmock(flags).should_receive('make_match_archives_flags').and_return(()) @@ -115,15 +105,16 @@ def test_diff_with_only_patterns(): flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) + flexmock(borgmatic.execute).should_receive('execute_command').once() + flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) borgmatic.borg.diff.diff( repository='repo', archive='archive', - config={'numeric_ids': True}, + config={}, local_borg_version=None, diff_arguments=flexmock( same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, diff --git a/tests/unit/commands/test_borgmatic_diff.py b/tests/unit/commands/test_borgmatic_diff.py index 5bf443c4..9bf340fe 100644 --- a/tests/unit/commands/test_borgmatic_diff.py +++ b/tests/unit/commands/test_borgmatic_diff.py @@ -11,7 +11,6 @@ def test_run_configuration_with_diff_action_calls_run_diff(): 'diff': flexmock( archive='archive1', same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -32,7 +31,6 @@ def test_run_configuration_with_diff_action_and_verbose_calls_run_diff(): 'diff': flexmock( archive='archive1', same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -49,7 +47,6 @@ def test_run_configuration_with_diff_action_and_verbose_calls_run_diff(): 'diff': flexmock( archive='archive1', same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, diff --git a/tests/unit/commands/test_run_actions_diff.py b/tests/unit/commands/test_run_actions_diff.py index 2c4b2962..d60a6dbd 100644 --- a/tests/unit/commands/test_run_actions_diff.py +++ b/tests/unit/commands/test_run_actions_diff.py @@ -11,7 +11,6 @@ def test_run_actions_with_diff_calls_diff_action(): 'diff': flexmock( archive='archive1', same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, @@ -42,7 +41,6 @@ def test_run_actions_with_diff_and_dry_run_calls_diff_action(): 'diff': flexmock( archive='archive1', same_chunker_params=False, - sort_by=None, sort_keys=[], content_only=False, second_archive=None, From 06e2b4f2c726f16904149b30125e83ca14c747c0 Mon Sep 17 00:00:00 2001 From: lingfish Date: Tue, 24 Feb 2026 11:28:36 +1100 Subject: [PATCH 11/15] Attempt to use with_args(). --- tests/unit/borg/test_diff.py | 110 ++++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 39 deletions(-) diff --git a/tests/unit/borg/test_diff.py b/tests/unit/borg/test_diff.py index 92c908ef..2828e630 100644 --- a/tests/unit/borg/test_diff.py +++ b/tests/unit/borg/test_diff.py @@ -1,24 +1,24 @@ +import logging + from flexmock import flexmock -import borgmatic.borg.diff -import borgmatic.borg.environment -import borgmatic.borg.feature -import borgmatic.execute -from borgmatic.borg import flags +from borgmatic.borg import diff as module def test_diff_calls_execute_command(): - flexmock(borgmatic.borg.feature).should_receive('available').and_return(False) - flexmock(flags).should_receive('make_repository_flags').and_return(()) - flexmock(flags).should_receive('make_match_archives_flags').and_return(()) - flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) - flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(False) + flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive( + 'make_repository_archive_flags' + ).and_return(()) + flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) - flexmock(borgmatic.execute).should_receive('execute_command').once() - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(module.borgmatic.execute).should_receive('execute_command').once() + flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return({}) - borgmatic.borg.diff.diff( + module.borgmatic.borg.diff.diff( repository='repo', archive='archive', config={}, @@ -38,17 +38,19 @@ def test_diff_calls_execute_command(): def test_diff_with_numeric_ids_flag(): - flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) - flexmock(flags).should_receive('make_repository_flags').and_return(()) - flexmock(flags).should_receive('make_match_archives_flags').and_return(()) - flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) - flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) + flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive( + 'make_repository_archive_flags' + ).and_return(()) + flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) - flexmock(borgmatic.execute).should_receive('execute_command').once() - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(module.borgmatic.execute).should_receive('execute_command').once() + flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return({}) - borgmatic.borg.diff.diff( + module.borgmatic.borg.diff.diff( repository='repo', archive='archive', config={'numeric_ids': True}, @@ -68,17 +70,19 @@ def test_diff_with_numeric_ids_flag(): def test_diff_with_numeric_ids_flag_false(): - flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) - flexmock(flags).should_receive('make_repository_flags').and_return(()) - flexmock(flags).should_receive('make_match_archives_flags').and_return(()) - flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) - flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) + flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive( + 'make_repository_archive_flags' + ).and_return(()) + flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( flexmock(name='test') ) - flexmock(borgmatic.execute).should_receive('execute_command').once() - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + flexmock(module.borgmatic.execute).should_receive('execute_command').once() + flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return({}) - borgmatic.borg.diff.diff( + module.borgmatic.borg.diff.diff( repository='repo', archive='archive', config={'numeric_ids': False}, @@ -98,17 +102,45 @@ def test_diff_with_numeric_ids_flag_false(): def test_diff_with_only_patterns(): - flexmock(borgmatic.borg.feature).should_receive('available').and_return(True) - flexmock(flags).should_receive('make_repository_flags').and_return(()) - flexmock(flags).should_receive('make_match_archives_flags').and_return(()) - flexmock(flags).should_receive('make_repository_archive_flags').and_return(()) - flexmock(borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( - flexmock(name='test') - ) - flexmock(borgmatic.execute).should_receive('execute_command').once() - flexmock(borgmatic.borg.environment).should_receive('make_environment').and_return({}) + # Mock the feature check + flexmock(module.borgmatic.borg.feature).should_receive('available').and_return(True) - borgmatic.borg.diff.diff( + flexmock(module.borgmatic.borg.flags).should_receive('make_repository_flags').and_return( + ('--repo', 'repo') + ) + flexmock(module.borgmatic.borg.flags).should_receive('make_match_archives_flags').and_return(()) + flexmock(module.borgmatic.borg.flags).should_receive( + 'make_repository_archive_flags' + ).and_return(()) + + environment = flexmock() + flexmock(module.borgmatic.borg.environment).should_receive('make_environment').and_return( + environment + ) + + flexmock(module.borgmatic.borg.pattern).should_receive('write_patterns_file').and_return( + '/tmp/test_patterns' + ) + + expected_command = ( + 'borg', + 'diff', + '--log-json', # Add this flag + '--repo', + 'repo', + None, + ) + + flexmock(module.borgmatic.execute).should_receive('execute_command').with_args( + full_command=expected_command, + output_log_level=logging.ANSWER, + environment=environment, + working_directory=None, + borg_local_path='borg', + borg_exit_codes=None, + ).once() + + module.borgmatic.borg.diff.diff( repository='repo', archive='archive', config={}, From 012c02d96206a73c26c5f2517fea129fc976d3e6 Mon Sep 17 00:00:00 2001 From: lingfish Date: Wed, 25 Feb 2026 17:06:16 +1100 Subject: [PATCH 12/15] Add the CLI doco generation for the diff action. --- docs/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index a58c92c0..40add4ae 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -5,7 +5,7 @@ RUN apk add --no-cache py3-pip py3-ruamel.yaml py3-ruamel.yaml.clib RUN pip install --break-system-packages --no-cache /app && borgmatic config generate && borgmatic config generate --destination /etc/borgmatic --split && chmod +r /etc/borgmatic/*.yaml RUN mkdir /command-line \ && borgmatic --help > /command-line/global.txt \ - && for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" "config show" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate borg; do \ + && for action in repo-create transfer create prune compact check delete extract config "config bootstrap" "config generate" "config validate" "config show" export-tar mount umount repo-delete restore repo-list list repo-info info break-lock "key export" "key import" "key change-passphrase" recreate diff borg; do \ borgmatic $action --help > /command-line/${action/ /-}.txt; done RUN /app/docs/fetch-contributors >> /contributors.html From f90d7a99c645cc9908f00f3eba1c725939194b5d Mon Sep 17 00:00:00 2001 From: lingfish Date: Wed, 25 Feb 2026 17:22:39 +1100 Subject: [PATCH 13/15] Add the CLI doco generation for the diff action. --- docs/reference/command-line/actions/diff.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/reference/command-line/actions/diff.md diff --git a/docs/reference/command-line/actions/diff.md b/docs/reference/command-line/actions/diff.md new file mode 100644 index 00000000..d81f453b --- /dev/null +++ b/docs/reference/command-line/actions/diff.md @@ -0,0 +1,12 @@ +--- +title: diff +eleventyNavigation: + key: diff + parent: 🎬 Actions +--- + +{% include snippet/command-line/sample.md %} + +``` +{% include borgmatic/command-line/diff.txt %} +``` From 89703334f3c851eab28db7a39136e67c50c83c5f Mon Sep 17 00:00:00 2001 From: lingfish Date: Thu, 26 Feb 2026 08:51:11 +1100 Subject: [PATCH 14/15] Add more doco. --- docs/how-to/inspect-your-backups.md | 19 +++++++++++++++++++ docs/reference/command-line/actions/diff.md | 4 ++++ 2 files changed, 23 insertions(+) diff --git a/docs/how-to/inspect-your-backups.md b/docs/how-to/inspect-your-backups.md index 9a651ff0..1a151f90 100644 --- a/docs/how-to/inspect-your-backups.md +++ b/docs/how-to/inspect-your-backups.md @@ -116,3 +116,22 @@ By default, borgmatic only logs to the console. But to enable simultaneous syslog or file logging, see the [logging documentation](https://torsion.org/borgmatic/reference/command-line/logging/) for details. + +## Finding differences between two archives + +New in borgmatic version +2.1.3You can compare differences between two archives. For example: + +```bash +borgmatic diff --archive latest --second-archive host-2023-01-02T04:06:07.080910 +``` + +This shows the differences (file contents, user/group/mode) between the latest +archive and the second one supplied. + +Note that, by default, `borgmatic diff` compares everything in the archives; that +is, patterns are _not_ taken into consideration. If you require this, supply the +`--only-patterns` flag. + +See the [`borgbackup`](https://borgbackup.readthedocs.io/en/stable/usage/diff.html) +documentation for information on output format, what is compared, and more. diff --git a/docs/reference/command-line/actions/diff.md b/docs/reference/command-line/actions/diff.md index d81f453b..e3d8c681 100644 --- a/docs/reference/command-line/actions/diff.md +++ b/docs/reference/command-line/actions/diff.md @@ -10,3 +10,7 @@ eleventyNavigation: ``` {% include borgmatic/command-line/diff.txt %} ``` + +## Related documentation + +* [Finding differences](https://torsion.org/borgmatic/how-to/inspect-your-backups/#finding-differences-between-two-archives) From eaf3d959e62193a9966cfa1cd9d20f4332eac39e Mon Sep 17 00:00:00 2001 From: lingfish Date: Thu, 26 Feb 2026 13:56:46 +1100 Subject: [PATCH 15/15] More test changes. --- tests/unit/commands/test_borgmatic.py | 24 ++++++++ tests/unit/commands/test_borgmatic_diff.py | 59 ------------------ tests/unit/commands/test_run_actions_diff.py | 64 -------------------- 3 files changed, 24 insertions(+), 123 deletions(-) delete mode 100644 tests/unit/commands/test_borgmatic_diff.py delete mode 100644 tests/unit/commands/test_run_actions_diff.py diff --git a/tests/unit/commands/test_borgmatic.py b/tests/unit/commands/test_borgmatic.py index 7caa1596..5c076118 100644 --- a/tests/unit/commands/test_borgmatic.py +++ b/tests/unit/commands/test_borgmatic.py @@ -2689,3 +2689,27 @@ def test_get_singular_option_value_with_no_config_returns_none(): ) is None ) + + +def test_run_actions_runs_diff(): + flexmock(module).should_receive('add_custom_log_levels') + flexmock(module).should_receive('get_skip_actions').and_return([]) + flexmock(module.borgmatic.config.validate).should_receive('repositories_match').never() + flexmock(module.borgmatic.config.paths).should_receive('get_working_directory').and_return( + flexmock(), + ) + flexmock(module.command).should_receive('Before_after_hooks').and_return(flexmock()) + flexmock(borgmatic.actions.diff).should_receive('run_diff').once() + + tuple( + module.run_actions( + arguments={'global': flexmock(dry_run=False), 'diff': flexmock()}, + config_filename=flexmock(), + config={'repositories': []}, + config_paths=[], + local_path=flexmock(), + remote_path=flexmock(), + local_borg_version=flexmock(), + repository={'path': 'repo'}, + ), + ) diff --git a/tests/unit/commands/test_borgmatic_diff.py b/tests/unit/commands/test_borgmatic_diff.py deleted file mode 100644 index 9bf340fe..00000000 --- a/tests/unit/commands/test_borgmatic_diff.py +++ /dev/null @@ -1,59 +0,0 @@ -from flexmock import flexmock - -import borgmatic.actions.diff -import borgmatic.commands.borgmatic as module - - -def test_run_configuration_with_diff_action_calls_run_diff(): - config = {'repositories': [{'path': 'foo'}]} - arguments = { - 'global': flexmock(dry_run=False, monitoring_verbosity=0), - 'diff': flexmock( - archive='archive1', - same_chunker_params=False, - sort_keys=[], - content_only=False, - second_archive=None, - only_patterns=False, - ), - } - flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) - flexmock(module.dispatch).should_receive('call_hooks') - flexmock(borgmatic.actions.diff).should_receive('run_diff').once() - - list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) - - -def test_run_configuration_with_diff_action_and_verbose_calls_run_diff(): - config = {'repositories': [{'path': 'foo'}]} - arguments = { - 'global': flexmock(dry_run=False, monitoring_verbosity=1), - 'diff': flexmock( - archive='archive1', - same_chunker_params=False, - sort_keys=[], - content_only=False, - second_archive=None, - only_patterns=False, - ), - } - flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) - flexmock(borgmatic.actions.diff).should_receive('run_diff').once() - - list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) - config = {'repositories': [{'path': 'foo'}]} - arguments = { - 'global': flexmock(dry_run=False, monitoring_verbosity=1), - 'diff': flexmock( - archive='archive1', - same_chunker_params=False, - sort_keys=[], - content_only=False, - second_archive=None, - only_patterns=False, - ), - } - flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) - flexmock(borgmatic.actions.diff).should_receive('run_diff').once() - - list(module.run_configuration('test.yaml', config, ['/tmp/test.yaml'], arguments)) diff --git a/tests/unit/commands/test_run_actions_diff.py b/tests/unit/commands/test_run_actions_diff.py deleted file mode 100644 index d60a6dbd..00000000 --- a/tests/unit/commands/test_run_actions_diff.py +++ /dev/null @@ -1,64 +0,0 @@ -from flexmock import flexmock - -import borgmatic.actions.diff -import borgmatic.commands.borgmatic as module - - -def test_run_actions_with_diff_calls_diff_action(): - config = {'repositories': [{'path': 'foo'}]} - arguments = { - 'global': flexmock(dry_run=False), - 'diff': flexmock( - archive='archive1', - same_chunker_params=False, - sort_keys=[], - content_only=False, - second_archive=None, - only_patterns=False, - ), - } - flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) - flexmock(borgmatic.actions.diff).should_receive('run_diff').once() - - list( - module.run_actions( - arguments=arguments, - config_filename='test.yaml', - config=config, - config_paths=[], - local_path=None, - remote_path=None, - local_borg_version=None, - repository={'path': 'foo'}, - ) - ) - - -def test_run_actions_with_diff_and_dry_run_calls_diff_action(): - config = {'repositories': [{'path': 'foo'}]} - arguments = { - 'global': flexmock(dry_run=True), - 'diff': flexmock( - archive='archive1', - same_chunker_params=False, - sort_keys=[], - content_only=False, - second_archive=None, - only_patterns=False, - ), - } - flexmock(module.borg_version).should_receive('local_borg_version').and_return(flexmock()) - flexmock(borgmatic.actions.diff).should_receive('run_diff').once() - - list( - module.run_actions( - arguments=arguments, - config_filename='test.yaml', - config=config, - config_paths=[], - local_path=None, - remote_path=None, - local_borg_version=None, - repository={'path': 'foo'}, - ) - )