From 2d0e590be5baa2fdadc72e6a5fbb7d8ee79474a4 Mon Sep 17 00:00:00 2001 From: Michael Deyaso Date: Wed, 22 Mar 2023 14:58:56 +0300 Subject: [PATCH 1/4] prune --keep-all: alias for --keep-last , fixes #6656 --- src/borg/archiver/prune_cmd.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 2f18b485b8..ebcb9688f2 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -138,7 +138,7 @@ def do_prune(self, args, repository, manifest): 'At least one of the "keep-within", "keep-last", ' '"keep-secondly", "keep-minutely", "keep-hourly", "keep-daily", ' '"keep-weekly", "keep-monthly", "keep-13weekly", "keep-3monthly", ' - 'or "keep-yearly" settings must be specified.' + '"keep-yearly", or "keep-all" settings must be specified.' ) if args.format is not None: @@ -320,6 +320,13 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): action=Highlander, help="number of secondly archives to keep", ) + subparser.add_argument( + "--keep-all", + dest="secondly", + action="store_const", + const=float("inf"), + help="keep all archives (alias of --keep-last=)", + ) subparser.add_argument( "--keep-minutely", dest="minutely", From 4d27c7d54d7deb605e3725fcbaf438d38af0407a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 10 Oct 2025 12:08:07 +0200 Subject: [PATCH 2/4] add test for --keep-all --- src/borg/testsuite/archiver/prune_cmd_test.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index a18212c85e..bffda777ad 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -272,6 +272,32 @@ def __repr__(self): return f"{self.id}: {self.ts.isoformat()}" +def test_prune_keep_all(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + # create a few archives with distinct seconds so 'secondly' rule can keep all + _create_archive_ts(archiver, "a1", 2025, 1, 1, 0, 0, 0) + _create_archive_ts(archiver, "a2", 2025, 1, 1, 0, 0, 1) + _create_archive_ts(archiver, "a3", 2025, 1, 1, 0, 0, 2) + + # Dry-run prune: nothing should be pruned, all should be kept under 'secondly' rule + output = cmd(archiver, "prune", "--list", "--dry-run", "--keep-all") + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+a1", output) + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+a2", output) + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+a3", output) + assert "Would prune:" not in output + assert "Pruning archive" not in output + output = cmd(archiver, "repo-list", "--format", "{name}{NL}") + names = set(output.splitlines()) + assert names == {"a1", "a2", "a3"} + + # Real prune with --keep-all should also not delete anything + cmd(archiver, "prune", "--keep-all") + output = cmd(archiver, "repo-list", "--format", "{name}{NL}") + names = set(output.splitlines()) + assert names == {"a1", "a2", "a3"} + + # This is the local timezone of the system running the tests. # We need this e.g. to construct archive timestamps for the prune tests, # because borg prune operates in the local timezone (it first converts the From 0b9899a1929be22d615ad333b86b5be08627834b Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 10 Oct 2025 12:25:21 +0200 Subject: [PATCH 3/4] make --keep-all mutually exclusive to other --keep-* options, tests --- src/borg/archiver/prune_cmd.py | 20 +++++++++++++++++++ src/borg/testsuite/archiver/prune_cmd_test.py | 20 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index ebcb9688f2..79c1a2ab37 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -141,6 +141,26 @@ def do_prune(self, args, repository, manifest): '"keep-yearly", or "keep-all" settings must be specified.' ) + # --keep-all is an alias for --keep-last= and must not be + # used together with any other --keep-... option, because that would be + # misleading. Enforce this at argument parsing/validation time. + keep_all = args.secondly == float("inf") + if keep_all: + if any( + ( + args.minutely, + args.hourly, + args.daily, + args.weekly, + args.monthly, + args.quarterly_13weekly, + args.quarterly_3monthly, + args.yearly, + args.within, + ) + ): + raise CommandError("--keep-all cannot be combined with other --keep-... options.") + if args.format is not None: format = args.format elif args.short: diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index bffda777ad..0990441e30 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -6,7 +6,7 @@ from ...constants import * # NOQA from ...archiver.prune_cmd import prune_split, prune_within from . import cmd, RK_ENCRYPTION, src_dir, generate_archiver_tests -from ...helpers import interval +from ...helpers import interval, CommandError pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local,remote,binary") # NOQA @@ -298,6 +298,24 @@ def test_prune_keep_all(archivers, request): assert names == {"a1", "a2", "a3"} +def test_prune_keep_all_mutually_exclusive_with_others(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + # create a single archive + _create_archive_ts(archiver, "x1", 2025, 1, 1, 0, 0, 0) + # Using --keep-all together with any other keep option must error out + output = cmd(archiver, "prune", "--keep-all", "--keep-daily=1", exit_code=CommandError().exit_code, fork=True) + assert "--keep-all cannot be combined" in output + + +def test_prune_keep_all_mutually_exclusive_with_within(archivers, request): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + _create_archive_ts(archiver, "x1", 2025, 1, 1, 0, 0, 0) + output = cmd(archiver, "prune", "--keep-all", "--keep-within", "1d", exit_code=CommandError().exit_code, fork=True) + assert "--keep-all cannot be combined" in output + + # This is the local timezone of the system running the tests. # We need this e.g. to construct archive timestamps for the prune tests, # because borg prune operates in the local timezone (it first converts the From c57f66af8ce25764acbf244c27669d90bb2e671a Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Fri, 10 Oct 2025 12:46:34 +0200 Subject: [PATCH 4/4] prune: add failing test for problematic combination --- src/borg/testsuite/archiver/prune_cmd_test.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index 0990441e30..df75d09cf1 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -316,6 +316,32 @@ def test_prune_keep_all_mutually_exclusive_with_within(archivers, request): assert "--keep-all cannot be combined" in output +def test_prune_keep_all_and_keep_last_2(archivers, request): + # Problem: --keep-all, --keep-secondly and --keep-last=X use the same variable + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + # Create three archives with distinct seconds + _create_archive_ts(archiver, "c1", 2025, 1, 1, 0, 0, 0) + _create_archive_ts(archiver, "c2", 2025, 1, 1, 0, 0, 1) + _create_archive_ts(archiver, "c3", 2025, 1, 1, 0, 0, 2) + + # Dry-run prune: with conflicting options, keep-all dominates + output = cmd(archiver, "prune", "--list", "--dry-run", "--keep-all", "--keep-last=2") + # Expect all kept under 'secondly' rule, nothing would be pruned + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+c1", output) + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+c2", output) + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+c3", output) + assert "Would prune:" not in output + + # Dry-run prune: with conflicting options, keep-all dominates + output = cmd(archiver, "prune", "--list", "--dry-run", "--keep-last=2", "--keep-all") + # Expect all kept under 'secondly' rule, nothing would be pruned + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+c1", output) + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+c2", output) + assert re.search(r"Keeping archive \(rule: secondly(?:\[oldest\])? #\d+\):\s+c3", output) + assert "Would prune:" not in output + + # This is the local timezone of the system running the tests. # We need this e.g. to construct archive timestamps for the prune tests, # because borg prune operates in the local timezone (it first converts the