From 2712d2f3bbbb74640432a4a6c3f0d3fdb0b17d00 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 25 Sep 2026 21:44:08 +0200 Subject: [PATCH 1/7] tag: --set/--add/--remove take one tag per option, fixes #10430 They used nargs="*" and thus swallowed the archive NAME if it was given after them, e.g. "borg tag --set keep aid:1ddaae55" failed. Now each option takes exactly one tag and can be given multiple times, e.g. "borg tag --add a --add b NAME". Also add examples to the "borg tag" help. Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/tag_cmd.py | 47 +++++++++++++++++++-- src/borg/testsuite/archiver/tag_cmd_test.py | 19 +++++++-- 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index f78a67c424..8b3e365a6d 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -60,14 +60,55 @@ 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). + + 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 + + # 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. + subparser.add_argument( + "--set", + dest="set_tags", + metavar="TAG", + type=tag_validator, + action="extend", + nargs=1, + help="set tags (can be given multiple times)", + ) + 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/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index 567fe5494a..c8eb68628b 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -12,10 +12,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): @@ -43,7 +41,7 @@ def test_tag_set_noclobber_special(archivers, request): output = cmd(archiver, "tag", "-a", "archive", "--set", "clobber") 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 @@ -55,3 +53,16 @@ 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 From 01d42bec439099582f698c4e55348ebf1226606f Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 25 Sep 2026 21:45:03 +0200 Subject: [PATCH 2/7] tag: add --clear to remove all normal tags Special tags like @PROT are kept. --clear can be combined with --add to replace the normal tags, it is mutually exclusive with --set. Previously, a bare "--set" cleared all tags, but that is not possible anymore since --set now takes exactly one tag. Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/tag_cmd.py | 15 ++++++++++++++- src/borg/testsuite/archiver/tag_cmd_test.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 8b3e365a6d..16ab872775 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -29,6 +29,9 @@ def do_tag(self, args, repository, manifest, cache): clobber = not existing_special.issubset(new_tags) if not clobber: 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 @@ -61,6 +64,9 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): ``--set``, but you must also give pre-existing special tags (so they won't be removed). + ``--clear`` removes all normal tags, but keeps special tags. Combined with + ``--add``, it replaces the normal tags. + Each of ``--set``, ``--add`` and ``--remove`` takes exactly one tag. To give multiple tags, use the option multiple times. @@ -75,6 +81,9 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): # 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 """ @@ -83,7 +92,8 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): subparsers.add_subcommand("tag", subparser, help="tag archives") # 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. - subparser.add_argument( + set_clear_group = subparser.add_mutually_exclusive_group() + set_clear_group.add_argument( "--set", dest="set_tags", metavar="TAG", @@ -92,6 +102,9 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): 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", diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index c8eb68628b..632ca32b00 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -45,6 +45,27 @@ def test_tag_set_noclobber_special(archivers, request): 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) From 83b6583dd7a51cb855d7bff43379d425fa8170f4 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 25 Sep 2026 21:47:55 +0200 Subject: [PATCH 3/7] create: replace --tags with --tag, taking one tag per option --tags used nargs="+" and thus swallowed the archive NAME if it was given after it, e.g. "borg create --tags foo NAME PATH" failed. Now --tag takes exactly one tag and can be given multiple times, e.g. "borg create --tag foo --tag bar NAME PATH". Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/create_cmd.py | 11 +++++++---- src/borg/testsuite/archiver/completion_cmd_test.py | 2 +- src/borg/testsuite/archiver/create_cmd_test.py | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) 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/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 From 2c058d1d892303f663b44c63e527bc50190e8620 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 25 Sep 2026 22:04:12 +0200 Subject: [PATCH 4/7] tag: refuse to change the tags of all archives without a selection Without NAME or archive filter options, "borg tag --set/--clear/--add/--remove" silently changed the tags of all archives in the repository. Now it aborts like "borg delete" does, unless archives are selected explicitly, e.g. using -a 'sh:*' for all archives. Just showing the tags of all archives still works without a selection. Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/tag_cmd.py | 15 +++++++++++- src/borg/testsuite/archiver/tag_cmd_test.py | 26 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 16ab872775..1afd4a74e4 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: @@ -67,6 +76,10 @@ def build_parser_tag(self, subparsers, common_parser, mid_common_parser): ``--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. diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index 632ca32b00..da71243962 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -1,4 +1,7 @@ +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 @@ -87,3 +90,26 @@ def test_tag_options_before_archive_name(archivers, request): 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 From d76a58b7294c3d007955bbfff9991af755e13b0a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 25 Sep 2026 22:04:43 +0200 Subject: [PATCH 5/7] tag: warn if --set is refused because it would remove special tags Previously, borg silently did not set the tags, so it looked like the given tags were just ignored, and the return code was 0. Now borg emits a warning naming the special tags that would be removed and exits with rc 1 (warning). Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/tag_cmd.py | 13 ++++++++++--- src/borg/testsuite/archiver/tag_cmd_test.py | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 1afd4a74e4..08fcec375c 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -35,8 +35,14 @@ def do_tag(self, args, repository, manifest, cache): # 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. @@ -71,7 +77,8 @@ 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. diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index da71243962..02a6577bed 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -41,7 +41,8 @@ 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", "--set", "@PROT") From 7ecf12df2ba7617b51ca862d2a6deab161c8de79 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 25 Sep 2026 22:05:03 +0200 Subject: [PATCH 6/7] docs: explain the tags: archive match pattern Document that tags: takes a comma-separated list of tags and matches archives having all of them. Co-Authored-By: Claude Opus 5.5 --- src/borg/archiver/help_cmd.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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' From cce5c99f4f2897f240ad14e3bcf7e8bae0df8451 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sat, 26 Sep 2026 22:35:08 +0200 Subject: [PATCH 7/7] tag: only rewrite the archive metadata if the tags changed Previously, "borg tag" always rewrote the archive metadata (and thus the archive entry in the repository), even when just listing the tags or when the requested change was a no-op (including a refused --set). Co-Authored-By: Claude Fable 5.1 --- src/borg/archiver/tag_cmd.py | 10 +++++-- src/borg/testsuite/archiver/tag_cmd_test.py | 31 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/borg/archiver/tag_cmd.py b/src/borg/archiver/tag_cmd.py index 08fcec375c..041ea5f400 100644 --- a/src/borg/archiver/tag_cmd.py +++ b/src/borg/archiver/tag_cmd.py @@ -30,6 +30,7 @@ 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. @@ -50,9 +51,12 @@ def do_tag(self, args, repository, manifest, cache): 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))}." diff --git a/src/borg/testsuite/archiver/tag_cmd_test.py b/src/borg/testsuite/archiver/tag_cmd_test.py index 02a6577bed..a937bbb0e5 100644 --- a/src/borg/testsuite/archiver/tag_cmd_test.py +++ b/src/borg/testsuite/archiver/tag_cmd_test.py @@ -1,3 +1,6 @@ +import os +from pathlib import Path + import pytest from ...constants import * # NOQA @@ -114,3 +117,31 @@ def test_tag_all_archives_needs_selection(archivers, request): # 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]