Skip to content
Merged
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
11 changes: 7 additions & 4 deletions src/borg/archiver/create_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,8 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser):
The archive will consume almost no disk space for files or parts of files that
have already been stored in other archives.

The ``--tags`` option can be used to add a list of tags to the new archive.
The ``--tag`` option can be used to add a tag to the new archive. To add
multiple tags, give it multiple times, e.g. ``--tag foo --tag bar``.

The archive name does not need to be unique; you can and should use the same
name for a series of archives. The unique archive identifier is its ID (hash),
Expand Down Expand Up @@ -1348,12 +1349,14 @@ def build_parser_create(self, subparsers, common_parser, mid_common_parser):
help="select compression algorithm, see the output of the " '"borg help compression" command for details.',
)
archive_group.add_argument(
"--tags",
"--tag",
metavar="TAG",
dest="tags",
type=helpers.tag_validator,
nargs="+",
help="add tags to archive (comma-separated or multiple arguments)",
# "extend" with nargs=1: one tag per option (so it can not swallow NAME), giving a flat list of tags.
action="extend",
nargs=1,
help="add tag to archive (can be given multiple times)",
)

subparser.add_argument("name", metavar="NAME", type=archivename_validator, help="specify the archive name")
Expand Down
5 changes: 3 additions & 2 deletions src/borg/archiver/help_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ class HelpMixIn:
- aid: prefix match on the archive id (only one result allowed)
- user: exact match on the username who created the archive
- host: exact match on the hostname where the archive was created
- tags: match on the archive tags
- tags: match archives having all the given tags (comma-separated, e.g. tags:TAG1,TAG2)
- date: match on the archive creation timestamp

In case of a name pattern match,
Expand Down Expand Up @@ -399,8 +399,9 @@ class HelpMixIn:
borg delete -a 'user:kenny'
borg delete -a 'host:kenny-pc'

# tags match
# tags match (both lines match archives having TAG1 and TAG2)
borg delete -a 'tags:TAG1' -a 'tags:TAG2'
borg delete -a 'tags:TAG1,TAG2'

# archive creation date match
borg delete -a 'date:2025-01'
Expand Down
98 changes: 88 additions & 10 deletions src/borg/archiver/tag_cmd.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from ._common import with_repository, define_archive_filters_group, archive_match_patterns
from ..archive import Archive
from ..constants import * # NOQA
from ..helpers import bin_to_hex, archivename_validator, tag_validator
from ..helpers import CommandError, bin_to_hex, archivename_validator, tag_validator
from ..helpers.argparsing import ArgumentParser

from ..logger import create_logger
Expand All @@ -14,27 +14,49 @@ class TagMixIn:
def do_tag(self, args, repository, manifest, cache):
"""Manage tags."""

modifying = args.set_tags is not None or args.clear_tags or args.add_tags or args.remove_tags
# 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)
)
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))]
else:
archive_infos = manifest.archives.list_considering(args)

for archive_info in archive_infos:
archive = Archive(manifest, archive_info, cache=cache)
old_tags = set(archive.tags)
if args.set_tags is not None:
# avoid that --set (accidentally) erases existing special tags,
# but allow --set if the existing special tags are also given.
new_tags = set(args.set_tags)
existing_special = {tag for tag in archive.tags if tag.startswith("@")}
clobber = not existing_special.issubset(new_tags)
if not clobber:
missing_special = existing_special - new_tags
if missing_special:
self.print_warning(
f"Archive {archive_info.name} {bin_to_hex(archive_info.id):.8}: not setting tags, "
f"this would remove special tags {','.join(sorted(missing_special))}. "
f"Also give them to --set or use --add / --remove."
)
else:
archive.tags = new_tags
if args.clear_tags:
# only remove normal tags, keep special tags.
archive.tags = {tag for tag in archive.tags if tag.startswith("@")}
archive.tags |= set(args.add_tags or [])
archive.tags -= set(args.remove_tags or [])
old_id = archive.id
archive.set_meta("tags", list(sorted(archive.tags)))
if old_id != archive.id:
manifest.archives.delete_by_id(old_id)
if archive.tags != old_tags:
# only rewrite the archive metadata if the tags changed, so that just
# listing the tags (or a no-op change) does not write to the repository.
archive.set_meta("tags", list(sorted(archive.tags)))
if old_id != archive.id:
manifest.archives.delete_by_id(old_id)
print(
f"id: {bin_to_hex(old_id):.8} -> {bin_to_hex(archive.id):.8}, "
f"tags: {','.join(sorted(archive.tags))}."
Expand All @@ -59,15 +81,71 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser):

Pre-existing special tags cannot be removed via ``--set``. You can still use
``--set``, but you must also give pre-existing special tags (so they won't be
removed).
removed). If they are not given, borg emits a warning and does not set the tags
of that archive.

``--clear`` removes all normal tags, but keeps special tags. Combined with
``--add``, it replaces the normal tags.

To change tags, you must select the archives: give an archive NAME or use archive
filter options like ``--match-archives``. To change the tags of all archives, use
``--match-archives 'sh:*'``.

