diff --git a/src/borg/archiver/create_cmd.py b/src/borg/archiver/create_cmd.py index 03e1874570..6285106f1e 100644 --- a/src/borg/archiver/create_cmd.py +++ b/src/borg/archiver/create_cmd.py @@ -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), @@ -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") diff --git a/src/borg/archiver/help_cmd.py b/src/borg/archiver/help_cmd.py index 645a45c77d..74584b6f8a 100644 --- a/src/borg/archiver/help_cmd.py +++ b/src/borg/archiver/help_cmd.py @@ -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, @@ -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' diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index f78a67c424..041ea5f400 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -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 @@ -14,6 +14,15 @@ 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: @@ -21,20 +30,33 @@ def do_tag(self, args, repository, manifest, cache): 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))}." @@ -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( diff --git a/src/borg/testsuite/archiver/completion_cmd_test.py b/src/borg/testsuite/archiver/completion_cmd_test.py index afeea7867e..932b70d9ab 100644 --- a/src/borg/testsuite/archiver/completion_cmd_test.py +++ b/src/borg/testsuite/archiver/completion_cmd_test.py @@ -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): diff --git a/src/borg/testsuite/archiver/create_cmd_test.py b/src/borg/testsuite/archiver/create_cmd_test.py index 7dda8321e1..b94569ee51 100644 --- a/src/borg/testsuite/archiver/create_cmd_test.py +++ b/src/borg/testsuite/archiver/create_cmd_test.py @@ -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"] @@ -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 diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index 567fe5494a..a937bbb0e5 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -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 @@ -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): @@ -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) @@ -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]