diff --git a/docs/changes.rst b/docs/changes.rst index 126d5935a7..1bcd82c294 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -168,6 +168,7 @@ Version 2.0.0b26 (not released yet) New features: +- add --exclude-archives option to filter out archives, #9936 - encrypt/authenticate the files cache with the repository key, #5041 - add BORG_LOCK_RECHECK_DELAY env var to set the lock race recheck delay, #9899 - mount: add new mount options: diff --git a/src/borg/archive.py b/src/borg/archive.py index 733b96122c..768055ba74 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -2422,6 +2422,7 @@ def check( repair=False, find_lost_archives=False, match=None, + exclude=None, sort_by="", first=0, last=0, @@ -2438,6 +2439,7 @@ def check( :param find_lost_archives: create archive directory entries that are missing :param first/last/sort_by: only check this number of first/last archives ordered by sort_by :param match: only check archives matching this pattern + :param exclude: exclude archives matching this pattern :param older/newer: only check archives older/newer than timedelta from now :param oldest/newest: only check archives older/newer than timedelta from oldest/newest archive timestamp :param verify_data: integrity verification of data referenced by archives @@ -3113,10 +3115,11 @@ def robust_item_ids(): ) sort_by = sort_by.split(",") - if any((first, last, match, older, newer, newest, oldest)): + if any((first, last, match, exclude, older, newer, newest, oldest)): archive_infos = self.manifest.archives.list( sort_by=sort_by, match=match, + exclude=exclude, first=first, last=last, oldest=oldest, diff --git a/src/borg/archiver/__init__.py b/src/borg/archiver/__init__.py index caa320611a..2e6456062c 100644 --- a/src/borg/archiver/__init__.py +++ b/src/borg/archiver/__init__.py @@ -411,7 +411,7 @@ def parse_args(self, args=None): value = getattr(args, name, None) if value is not None: setattr(args, name, replace_placeholders(value)) - for name in ("match_archives",): # lists + for name in ("match_archives", "exclude_archives"): # lists value = getattr(args, name, None) if value: setattr(args, name, [replace_placeholders(elem) for elem in value]) diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py index 610f2fdeca..2a35520319 100644 --- a/src/borg/archiver/_common.py +++ b/src/borg/archiver/_common.py @@ -434,8 +434,7 @@ def define_archive_filters_group( filters_group = subparser.add_argument_group( "Archive filters", "Archive filters can be applied to repository targets." ) - group = filters_group.add_mutually_exclusive_group() - group.add_argument( + filters_group.add_argument( "-a", "--match-archives", metavar="PATTERN", @@ -443,6 +442,13 @@ def define_archive_filters_group( action="append", help='only consider archives matching all patterns. See "borg help match-archives".', ) + filters_group.add_argument( + "--exclude-archives", + metavar="PATTERN", + dest="exclude_archives", + action="append", + help='exclude archives matching any pattern. See "borg help match-archives".', + ) if sort_by: sort_by_default = "timestamp" diff --git a/src/borg/archiver/analyze_cmd.py b/src/borg/archiver/analyze_cmd.py index 35f805753b..37ae4a59be 100644 --- a/src/borg/archiver/analyze_cmd.py +++ b/src/borg/archiver/analyze_cmd.py @@ -53,7 +53,7 @@ def analyze(self): ) # the decomposition is inherently repository-wide: "shared" and "unreferenced" can only # be determined by looking at every archive, so archive filters must not be applied. - filters = ["match_archives", "first", "last", "older", "newer", "oldest", "newest"] + filters = ["match_archives", "exclude_archives", "first", "last", "older", "newer", "oldest", "newest"] if any(getattr(self.args, name, None) for name in filters): raise Error("--group-by analyzes the whole repository and cannot be combined with archive filters.") by_group = self.analyze_by_group(group_by) diff --git a/src/borg/archiver/check_cmd.py b/src/borg/archiver/check_cmd.py index f297e7aeed..d565339f9e 100644 --- a/src/borg/archiver/check_cmd.py +++ b/src/borg/archiver/check_cmd.py @@ -72,10 +72,17 @@ def do_check(self, args, repository): ): raise CancelledByUser() if args.repo_only and any( - (args.verify_data, args.first, args.last, args.match_archives, args.format is not None) + ( + args.verify_data, + args.first, + args.last, + args.match_archives, + getattr(args, "exclude_archives", None), + args.format is not None, + ) ): raise CommandError( - "--repository-only contradicts --first, --last, -a / --match-archives, " + "--repository-only contradicts --first, --last, -a / --match-archives, --exclude-archives, " "--verify-data and --format arguments." ) if args.repo_only and args.find_lost_archives: @@ -142,6 +149,7 @@ def do_check(self, args, repository): repair=args.repair, find_lost_archives=args.find_lost_archives, match=args.match_archives, + exclude=getattr(args, "exclude_archives", None), sort_by=args.sort_by or "timestamp", first=args.first, last=args.last, diff --git a/src/borg/archiver/delete_cmd.py b/src/borg/archiver/delete_cmd.py index f524ee5fad..fd7fd5815b 100644 --- a/src/borg/archiver/delete_cmd.py +++ b/src/borg/archiver/delete_cmd.py @@ -19,7 +19,9 @@ def do_delete(self, args, repository): dry_run = args.dry_run manifest = Manifest.load(repository) if args.name: - archive_infos = [manifest.archives.get_one(archive_match_patterns(args))] + archive_infos = [ + manifest.archives.get_one(archive_match_patterns(args), exclude=getattr(args, "exclude_archives", None)) + ] else: archive_infos = manifest.archives.list_considering(args) archive_infos = [ai for ai in archive_infos if "@PROT" not in ai.tags] @@ -29,7 +31,17 @@ def do_delete(self, args, repository): # any explicitly given archive filter counts as a deliberate selection; # all these args are falsy when not given (--first / --last are PositiveInt, defaulting to None). any_filters_given = any( - (args.name, args.match_archives, args.first, args.last, args.oldest, args.newest, args.older, args.newer) + ( + args.name, + args.match_archives, + getattr(args, "exclude_archives", None), + args.first, + args.last, + args.oldest, + args.newest, + args.older, + args.newer, + ) ) if not any_filters_given: raise CommandError( diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index d11557e628..40591ce010 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -313,6 +313,12 @@ class HelpMixIn: in the repository. It can be given multiple times; an archive is only considered if it matches all given patterns. + The ``--exclude-archives`` option excludes archives matching a given pattern. It can be + given multiple times; an archive is excluded if it matches any given pattern. + + When combined, an archive is only considered if it matches all ``--match-archives`` patterns + and none of the ``--exclude-archives`` patterns. + Commands that also accept an archive NAME as a positional argument treat that NAME like an additional pattern, so NAME and ``--match-archives`` can be combined to narrow down the selection, e.g. ``borg prune home -a host:myhost``. NAME accepts the same prefixes as the @@ -406,12 +412,16 @@ class HelpMixIn: # archive creation date match borg delete -a 'date:2025-01' borg delete -a 'date:2025-01-01T14:30Z' - borg delete -a 'date:2025-01-01T09:30[America/New_York]'\n\n""" + borg delete -a 'date:2025-01-01T09:30[America/New_York]' + + # exclude archives matching a pattern: + borg repo-list --exclude-archives 'sh:*tmp*' + borg repo-list -a 'host:kenny-pc' --exclude-archives 'tags:experimental'\n\n""" ) helptext["placeholders"] = textwrap.dedent( """ Repository URLs, the archive name arguments (``NAME``, ``OLDNAME``, ``NEWNAME``, - ``ARCHIVE1``, ``ARCHIVE2``), ``-a`` / ``--match-archives``, ``--comment`` and + ``ARCHIVE1``, ``ARCHIVE2``), ``-a`` / ``--match-archives``, ``--exclude-archives``, ``--comment`` and ``BORG_REMOTE_PATH`` values support these placeholders: {hostname} diff --git a/src/borg/archiver/info_cmd.py b/src/borg/archiver/info_cmd.py index dc479339a0..279afb869b 100644 --- a/src/borg/archiver/info_cmd.py +++ b/src/borg/archiver/info_cmd.py @@ -18,7 +18,9 @@ def do_info(self, args, repository, manifest, cache): """Show archive details such as disk space used""" if args.name: - archive_infos = [manifest.archives.get_one(archive_match_patterns(args))] + archive_infos = [ + manifest.archives.get_one(archive_match_patterns(args), exclude=getattr(args, "exclude_archives", None)) + ] else: archive_infos = manifest.archives.list_considering(args) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 0e5efcfe93..976f16d171 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -201,7 +201,9 @@ def do_prune(self, args, repository, manifest): self._validate_prune_args(args) match = archive_match_patterns(args) - archives = manifest.archives.list(match=match, sort_by=["ts"], reverse=True) + archives = manifest.archives.list( + match=match, exclude=getattr(args, "exclude_archives", None), sort_by=["ts"], reverse=True + ) archives = [ai for ai in archives if "@PROT" not in ai.tags] # The retention rules are applied to each group of archives separately, so that unrelated diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 041ea5f400..f6fb2abe72 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -18,13 +18,25 @@ def do_tag(self, args, repository, manifest, cache): # any explicitly given archive filter counts as a deliberate selection; # all these args are falsy when not given (--first / --last are PositiveInt, defaulting to None). any_filters_given = any( - (args.name, args.match_archives, args.first, args.last, args.oldest, args.newest, args.older, args.newer) + ( + args.name, + args.match_archives, + getattr(args, "exclude_archives", None), + args.first, + args.last, + args.oldest, + args.newest, + args.older, + args.newer, + ) ) if modifying and not any_filters_given: raise CommandError("Aborting: if you really want to change the tags of all archives, please use -a 'sh:*'.") if args.name: - archive_infos = [manifest.archives.get_one(archive_match_patterns(args))] + archive_infos = [ + manifest.archives.get_one(archive_match_patterns(args), exclude=getattr(args, "exclude_archives", None)) + ] else: archive_infos = manifest.archives.list_considering(args) diff --git a/src/borg/archiver/undelete_cmd.py b/src/borg/archiver/undelete_cmd.py index b7d1bd4ac5..bd76a4c060 100644 --- a/src/borg/archiver/undelete_cmd.py +++ b/src/borg/archiver/undelete_cmd.py @@ -19,7 +19,11 @@ def do_undelete(self, args, repository): dry_run = args.dry_run manifest = Manifest.load(repository) if args.name: - archive_infos = [manifest.archives.get_one(archive_match_patterns(args), deleted=True)] + archive_infos = [ + manifest.archives.get_one( + archive_match_patterns(args), exclude=getattr(args, "exclude_archives", None), deleted=True + ) + ] else: args.deleted = True archive_infos = manifest.archives.list_considering(args) @@ -29,7 +33,17 @@ def do_undelete(self, args, repository): # any explicitly given archive filter counts as a deliberate selection; # all these args are falsy when not given (--first / --last are PositiveInt, defaulting to None). any_filters_given = any( - (args.name, args.match_archives, args.first, args.last, args.oldest, args.newest, args.older, args.newer) + ( + args.name, + args.match_archives, + getattr(args, "exclude_archives", None), + args.first, + args.last, + args.oldest, + args.newest, + args.older, + args.newer, + ) ) if not any_filters_given: raise CommandError("Aborting: if you really want to undelete all archives, please use -a 'sh:*'.") diff --git a/src/borg/legacy/archives.py b/src/borg/legacy/archives.py index ce2fc38478..90a9e6d3dc 100644 --- a/src/borg/legacy/archives.py +++ b/src/borg/legacy/archives.py @@ -99,9 +99,11 @@ def _info_tuples(self, *, deleted=False): host=info["hostname"], ) - def _matching_info_tuples(self, match_patterns, match_end, *, deleted=False): + def _matching_info_tuples(self, match_patterns, match_end, *, exclude_patterns=None, deleted=False): archive_infos = list(self._info_tuples(deleted=deleted)) if match_patterns: + if isinstance(match_patterns, str): + match_patterns = [match_patterns] assert isinstance(match_patterns, list), f"match_pattern is a {type(match_patterns)}" for match in match_patterns: if match.startswith("aid:"): @@ -131,6 +133,36 @@ def _matching_info_tuples(self, match_patterns, match_end, *, deleted=False): regex = get_regex_from_pattern(match) regex = re.compile(regex + match_end) archive_infos = [x for x in archive_infos if regex.match(x.name) is not None] + if exclude_patterns: + if isinstance(exclude_patterns, str): + exclude_patterns = [exclude_patterns] + assert isinstance(exclude_patterns, list), f"exclude_pattern is a {type(exclude_patterns)}" + for exclude in exclude_patterns: + if exclude.startswith("aid:"): + wanted_id = exclude.removeprefix("aid:") + archive_infos = [x for x in archive_infos if not bin_to_hex(x.id).startswith(wanted_id)] + elif exclude.startswith("tags:"): + wanted_tags = exclude.removeprefix("tags:") + wanted_tags = [tag for tag in wanted_tags.split(",") if tag] + archive_infos = [x for x in archive_infos if not (set(x.tags) >= set(wanted_tags))] + elif exclude.startswith("user:"): + wanted_user = exclude.removeprefix("user:") + archive_infos = [x for x in archive_infos if x.user != wanted_user] + elif exclude.startswith("host:"): + wanted_host = exclude.removeprefix("host:") + archive_infos = [x for x in archive_infos if x.host != wanted_host] + elif exclude.startswith("date:"): + wanted_date = exclude.removeprefix("date:") + try: + date_matches = compile_date_pattern(wanted_date) + except DatePatternError as exc: + raise CommandError(f"Invalid date pattern: {exclude} ({exc})") + archive_infos = [x for x in archive_infos if not date_matches(x.ts)] + else: + exclude = exclude.removeprefix("name:") + regex = get_regex_from_pattern(exclude) + regex = re.compile(regex + match_end) + archive_infos = [x for x in archive_infos if regex.match(x.name) is None] return archive_infos def count(self): @@ -207,6 +239,7 @@ def list( self, *, match=None, + exclude=None, match_end=r"\Z", sort_by=(), reverse=False, @@ -225,7 +258,7 @@ def list( if isinstance(sort_by, (str, bytes)): raise TypeError("sort_by must be a sequence of str") - archive_infos = self._matching_info_tuples(match, match_end, deleted=deleted) + archive_infos = self._matching_info_tuples(match, match_end, exclude_patterns=exclude, deleted=deleted) if any([oldest, newest, older, newer]): archive_infos = filter_archives_by_date( @@ -251,6 +284,7 @@ def list_considering(self, args): return self.list( sort_by=args.sort_by.split(","), match=args.match_archives, + exclude=getattr(args, "exclude_archives", None), first=getattr(args, "first", None), last=getattr(args, "last", None), older=getattr(args, "older", None), @@ -260,10 +294,14 @@ def list_considering(self, args): deleted=getattr(args, "deleted", False), ) - def get_one(self, match, *, match_end=r"\Z", deleted=False): + def get_one(self, match, *, exclude=None, match_end=r"\Z", deleted=False): """Get exactly one archive matching .""" assert match is not None - archive_infos = self._matching_info_tuples(match, match_end, deleted=deleted) + if isinstance(match, str): + match = [match] + if isinstance(exclude, str): + exclude = [exclude] + archive_infos = self._matching_info_tuples(match, match_end, exclude_patterns=exclude, deleted=deleted) if len(archive_infos) != 1: raise CommandError(f"{match} needed to match precisely one archive, but matched {len(archive_infos)}.") return archive_infos[0] diff --git a/src/borg/manifest.py b/src/borg/manifest.py index 6a9697375c..7991a20ebf 100644 --- a/src/borg/manifest.py +++ b/src/borg/manifest.py @@ -156,6 +156,7 @@ def list( self, *, match=None, + exclude=None, match_end=r"\Z", sort_by=(), reverse=False, @@ -168,7 +169,7 @@ def list( deleted=False, ): ... def list_considering(self, args, *, reverse=False): ... - def get_one(self, match, *, match_end=r"\Z", deleted=False): ... + def get_one(self, match, *, exclude=None, match_end=r"\Z", deleted=False): ... class Archives: @@ -272,9 +273,11 @@ def _info_tuples(self, *, deleted=False): metadata=archive_item, ) - def _matching_info_tuples(self, match_patterns, match_end, *, deleted=False): + def _matching_info_tuples(self, match_patterns, match_end, *, exclude_patterns=None, deleted=False): archive_infos = list(self._info_tuples(deleted=deleted)) if match_patterns: + if isinstance(match_patterns, str): + match_patterns = [match_patterns] assert isinstance(match_patterns, list), f"match_pattern is a {type(match_patterns)}" for match in match_patterns: if match.startswith("aid:"): # do a match on the archive ID (prefix) @@ -304,6 +307,36 @@ def _matching_info_tuples(self, match_patterns, match_end, *, deleted=False): regex = get_regex_from_pattern(match) regex = re.compile(regex + match_end) archive_infos = [x for x in archive_infos if regex.match(x.name) is not None] + if exclude_patterns: + if isinstance(exclude_patterns, str): + exclude_patterns = [exclude_patterns] + assert isinstance(exclude_patterns, list), f"exclude_pattern is a {type(exclude_patterns)}" + for exclude in exclude_patterns: + if exclude.startswith("aid:"): # exclude archives matching archive ID prefix + wanted_id = exclude.removeprefix("aid:") + archive_infos = [x for x in archive_infos if not bin_to_hex(x.id).startswith(wanted_id)] + elif exclude.startswith("tags:"): + wanted_tags = exclude.removeprefix("tags:") + wanted_tags = [tag for tag in wanted_tags.split(",") if tag] # remove empty tags + archive_infos = [x for x in archive_infos if not (set(x.tags) >= set(wanted_tags))] + elif exclude.startswith("user:"): + wanted_user = exclude.removeprefix("user:") + archive_infos = [x for x in archive_infos if x.user != wanted_user] + elif exclude.startswith("host:"): + wanted_host = exclude.removeprefix("host:") + archive_infos = [x for x in archive_infos if x.host != wanted_host] + elif exclude.startswith("date:"): + wanted_date = exclude.removeprefix("date:") + try: + date_matches = compile_date_pattern(wanted_date) + except DatePatternError as exc: + raise CommandError(f"Invalid date pattern: {exclude} ({exc})") + archive_infos = [x for x in archive_infos if not date_matches(x.ts)] + else: # exclude on the name + exclude = exclude.removeprefix("name:") # accept optional name: prefix + regex = get_regex_from_pattern(exclude) + regex = re.compile(regex + match_end) + archive_infos = [x for x in archive_infos if regex.match(x.name) is None] return archive_infos def count(self): @@ -423,6 +456,7 @@ def list( self, *, match=None, + exclude=None, match_end=r"\Z", sort_by=(), reverse=False, @@ -437,7 +471,8 @@ def list( """ Return list of ArchiveInfo instances according to the parameters. - First match *match* (considering *match_end*), then filter by timestamp considering *older* and *newer*. + First match *match* (considering *match_end*) and exclude *exclude*. + Then filter by timestamp considering *older* and *newer*. Second, follow with a filter considering *oldest* and *newest*, then sort by the given *sort_by* argument. Apply *first* and *last* filters, and then possibly *reverse* the list. @@ -454,7 +489,7 @@ def list( if isinstance(sort_by, (str, bytes)): raise TypeError("sort_by must be a sequence of str") - archive_infos = self._matching_info_tuples(match, match_end, deleted=deleted) + archive_infos = self._matching_info_tuples(match, match_end, exclude_patterns=exclude, deleted=deleted) if any([oldest, newest, older, newer]): archive_infos = filter_archives_by_date( @@ -483,6 +518,7 @@ def list_considering(self, args, *, reverse=False): sort_by=args.sort_by.split(","), reverse=reverse, match=args.match_archives, + exclude=getattr(args, "exclude_archives", None), first=getattr(args, "first", None), last=getattr(args, "last", None), older=getattr(args, "older", None), @@ -492,10 +528,14 @@ def list_considering(self, args, *, reverse=False): deleted=getattr(args, "deleted", False), ) - def get_one(self, match, *, match_end=r"\Z", deleted=False): + def get_one(self, match, *, exclude=None, match_end=r"\Z", deleted=False): """get exactly one archive matching """ assert match is not None - archive_infos = self._matching_info_tuples(match, match_end, deleted=deleted) + if isinstance(match, str): + match = [match] + if isinstance(exclude, str): + exclude = [exclude] + archive_infos = self._matching_info_tuples(match, match_end, exclude_patterns=exclude, deleted=deleted) if len(archive_infos) != 1: raise CommandError(f"{match} needed to match precisely one archive, but matched {len(archive_infos)}.") return archive_infos[0] diff --git a/src/borg/testsuite/archiver/analyze_cmd_test.py b/src/borg/testsuite/archiver/analyze_cmd_test.py index 059db150b7..6896f42f39 100644 --- a/src/borg/testsuite/archiver/analyze_cmd_test.py +++ b/src/borg/testsuite/archiver/analyze_cmd_test.py @@ -146,6 +146,8 @@ def test_analyze_group_by_rejects_filters(archivers, request): with pytest.raises(Error, match="cannot be combined with archive filters"): cmd(archiver, "analyze", "--group-by", "name", "-a", "sh:one") + with pytest.raises(Error, match="cannot be combined with archive filters"): + cmd(archiver, "analyze", "--group-by", "name", "--exclude-archives", "sh:one") def test_analyze_group_by_needs_a_key(archivers, request): diff --git a/src/borg/testsuite/archiver/check_cmd_test.py b/src/borg/testsuite/archiver/check_cmd_test.py index 4fb0eb9db1..fc4b2a1aa9 100644 --- a/src/borg/testsuite/archiver/check_cmd_test.py +++ b/src/borg/testsuite/archiver/check_cmd_test.py @@ -908,6 +908,18 @@ def test_check_format_repository_only(archivers, request, monkeypatch): cmd(archiver, "check", "--repository-only", exit_code=0) +def test_check_exclude_archives_repository_only(archivers, request): + archiver = request.getfixturevalue(archivers) + check_cmd_setup(archiver) + if archiver.FORK_DEFAULT: + expected_ec = CommandError().exit_code + output = cmd(archiver, "check", "--repository-only", "--exclude-archives", "test", exit_code=expected_ec) + assert "--repository-only contradicts" in output + else: + with pytest.raises(CommandError, match="--repository-only contradicts"): + cmd(archiver, "check", "--repository-only", "--exclude-archives", "test") + + def test_check_format_invalid_format_string(archivers, request): archiver = request.getfixturevalue(archivers) check_cmd_setup(archiver) diff --git a/src/borg/testsuite/archiver/delete_cmd_test.py b/src/borg/testsuite/archiver/delete_cmd_test.py index e90d324e44..763fed2851 100644 --- a/src/borg/testsuite/archiver/delete_cmd_test.py +++ b/src/borg/testsuite/archiver/delete_cmd_test.py @@ -82,6 +82,21 @@ def test_delete_all_with_match_archives(archivers, request): assert cmd(archiver, "repo-list") == "" +def test_delete_with_exclude_archives(archivers, request): + archiver = request.getfixturevalue(archivers) + create_regular_file(archiver.input_path, "file1", size=1024 * 80) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", "input") + cmd(archiver, "create", "test2", "input") + cmd(archiver, "create", "test3", "input") + # delete with exclude: delete all matching sh:* except test1 + cmd(archiver, "delete", "-a", "sh:*", "--exclude-archives", "test1") + output = cmd(archiver, "repo-list") + assert "test1" in output + assert "test2" not in output + assert "test3" not in output + + def test_delete_first_last(archivers, request): archiver = request.getfixturevalue(archivers) create_regular_file(archiver.input_path, "file1", size=1024 * 80) diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index e101d4cd1a..651ca62f56 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -910,6 +910,22 @@ def test_prune_name_and_match_archives_are_combined(archivers, request, backup_f assert output.count("host2") == 2 # host2 was not touched at all +def test_prune_with_exclude_archives(archivers, request, backup_files, monkeypatch): + """Pruning can exclude archives matching patterns.""" + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + for host in ("host1", "host2"): + monkeypatch.setenv("BORG_HOSTNAME", host) + _create_archive_ts(archiver, backup_files, "home", 2024, 1, 1) + _create_archive_ts(archiver, backup_files, "home", 2024, 1, 2) + monkeypatch.delenv("BORG_HOSTNAME") + # home archives are candidates, but exclude host1 + cmd(archiver, "prune", "home", "--exclude-archives", "host:host1", "--keep-daily=1") + output = cmd(archiver, "repo-list", "--format={hostname}{NL}") + assert output.count("host1") == 2 # host1 was excluded from pruning, both kept + assert output.count("host2") == 1 # host2 was pruned, keeping 1 daily + + @pytest.mark.parametrize("rule", PRUNING_RULES, ids=[rule.key for rule in PRUNING_RULES]) def test_period_func_is_created_per_prune_invocation(rule): """Period grouping functions are per prune() invocation, they must not share state.""" diff --git a/src/borg/testsuite/archiver/repo_list_cmd_test.py b/src/borg/testsuite/archiver/repo_list_cmd_test.py index 2030df6d0a..cacad896d3 100644 --- a/src/borg/testsuite/archiver/repo_list_cmd_test.py +++ b/src/borg/testsuite/archiver/repo_list_cmd_test.py @@ -27,6 +27,28 @@ def test_repo_list_glob(archivers, request, backup_files): assert "something-else" not in output +def test_repo_list_exclude_archives(archivers, request, backup_files): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test-1", backup_files) + cmd(archiver, "create", "something-else-than-test-1", backup_files) + cmd(archiver, "create", "test-2", backup_files) + output = cmd(archiver, "repo-list", "--exclude-archives=test-2") + assert "test-1" in output + assert "test-2" not in output + assert "something-else" in output + # Multiple excludes: OR semantics + output = cmd(archiver, "repo-list", "--exclude-archives=test-1", "--exclude-archives=sh:something-*") + assert "test-1" not in output + assert "test-2" in output + assert "something-else" not in output + # Combined with --match-archives + output = cmd(archiver, "repo-list", "--match-archives=sh:test-*", "--exclude-archives=test-2") + assert "test-1" in output + assert "test-2" not in output + assert "something-else" not in output + + def test_archives_format(archivers, request, backup_files): archiver = request.getfixturevalue(archivers) cmd(archiver, "repo-create", RK_ENCRYPTION) diff --git a/src/borg/testsuite/archives_test.py b/src/borg/testsuite/archives_test.py index 563aea50a3..7f1111289c 100644 --- a/src/borg/testsuite/archives_test.py +++ b/src/borg/testsuite/archives_test.py @@ -54,7 +54,9 @@ def _archiveinfo(name, id_, ts=TS, *, username="", hostname="", tags=()): def _stub_matching_info_tuples(infos): ar, _, _ = _archives() - ar._matching_info_tuples = Mock(side_effect=lambda match_patterns, match_end, deleted=False: list(infos)) + ar._matching_info_tuples = Mock( + side_effect=lambda match_patterns, match_end, exclude_patterns=None, deleted=False: list(infos) + ) return ar @@ -577,6 +579,84 @@ def test_list_match_end_custom(): assert result == [i1] +def test_list_exclude_name(): + i1 = _archiveinfo("archive-a", _id(1)) + i2 = _archiveinfo("archive-b", _id(2)) + ar = _stub_info_tuples([i1, i2]) + assert ar.list(exclude=["archive-a"]) == [i2] + + +def test_list_exclude_name_prefix(): + i1 = _archiveinfo("archive-a", _id(1)) + i2 = _archiveinfo("other", _id(2)) + ar = _stub_info_tuples([i1, i2]) + assert ar.list(exclude=["name:archive-a"]) == [i2] + + +def test_list_exclude_user(): + i1 = _archiveinfo("a", _id(1), username="alice") + i2 = _archiveinfo("b", _id(2), username="bob") + ar = _stub_info_tuples([i1, i2]) + assert ar.list(exclude=["user:alice"]) == [i2] + + +def test_list_exclude_host(): + i1 = _archiveinfo("a", _id(1), hostname="laptop") + i2 = _archiveinfo("b", _id(2), hostname="server") + ar = _stub_info_tuples([i1, i2]) + assert ar.list(exclude=["host:laptop"]) == [i2] + + +def test_list_exclude_tags(): + i1 = _archiveinfo("a", _id(1), tags=("prod", "db")) + i2 = _archiveinfo("b", _id(2), tags=("dev",)) + ar = _stub_info_tuples([i1, i2]) + assert ar.list(exclude=["tags:prod"]) == [i2] + + +def test_list_exclude_aid(): + i1 = _archiveinfo("a", _id(1)) + i2 = _archiveinfo("b", _id(2)) + ar = _stub_info_tuples([i1, i2]) + prefix = bin_to_hex(_id(1))[:4] + assert ar.list(exclude=[f"aid:{prefix}"]) == [i2] + + +def test_list_exclude_date(): + i1 = _archiveinfo("a", _id(1), ts=TS) + i2 = _archiveinfo("b", _id(2), ts=TS2) + ar = _stub_info_tuples([i1, i2]) + assert ar.list(exclude=["date:2020-06"]) == [i2] + + +def test_list_exclude_date_invalid_raises(): + i1 = _archiveinfo("a", _id(1)) + ar = _stub_info_tuples([i1]) + with pytest.raises(CommandError, match="Invalid date pattern"): + ar.list(exclude=["date:not-a-date"]) + + +def test_list_exclude_multiple_patterns(): + i1 = _archiveinfo("archive-a", _id(1), username="alice", hostname="laptop") + i2 = _archiveinfo("archive-b", _id(2), username="bob", hostname="server") + i3 = _archiveinfo("archive-c", _id(3), username="charlie", hostname="workstation") + ar = _stub_info_tuples([i1, i2, i3]) + # OR semantics: excludes matching user:alice OR host:server + result = ar.list(exclude=["user:alice", "host:server"]) + assert result == [i3] + + +def test_list_match_and_exclude_combined(): + i1 = _archiveinfo("prod-db-1", _id(1), username="alice", hostname="server") + i2 = _archiveinfo("prod-db-2", _id(2), username="bob", hostname="server") + i3 = _archiveinfo("prod-web-1", _id(3), username="alice", hostname="server") + i4 = _archiveinfo("dev-db-1", _id(4), username="alice", hostname="laptop") + ar = _stub_info_tuples([i1, i2, i3, i4]) + # match prod-* on server, exclude username bob and exclude *web* + result = ar.list(match=["sh:prod-*", "host:server"], exclude=["user:bob", "sh:*web*"]) + assert result == [i1] + + def test_get_one_exact_match(): i1 = _archiveinfo("backup", _id(1)) ar = _stub_info_tuples([i1]) @@ -605,6 +685,20 @@ def test_get_one_deleted_passes_flag(): ar._info_tuples.assert_called_once_with(deleted=True) +def test_get_one_with_exclude(): + i1 = _archiveinfo("backup-1", _id(1)) + i2 = _archiveinfo("backup-2", _id(2)) + ar = _stub_info_tuples([i1, i2]) + assert ar.get_one(["sh:backup-*"], exclude=["backup-2"]) == i1 + + +def test_get_one_with_exclude_removes_target_raises(): + i1 = _archiveinfo("backup-1", _id(1)) + ar = _stub_info_tuples([i1]) + with pytest.raises(CommandError, match=r"matched 0\."): + ar.get_one(["backup-1"], exclude=["backup-1"]) + + def test_list_considering_raises_if_name_set(): ar, _, _ = _archives() args = Mock() @@ -672,3 +766,24 @@ def test_list_considering_multi_key_sort(): ) result = ar.list_considering(args) assert result == [i3, i2, i1] + + +def test_list_considering_with_exclude_archives(): + i1 = _archiveinfo("archive-a", _id(1)) + i2 = _archiveinfo("other", _id(2)) + ar = _stub_info_tuples([i1, i2]) + args = Namespace( + name=None, + sort_by="name", + match_archives=None, + exclude_archives=["other"], + first=None, + last=None, + older=None, + newer=None, + oldest=None, + newest=None, + deleted=False, + ) + result = ar.list_considering(args) + assert result == [i1] diff --git a/src/borg/testsuite/legacy_archives_test.py b/src/borg/testsuite/legacy_archives_test.py index 56a2382b1b..e3f6c367ff 100644 --- a/src/borg/testsuite/legacy_archives_test.py +++ b/src/borg/testsuite/legacy_archives_test.py @@ -406,6 +406,84 @@ def test_list_match_aid_ambiguous(): la.list(match=[f"aid:{prefix}"]) +def test_list_exclude_name(): + i1 = _archiveinfo("archive-a", _id(1)) + i2 = _archiveinfo("archive-b", _id(2)) + la = _make_list_target([i1, i2]) + assert la.list(exclude=["archive-a"]) == [i2] + + +def test_list_exclude_name_prefix(): + i1 = _archiveinfo("archive-a", _id(1)) + i2 = _archiveinfo("other", _id(2)) + la = _make_list_target([i1, i2]) + assert la.list(exclude=["name:archive-a"]) == [i2] + + +def test_list_exclude_user(): + i1 = _archiveinfo("a", _id(1), username="alice") + i2 = _archiveinfo("b", _id(2), username="bob") + la = _make_list_target([i1, i2]) + assert la.list(exclude=["user:alice"]) == [i2] + + +def test_list_exclude_host(): + i1 = _archiveinfo("a", _id(1), hostname="laptop") + i2 = _archiveinfo("b", _id(2), hostname="server") + la = _make_list_target([i1, i2]) + assert la.list(exclude=["host:laptop"]) == [i2] + + +def test_list_exclude_date(): + i1 = _archiveinfo("a", _id(1), ts=TS) + i2 = _archiveinfo("b", _id(2), ts=TS2) + la = _make_list_target([i1, i2]) + assert la.list(exclude=["date:2020-06"]) == [i2] + + +def test_list_exclude_date_invalid_raises(): + i1 = _archiveinfo("a", _id(1)) + la = _make_list_target([i1]) + with pytest.raises(CommandError, match="Invalid date pattern"): + la.list(exclude=["date:not-a-date"]) + + +def test_list_exclude_tags(): + i1 = _archiveinfo("a", _id(1), tags=("prod", "db")) + i2 = _archiveinfo("b", _id(2), tags=("dev",)) + la = _make_list_target([i1, i2]) + assert la.list(exclude=["tags:prod"]) == [i2] + + +def test_list_exclude_aid(): + from ..helpers.parseformat import bin_to_hex + + i1 = _archiveinfo("a", _id(1)) + i2 = _archiveinfo("b", _id(2)) + la = _make_list_target([i1, i2]) + prefix = bin_to_hex(_id(1))[:4] + assert la.list(exclude=[f"aid:{prefix}"]) == [i2] + + +def test_list_exclude_multiple_patterns(): + i1 = _archiveinfo("archive-a", _id(1), username="alice", hostname="laptop") + i2 = _archiveinfo("archive-b", _id(2), username="bob", hostname="server") + i3 = _archiveinfo("archive-c", _id(3), username="charlie", hostname="workstation") + la = _make_list_target([i1, i2, i3]) + # OR semantics: excludes matching user:alice OR host:server + assert la.list(exclude=["user:alice", "host:server"]) == [i3] + + +def test_list_match_and_exclude_combined(): + i1 = _archiveinfo("prod-db-1", _id(1), username="alice", hostname="server") + i2 = _archiveinfo("prod-db-2", _id(2), username="bob", hostname="server") + i3 = _archiveinfo("prod-web-1", _id(3), username="alice", hostname="server") + i4 = _archiveinfo("dev-db-1", _id(4), username="alice", hostname="laptop") + la = _make_list_target([i1, i2, i3, i4]) + result = la.list(match=["sh:prod-*", "host:server"], exclude=["user:bob", "sh:*web*"]) + assert result == [i1] + + # ── get_one ─────────────────────────────────────────────────────────────────────── @@ -429,6 +507,20 @@ def test_get_one_multiple_matches_raises(): la.get_one(["a"]) +def test_get_one_with_exclude(): + i1 = _archiveinfo("backup-1", _id(1)) + i2 = _archiveinfo("backup-2", _id(2)) + la = _make_list_target([i1, i2]) + assert la.get_one(["sh:backup-*"], exclude=["backup-2"]) == i1 + + +def test_get_one_with_exclude_removes_target_raises(): + i1 = _archiveinfo("backup-1", _id(1)) + la = _make_list_target([i1]) + with pytest.raises(CommandError, match=r"matched 0"): + la.get_one(["backup-1"], exclude=["backup-1"]) + + # ── list_considering ────────────────────────────────────────────────────────────── @@ -459,6 +551,27 @@ def test_list_considering_delegates(): assert result == [i1] +def test_list_considering_with_exclude_archives(): + i1 = _archiveinfo("archive-a", _id(1)) + i2 = _archiveinfo("other", _id(2)) + la = _make_list_target([i1, i2]) + args = Namespace( + name=None, + sort_by="name", + match_archives=None, + exclude_archives=["other"], + first=None, + last=None, + older=None, + newer=None, + oldest=None, + newest=None, + deleted=False, + ) + result = la.list_considering(args) + assert result == [i1] + + # ── ArchivesInterface Protocol / Manifest dispatch ────────────────────────────────