Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 45 additions & 12 deletions src/borg/archiver/prune_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -178,29 +182,50 @@ 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()
archives_deleted += 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, is that a bug fix - did it count 0..6/7 ?

Copy link
Contributor Author

@ebuzerdrmz44 ebuzerdrmz44 Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it did start from 0

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, good it now counts more naturally 1..7/7.

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:
Expand Down Expand Up @@ -295,6 +320,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",
Expand Down
44 changes: 44 additions & 0 deletions src/borg/testsuite/archiver/prune_cmd_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
from datetime import datetime, timezone, timedelta

Expand Down Expand Up @@ -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
Loading