Each of ``--set``, ``--add`` and ``--remove`` takes exactly one tag. To give
multiple tags, use the option multiple times.

Examples::

# add the tags "important" and "keep" to the archive with the given ID
$ borg tag --add important --add keep aid:1ddaae55

# remove the tag "keep" from all archives named "home"
$ borg tag --remove keep --match-archives home

# set the tags of the archive with the given ID to exactly "foo" and "bar"
$ borg tag --set foo --set bar aid:1ddaae55

# remove all normal tags (but not special tags like @PROT) from the archive with the given ID
$ borg tag --clear aid:1ddaae55

# protect the archive with the given ID against deletion and pruning
$ borg tag --add @PROT aid:1ddaae55
"""
)
subparser = ArgumentParser(parents=[common_parser], description=self.do_tag.__doc__, epilog=tag_epilog)
subparsers.add_subcommand("tag", subparser, help="tag archives")
subparser.add_argument("--set", dest="set_tags", metavar="TAG", type=tag_validator, nargs="*", help="set tags")
subparser.add_argument("--add", dest="add_tags", metavar="TAG", type=tag_validator, nargs="*", help="add tags")
# each option takes exactly one tag, so it can not swallow the NAME positional argument.
# note: "extend" with nargs=1 (not "append") gives a flat list of tags that jsonargparse can validate.
set_clear_group = subparser.add_mutually_exclusive_group()
set_clear_group.add_argument(
"--set",
dest="set_tags",
metavar="TAG",
type=tag_validator,
action="extend",
nargs=1,
help="set tags (can be given multiple times)",
)
set_clear_group.add_argument(
"--clear", dest="clear_tags", action="store_true", help="remove all normal tags (keep special tags)"
)
subparser.add_argument(
"--add",
dest="add_tags",
metavar="TAG",
type=tag_validator,
action="extend",
nargs=1,
help="add tag (can be given multiple times)",
)
subparser.add_argument(
"--remove", dest="remove_tags", metavar="TAG", type=tag_validator, nargs="*", help="remove tags"
"--remove",
dest="remove_tags",
metavar="TAG",
type=tag_validator,
action="extend",
nargs=1,
help="remove tag (can be given multiple times)",
)
define_archive_filters_group(subparser)
subparser.add_argument(
Expand Down
2 changes: 1 addition & 1 deletion src/borg/testsuite/archiver/completion_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def test_tcsh_completion_dynamic_helpers(archivers, request):
assert "aid:{id}{NL}" in helper and "{archive}{NL}" in helper and "{tags}{NL}" in helper
assert "`" not in helper, "backquotes in the helper would nest inside the completion rules"
assert "eval _borg_complete_archive" in output, "archive completion not used for ARCHIVE"
assert "'n/--tags/`_borg_complete_tags`/'" in output, "tag completion not used for --tags"
assert "'n/--tag/`_borg_complete_tags`/'" in output, "tag completion not used for --tag"


def test_tcsh_completion_positional_patterns(archivers, request):
Expand Down
4 changes: 2 additions & 2 deletions src/borg/testsuite/archiver/create_cmd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ def test_create_tags(archivers, request):
archiver = request.getfixturevalue(archivers)
create_test_files(archiver.input_path)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "--tags", "foo", "bar", "baz", "--", "test", "input")
cmd(archiver, "create", "--tag", "foo", "--tag", "bar", "--tag", "baz", "test", "input")
info = cmd(archiver, "info", "--json", "test")
info = json.loads(info)
assert sorted(info["archives"][0]["tags"]) == ["bar", "baz", "foo"]
Expand All @@ -780,7 +780,7 @@ def test_create_invalid_tags(archivers, request):
archiver = request.getfixturevalue(archivers)
create_test_files(archiver.input_path)
cmd(archiver, "repo-create", RK_ENCRYPTION)
output = cmd(archiver, "create", "--tags", "@INVALID", "--", "test", "input", exit_code=EXIT_ERROR)
output = cmd(archiver, "create", "--tag", "@INVALID", "test", "input", exit_code=EXIT_ERROR)
assert "Unknown special tags given" in output


Expand Down
100 changes: 95 additions & 5 deletions src/borg/testsuite/archiver/tag_cmd_test.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import os
from pathlib import Path

import pytest

from ...constants import * # NOQA
from ...helpers import CommandError
from . import cmd, generate_archiver_tests, RK_ENCRYPTION

pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA
Expand All @@ -12,10 +18,8 @@ def test_tag_set(archivers, request):
assert "tags: aa." in output
output = cmd(archiver, "tag", "-a", "archive", "--set", "bb")
assert "tags: bb." in output
output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", "aa")
output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", "--set", "aa")
assert "tags: aa,bb." in output # sorted!
output = cmd(archiver, "tag", "-a", "archive", "--set")
assert "tags: ." in output # no tags!


def test_tag_add_remove(archivers, request):
Expand All @@ -40,13 +44,35 @@ def test_tag_set_noclobber_special(archivers, request):
assert "tags: @PROT." in output
# archive now has a special tag.
# it must not be possible to accidentally erase such special tags by using --set:
output = cmd(archiver, "tag", "-a", "archive", "--set", "clobber")
output = cmd(archiver, "tag", "-a", "archive", "--set", "clobber", exit_code=EXIT_WARNING)
assert "not setting tags, this would remove special tags @PROT." in output
assert "tags: @PROT." in output
# it is possible though to use --set if the existing special tags are also given:
output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "@PROT")
output = cmd(archiver, "tag", "-a", "archive", "--set", "noclobber", "--set", "@PROT")
assert "tags: @PROT,noclobber." in output


def test_tag_clear(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "archive", archiver.input_path)
output = cmd(archiver, "tag", "-a", "archive", "--add", "aa", "--add", "bb")
assert "tags: aa,bb." in output
output = cmd(archiver, "tag", "-a", "archive", "--clear")
assert "tags: ." in output # no tags!
output = cmd(archiver, "tag", "-a", "archive", "--add", "aa", "--add", "@PROT")
assert "tags: @PROT,aa." in output
# --clear must not remove special tags:
output = cmd(archiver, "tag", "-a", "archive", "--clear")
assert "tags: @PROT." in output
# --clear with --add replaces the normal tags:
cmd(archiver, "tag", "-a", "archive", "--add", "aa")
output = cmd(archiver, "tag", "-a", "archive", "--clear", "--add", "bb")
assert "tags: @PROT,bb." in output
# --clear and --set are mutually exclusive:
cmd(archiver, "tag", "-a", "archive", "--clear", "--set", "cc", exit_code=EXIT_ERROR)


def test_tag_only_known_special(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
Expand All @@ -55,3 +81,67 @@ def test_tag_only_known_special(archivers, request):
cmd(archiver, "tag", "-a", "archive", "--set", "@UNKNOWN", exit_code=EXIT_ERROR)
cmd(archiver, "tag", "-a", "archive", "--add", "@UNKNOWN", exit_code=EXIT_ERROR)
cmd(archiver, "tag", "-a", "archive", "--remove", "@UNKNOWN", exit_code=EXIT_ERROR)


def test_tag_options_before_archive_name(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "archive", archiver.input_path)
# tag options take one tag each, so they must not swallow the archive name given after them.
output = cmd(archiver, "tag", "--add", "aa", "--add", "bb", "archive")
assert "tags: aa,bb." in output
output = cmd(archiver, "tag", "--remove", "aa", "archive")
assert "tags: bb." in output
output = cmd(archiver, "tag", "--set", "cc", "--set", "dd", "archive")
assert "tags: cc,dd." in output


def test_tag_all_archives_needs_selection(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "archive1", archiver.input_path)
cmd(archiver, "create", "archive2", archiver.input_path)
cmd(archiver, "tag", "-a", "sh:*", "--add", "aa")
# Without NAME or archive filters, borg must refuse to change the tags of all archives.
msg = "if you really want to change the tags of all archives"
for tag_args in (["--set", "bb"], ["--clear"], ["--add", "bb"], ["--remove", "aa"]):
if archiver.FORK_DEFAULT:
output = cmd(archiver, "tag", *tag_args, exit_code=CommandError().exit_code)
assert msg in output
else:
with pytest.raises(CommandError, match=msg):
cmd(archiver, "tag", *tag_args)
# just showing the tags of all archives is fine:
output = cmd(archiver, "tag")
assert output.count("tags: aa.") == 2
# an explicit selection of all archives is fine:
output = cmd(archiver, "tag", "-a", "sh:*", "--clear")
assert output.count("tags: .") == 2


def test_tag_unchanged_tags_do_not_rewrite_archive(archivers, request):
archiver = request.getfixturevalue(archivers)
cmd(archiver, "repo-create", RK_ENCRYPTION)
cmd(archiver, "create", "archive", archiver.input_path)
cmd(archiver, "tag", "-a", "archive", "--add", "aa", "--add", "@PROT")
archives_dir = Path(archiver.repository_path) / "archives"

def live_entries():
# the archive entry objects, without soft-deleted ones (having a .del suffix).
return [p for p in archives_dir.iterdir() if not p.name.endswith(".del")]

(archive_entry,) = live_entries()
st_before = os.stat(archive_entry)
# neither just listing the tags nor a no-op change must rewrite the archive metadata:
output = cmd(archiver, "tag")
assert "tags: @PROT,aa." in output
output = cmd(archiver, "tag", "-a", "archive", "--add", "aa")
assert "tags: @PROT,aa." in output
output = cmd(archiver, "tag", "-a", "archive", "--remove", "zz")
assert "tags: @PROT,aa." in output
# a refused --set does not change the tags either:
output = cmd(archiver, "tag", "-a", "archive", "--set", "bb", exit_code=EXIT_WARNING)
assert "tags: @PROT,aa." in output
st_after = os.stat(archive_entry)
assert (st_after.st_mtime_ns, st_after.st_ino) == (st_before.st_mtime_ns, st_before.st_ino)
assert live_entries() == [archive_entry]
Loading