Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion src/borg/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -2422,6 +2422,7 @@ def check(
repair=False,
find_lost_archives=False,
match=None,
exclude=None,
sort_by="",
first=0,
last=0,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/borg/archiver/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
10 changes: 8 additions & 2 deletions src/borg/archiver/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,15 +434,21 @@ 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",
dest="match_archives",
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"
Expand Down
2 changes: 1 addition & 1 deletion src/borg/archiver/analyze_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions src/borg/archiver/check_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 14 additions & 2 deletions src/borg/archiver/delete_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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(
Expand Down
14 changes: 12 additions & 2 deletions src/borg/archiver/help_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion src/borg/archiver/info_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 3 additions & 1 deletion src/borg/archiver/prune_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions src/borg/archiver/tag_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
18 changes: 16 additions & 2 deletions src/borg/archiver/undelete_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:*'.")
Expand Down
46 changes: 42 additions & 4 deletions src/borg/legacy/archives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -207,6 +239,7 @@ def list(
self,
*,
match=None,
exclude=None,
match_end=r"\Z",
sort_by=(),
reverse=False,
Expand All @@ -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(
Expand All @@ -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),
Expand All @@ -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 <match>."""
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]
Expand Down
Loading