Add --json flag to borg prune for structured output (#9512)
Some checks failed
Lint / lint (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / security (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
CI / asan_ubsan (push) Has been cancelled
CI / native_tests (push) Has been cancelled
CI / vm_tests (Haiku, false, haiku, r1beta5) (push) Has been cancelled
CI / vm_tests (NetBSD, false, netbsd, 10.1) (push) Has been cancelled
CI / vm_tests (OmniOS, false, omnios, r151056) (push) Has been cancelled
CI / vm_tests (OpenBSD, false, openbsd, 7.8) (push) Has been cancelled
CI / vm_tests (borg-freebsd-14-x86_64-gh, FreeBSD, true, freebsd, 14.3) (push) Has been cancelled
CI / windows_tests (push) Has been cancelled

prune: add --json option, fixes #9222

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.
This commit is contained in:
Ebuzer Celil Durmaz 2026-03-20 23:03:01 +03:00 committed by GitHub
parent 8eff8270c0
commit 6bc41465a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 89 additions and 12 deletions

View file

@ -8,6 +8,7 @@ from ._common import with_repository, Highlander
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 @@ class PruneMixIn:
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,29 +182,50 @@ class PruneMixIn:
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()
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
archive_data["deleted_archive_number"] = archives_deleted
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["kept"] = True
archive_data["keep_rule"] = rule
archive_data["kept_archive_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 archives_deleted > 0:
if not args.json:
pi.finish()
if args.json:
json_print(basic_json_data(manifest, extra={"archives": output_data}))
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:
@ -295,6 +320,14 @@ class PruneMixIn:
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",

View file

@ -1,3 +1,4 @@
import json
import re
from datetime import datetime, timezone, timedelta
@ -416,3 +417,46 @@ 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 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]["keep_rule"] == "daily"
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
assert "time" in archive
assert "kept" 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]["kept"] is False
assert archives[0]["deleted_archive_number"] == 1