From 3c204c31f3fa4cf2f13dcf866e94354fcd88925d Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 1 Mar 2026 05:57:24 +0100 Subject: [PATCH 1/2] prune: fix Archive.DoesNotExist when using --list, fixes #9416 format_item() can trigger lazy loading of archive metadata (e.g. hostname, username, size) from the repository. Previously it was called after archive.delete(), which caused Archive.DoesNotExist for pruned archives. Fix: call formatter.format_item() early, before any deletion takes place. Also added a test. --- src/borg/archiver/prune_cmd.py | 17 ++++++++++------- src/borg/testsuite/archiver/prune_cmd_test.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/borg/archiver/prune_cmd.py b/src/borg/archiver/prune_cmd.py index 1aa9064fb..96d93ae34 100644 --- a/src/borg/archiver/prune_cmd.py +++ b/src/borg/archiver/prune_cmd.py @@ -179,29 +179,32 @@ class PruneMixIn: archives_deleted = 0 uncommitted_deletes = 0 pi = ProgressIndicatorPercent(total=len(to_delete), msg="Pruning archives %3.0f%%", msgid="prune") - for archive in archives: + for archive_info in archives: if sig_int and sig_int.action_done(): break - if archive in to_delete: + # 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 archive_info in to_delete: pi.show() if args.dry_run: log_message = "Would prune:" else: archives_deleted += 1 log_message = "Pruning archive (%d/%d):" % (archives_deleted, to_delete_len) - archive = Archive(manifest, archive.id, cache=cache) + archive = Archive(manifest, archive_info.id, cache=cache) archive.delete() uncommitted_deletes += 1 else: log_message = "Keeping archive (rule: {rule} #{num}):".format( - rule=kept_because[archive.id][0], num=kept_because[archive.id][1] + rule=kept_because[archive_info.id][0], num=kept_because[archive_info.id][1] ) if ( args.output_list - or (args.list_pruned and archive in to_delete) - or (args.list_kept and archive not in to_delete) + 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} {formatter.format_item(archive, jsonline=False)}") + list_logger.info(f"{log_message:<44} {archive_formatted}") pi.finish() if sig_int: raise Error("Got Ctrl-C / SIGINT.") diff --git a/src/borg/testsuite/archiver/prune_cmd_test.py b/src/borg/testsuite/archiver/prune_cmd_test.py index a18212c85..fff66905b 100644 --- a/src/borg/testsuite/archiver/prune_cmd_test.py +++ b/src/borg/testsuite/archiver/prune_cmd_test.py @@ -400,3 +400,19 @@ def test_prune_split_no_archives(): assert keep == [] assert kept_because == {} + + +def test_prune_list_with_metadata_format(archivers, request): + # Regression test for: prune --list with a format string that requires loading + # archive metadata (e.g. {hostname}) must not fail when archives are deleted. + # The bug was that format_item() was called after archive.delete(), causing + # Archive.DoesNotExist when the formatter tried to lazy-load the archive. + archiver = request.getfixturevalue(archivers) + cmd(archiver, "repo-create", RK_ENCRYPTION) + cmd(archiver, "create", "test1", src_dir) + cmd(archiver, "create", "test2", src_dir) + # {hostname} is a "call key" that triggers lazy loading of the archive from the repo. + # With the buggy code this would raise Archive.DoesNotExist for the pruned archive. + output = cmd(archiver, "prune", "--list", "--keep-daily=1", "--format={name} {hostname}{NL}") + assert "test1" in output + assert "test2" in output From 8a4f42d0d2b70e9a31b19ab07ccc5daa0483ca33 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Sun, 1 Mar 2026 06:08:14 +0100 Subject: [PATCH 2/2] delete: compute archive_formatted before archive deletion, see #9416 here it was not a problem currently, because format_archive(archive_info) does not load the archive from the repo, but only uses the given archive_info contents. --- src/borg/archiver/delete_cmd.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/borg/archiver/delete_cmd.py b/src/borg/archiver/delete_cmd.py index 742b15144..49c913f93 100644 --- a/src/borg/archiver/delete_cmd.py +++ b/src/borg/archiver/delete_cmd.py @@ -36,6 +36,8 @@ class DeleteMixIn: logger_list = logging.getLogger("borg.output.list") for i, archive_info in enumerate(archive_infos, 1): name, id, hex_id = archive_info.name, archive_info.id, bin_to_hex(archive_info.id) + # format early before deletion of the archive + archive_formatted = format_archive(archive_info) try: # this does NOT use Archive.delete, so this code hopefully even works in cases a corrupt archive # would make the code in class Archive crash, so the user can at least get rid of such archives. @@ -47,7 +49,7 @@ class DeleteMixIn: deleted = True if self.output_list: msg = "Would delete: {} ({}/{})" if dry_run else "Deleted archive: {} ({}/{})" - logger_list.info(msg.format(format_archive(archive_info), i, count)) + logger_list.info(msg.format(archive_formatted, i, count)) if dry_run: logger.info("Finished dry-run.") elif deleted: