From 4092ebf06596499b1229c42559551f495631a752 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Thu, 19 Mar 2026 05:00:52 +0300 Subject: [PATCH 1/4] Add --json flag to borg prune for structured output Enable programmatic extraction of prune/keep decisions via structured JSON output, instead of parsing log message text. Follows the repo-list --json pattern: outputs a single JSON object with repository, encryption, and archives array. Each archive includes pruned (bool), rule, and rule_number fields. --- src/borg/archiver/prune_cmd.py | 52 +++++++++++++++---- src/borg/testsuite/archiver/prune_cmd_test.py | 39 ++++++++++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 19b8426c97..c651ee3640 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -8,6 +8,7 @@ from ..constants import * # NOQA from ..helpers import ArchiveFormatter, interval, sig_int, ProgressIndicatorPercent, CommandError, Error from ..helpers import archivename_validator +from ..helpers import json_print, basic_json_data from ..helpers.argparsing import ArgumentParser from ..manifest import Manifest @@ -168,8 +169,11 @@ def do_prune(self, args, repository, manifest): keep += prune_split(archives, rule, num, kept_because) to_delete = set(archives) - set(keep) - logger.info("Found %d archives.", len(archives)) - logger.info("Keeping %d archives, pruning %d archives.", len(keep), len(to_delete)) + if not args.json: + logger.info("Found %d archives.", len(archives)) + logger.info("Keeping %d archives, pruning %d archives.", len(keep), len(to_delete)) + if args.json: + output_data = [] list_logger = logging.getLogger("borg.output.list") # set up counters for the progress display to_delete_len = len(to_delete) @@ -178,28 +182,48 @@ def do_prune(self, args, repository, manifest): for archive_info in archives: if sig_int and sig_int.action_done(): break - # format_item may internally load the archive from the repository, + # get_item_data/format_item may internally load the archive from the repository, # so we must call it before deleting the archive. - archive_formatted = formatter.format_item(archive_info, jsonline=False) + if args.json: + archive_data = formatter.get_item_data(archive_info, jsonline=True) + else: + archive_formatted = formatter.format_item(archive_info, jsonline=False) if archive_info in to_delete: - pi.show() + if not args.json: + pi.show() if args.dry_run: log_message = "Would prune:" else: log_message = "Pruning archive (%d/%d):" % (archives_deleted, to_delete_len) manifest.archives.delete_by_id(archive_info.id) archives_deleted += 1 + if args.json: + archive_data["pruned"] = True else: - log_message = "Keeping archive (rule: {rule} #{num}):".format( - rule=kept_because[archive_info.id][0], num=kept_because[archive_info.id][1] - ) - if ( + rule, num = kept_because[archive_info.id] + log_message = "Keeping archive (rule: {rule} #{num}):".format(rule=rule, num=num) + if args.json: + archive_data["pruned"] = False + archive_data["rule"] = rule + archive_data["rule_number"] = num + if args.json: + if ( + args.output_list + or not (args.list_pruned or args.list_kept) + or (args.list_pruned and archive_info in to_delete) + or (args.list_kept and archive_info not in to_delete) + ): + output_data.append(archive_data) + elif ( args.output_list or (args.list_pruned and archive_info in to_delete) or (args.list_kept and archive_info not in to_delete) ): list_logger.info(f"{log_message:<44} {archive_formatted}") - pi.finish() + if not args.json: + pi.finish() + if args.json: + json_print(basic_json_data(manifest, extra={"archives": output_data})) if archives_deleted > 0: manifest.write() self.print_warning('Done. Run "borg compact" to free space.', wc=None) @@ -295,6 +319,14 @@ def build_parser_prune(self, subparsers, common_parser, mid_common_parser): action=Highlander, help="specify format for the archive part " '(default: "{archive:<36} {time} [{id}]")', ) + subparser.add_argument( + "--json", + action="store_true", + help="Format output as JSON. " + "The form of ``--format`` is ignored, " + "but keys used in it are added to the JSON output. " + "Some keys are always present. Note: JSON can only represent text.", + ) subparser.add_argument( "--keep-within", metavar="INTERVAL", diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index abf431c1c1..3c64c2fb68 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -1,3 +1,4 @@ +import json import re from datetime import datetime, timezone, timedelta @@ -416,3 +417,41 @@ def test_prune_list_with_metadata_format(archivers, request, backup_files): output = cmd(archiver, "prune", "--list", "--keep-daily=1", "--format={name} {hostname}{NL}") assert "test1" in output assert "test2" in output + + +def test_prune_json(archivers, request, backup_files): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", backup_files) + cmd(archiver, "create", "test2", backup_files) + prune_result = json.loads(cmd(archiver, "prune", "--json", "--dry-run", "--keep-daily=1")) + assert "repository" in prune_result + assert "encryption" in prune_result + assert len(prune_result["repository"]["id"]) == 64 + archives = prune_result["archives"] + assert len(archives) == 2 + kept = [a for a in archives if not a["pruned"]] + pruned = [a for a in archives if a["pruned"]] + assert len(kept) == 1 + assert len(pruned) == 1 + assert kept[0]["name"] == "test2" + assert kept[0]["rule"] == "daily" + assert kept[0]["rule_number"] == 1 + assert pruned[0]["name"] == "test1" + for archive in archives: + assert "name" in archive + assert "id" in archive + assert "time" in archive + assert "pruned" in archive + + +def test_prune_json_list_pruned(archivers, request, backup_files): + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", backup_files) + cmd(archiver, "create", "test2", backup_files) + prune_result = json.loads(cmd(archiver, "prune", "--json", "--dry-run", "--list-pruned", "--keep-daily=1")) + archives = prune_result["archives"] + assert len(archives) == 1 + assert archives[0]["name"] == "test1" + assert archives[0]["pruned"] is True From cb4ed496eaaab8d5fdcebf99dbc596544315f719 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Thu, 19 Mar 2026 16:40:43 +0300 Subject: [PATCH 2/4] Address review: rename JSON fields --- src/borg/archiver/prune_cmd.py | 8 ++++---- src/borg/testsuite/archiver/prune_cmd_test.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index c651ee3640..be87bc6b5f 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -198,14 +198,14 @@ def do_prune(self, args, repository, manifest): manifest.archives.delete_by_id(archive_info.id) archives_deleted += 1 if args.json: - archive_data["pruned"] = True + archive_data["kept"] = False else: rule, num = kept_because[archive_info.id] log_message = "Keeping archive (rule: {rule} #{num}):".format(rule=rule, num=num) if args.json: - archive_data["pruned"] = False - archive_data["rule"] = rule - archive_data["rule_number"] = num + archive_data["kept"] = True + archive_data["keep_rule"] = rule + archive_data["keep_rule_number"] = num if args.json: if ( args.output_list diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index 3c64c2fb68..c07bd56656 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -430,19 +430,19 @@ def test_prune_json(archivers, request, backup_files): assert len(prune_result["repository"]["id"]) == 64 archives = prune_result["archives"] assert len(archives) == 2 - kept = [a for a in archives if not a["pruned"]] - pruned = [a for a in archives if a["pruned"]] + kept = [a for a in archives if a["kept"]] + pruned = [a for a in archives if not a["kept"]] assert len(kept) == 1 assert len(pruned) == 1 assert kept[0]["name"] == "test2" - assert kept[0]["rule"] == "daily" - assert kept[0]["rule_number"] == 1 + assert kept[0]["keep_rule"] == "daily" + assert kept[0]["keep_rule_number"] == 1 assert pruned[0]["name"] == "test1" for archive in archives: assert "name" in archive assert "id" in archive assert "time" in archive - assert "pruned" in archive + assert "kept" in archive def test_prune_json_list_pruned(archivers, request, backup_files): @@ -454,4 +454,4 @@ def test_prune_json_list_pruned(archivers, request, backup_files): archives = prune_result["archives"] assert len(archives) == 1 assert archives[0]["name"] == "test1" - assert archives[0]["pruned"] is True + assert archives[0]["kept"] is False From 86b82714cb1602a08a4e9f67ed38c3674d2d6214 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Thu, 19 Mar 2026 22:10:16 +0300 Subject: [PATCH 3/4] Rename JSON fields per review feedback --- src/borg/archiver/prune_cmd.py | 5 ++++- src/borg/testsuite/archiver/prune_cmd_test.py | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index be87bc6b5f..3588573179 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -178,6 +178,7 @@ def do_prune(self, args, repository, manifest): # set up counters for the progress display to_delete_len = len(to_delete) archives_deleted = 0 + deleted_archive_counter = 0 pi = ProgressIndicatorPercent(total=len(to_delete), msg="Pruning archives %3.0f%%", msgid="prune") for archive_info in archives: if sig_int and sig_int.action_done(): @@ -199,13 +200,15 @@ def do_prune(self, args, repository, manifest): archives_deleted += 1 if args.json: archive_data["kept"] = False + deleted_archive_counter += 1 + archive_data["deleted_archive_number"] = deleted_archive_counter else: rule, num = kept_because[archive_info.id] log_message = "Keeping archive (rule: {rule} #{num}):".format(rule=rule, num=num) if args.json: archive_data["kept"] = True archive_data["keep_rule"] = rule - archive_data["keep_rule_number"] = num + archive_data["kept_archive_number"] = num if args.json: if ( args.output_list diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index c07bd56656..14420a7a5d 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -436,8 +436,12 @@ def test_prune_json(archivers, request, backup_files): assert len(pruned) == 1 assert kept[0]["name"] == "test2" assert kept[0]["keep_rule"] == "daily" - assert kept[0]["keep_rule_number"] == 1 + assert kept[0]["kept_archive_number"] == 1 + assert "deleted_archive_number" not in kept[0] assert pruned[0]["name"] == "test1" + assert pruned[0]["deleted_archive_number"] == 1 + assert "keep_rule" not in pruned[0] + assert "kept_archive_number" not in pruned[0] for archive in archives: assert "name" in archive assert "id" in archive @@ -455,3 +459,4 @@ def test_prune_json_list_pruned(archivers, request, backup_files): assert len(archives) == 1 assert archives[0]["name"] == "test1" assert archives[0]["kept"] is False + assert archives[0]["deleted_archive_number"] == 1 From a8d39d07ea5c2667a7fd9bba4bbeb5163cdb9761 Mon Sep 17 00:00:00 2001 From: ebuzerdrmz44 Date: Thu, 19 Mar 2026 23:07:34 +0300 Subject: [PATCH 4/4] Reuse archives_deleted counter, guard manifest write for dry-run --- src/borg/archiver/prune_cmd.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 3588573179..92cf813301 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -178,7 +178,6 @@ def do_prune(self, args, repository, manifest): # set up counters for the progress display to_delete_len = len(to_delete) archives_deleted = 0 - deleted_archive_counter = 0 pi = ProgressIndicatorPercent(total=len(to_delete), msg="Pruning archives %3.0f%%", msgid="prune") for archive_info in archives: if sig_int and sig_int.action_done(): @@ -192,16 +191,15 @@ def do_prune(self, args, repository, manifest): if archive_info in to_delete: if not args.json: pi.show() + archives_deleted += 1 if args.dry_run: log_message = "Would prune:" else: log_message = "Pruning archive (%d/%d):" % (archives_deleted, to_delete_len) manifest.archives.delete_by_id(archive_info.id) - archives_deleted += 1 if args.json: archive_data["kept"] = False - deleted_archive_counter += 1 - archive_data["deleted_archive_number"] = deleted_archive_counter + archive_data["deleted_archive_number"] = archives_deleted else: rule, num = kept_because[archive_info.id] log_message = "Keeping archive (rule: {rule} #{num}):".format(rule=rule, num=num) @@ -227,7 +225,7 @@ def do_prune(self, args, repository, manifest): pi.finish() if args.json: json_print(basic_json_data(manifest, extra={"archives": output_data})) - if archives_deleted > 0: + if archives_deleted > 0 and not args.dry_run: manifest.write() self.print_warning('Done. Run "borg compact" to free space.', wc=None) if sig_int: