diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 2f18b485b8..79c1a2ab37 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -138,9 +138,29 @@ 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.' ) + # --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: @@ -320,6 +340,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", diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index a18212c85e..df75d09cf1 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 @@ -272,6 +272,76 @@ 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"} + + +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 + + +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