From 2037ccae8d1b31175386c74dae857de782528960 Mon Sep 17 00:00:00 2001 From: Parman Mohammadalizadeh Date: Sat, 1 Aug 2026 22:49:11 +0200 Subject: [PATCH 1/2] analyze: add --json output, fixes #9992 Every other status-type command (info, repo-info, repo-list, list, diff, prune) can emit JSON, so tooling does not have to scrape their text. borg analyze was the odd one out, although its numbers are exactly what monitoring wants. --json emits the numbers the text report is rendered from, as raw byte values, for the default mode (dedup_size, hotspots) as well as for --by-name (by_name). The compression factor is left out: it is stored_size / source_size, and "n/a" is not a useful JSON value. To keep one source of truth, the analysis methods now return their numbers and the printing moved into report_*() methods that format them. The text output is unchanged, byte for byte. hotspots is null rather than empty when fewer than two archives matched: the hot spots were then not computed at all, which is different from having computed them and found nothing. --- docs/internals/frontends.rst | 78 ++++++++++ docs/usage/analyze.rst.inc | 16 +- src/borg/archiver/_common.py | 1 + src/borg/archiver/analyze_cmd.py | 147 ++++++++++++++---- .../testsuite/archiver/analyze_cmd_test.py | 127 ++++++++++++++- 5 files changed, 339 insertions(+), 30 deletions(-) diff --git a/docs/internals/frontends.rst b/docs/internals/frontends.rst index 9b9bbe8fb3..c42702bae2 100644 --- a/docs/internals/frontends.rst +++ b/docs/internals/frontends.rst @@ -561,6 +561,84 @@ Example (excerpt) of ``borg diff --json-lines``:: {"path": "file3", "changes": [{"type": "removed", "size": 0}]} +Archive Analysis +++++++++++++++++ + +:ref:`borg_analyze` ``--json`` emits the numbers of its text report as one object. All sizes are +byte values; the compression factor the text report shows is ``stored_size / source_size``. + +Without ``--by-name``, the *dedup_size* and *hotspots* keys are present. + +*dedup_size* describes the considered set of archives: + +considered_archives + Number of archives matching the archive filters +total_archives + Number of non-deleted archives in the repository +whole_repository + True if no archive was left over by the filters, so the considered set is the whole + repository. Every referenced chunk is then trivially exclusive to the set, and the + *exclusive* key is absent. +deduplicated + Object with *source_size* and *stored_size*: the summed size of the union of chunks the + considered archives reference, chunks shared within the set counted once +exclusive + Object with *source_size* and *stored_size*: the chunks referenced only by the considered + set, i.e. what deleting the whole set would free. Absent if *whole_repository* is true. +unreferenced + Object with *stored_size* and *chunks*: the chunks no non-deleted archive references, which + ``borg compact`` could free. Their source size is not known, as it is only recorded in the + archives referencing a chunk. +total_chunks + Number of chunks in the repository chunk index +missing_chunks + Number of chunks referenced by an archive but absent from the repository chunk index + +*hotspots* is a list of objects with *path* (directory path) and *size* (bytes of chunks added or +removed in that directory between consecutive archives), busiest directory first. It is ``null`` +if fewer than two archives matched, as hot spots need at least two archives to compare. + +With ``--by-name``, the *by_name* key is present instead, decomposing the whole repository: + +archives + Number of non-deleted archives in the repository +names + List of objects with *name*, *archives* (number of archives with that name), *source_size* + and *stored_size*. The sizes are what is exclusive to that name: no archive of another name + references those chunks. Biggest *stored_size* first. +shared + Object with *source_size* and *stored_size*: the chunks referenced by two or more names +unreferenced + As above +total + Object with *archives*, *source_size* and *stored_size*. Each chunk is counted in exactly one + of *names*, *shared* and *unreferenced*, so the *names* and *shared* sizes add up to *total*. +total_chunks, missing_chunks + As above + +Example of ``borg analyze -a 'sh:userA-*' --json``:: + + { + "dedup_size": { + "considered_archives": 2, + "deduplicated": {"source_size": 3000, "stored_size": 3536}, + "exclusive": {"source_size": 2000, "stored_size": 3338}, + "missing_chunks": 0, + "total_archives": 3, + "total_chunks": 13, + "unreferenced": {"chunks": 0, "stored_size": 0}, + "whole_repository": false + }, + "encryption": {"encryption": "aes256-ocb", "id_hash": "sha256"}, + "hotspots": [{"path": "home/user/src", "size": 1000}], + "repository": { + "id": "06e4027d32f8eae8333f8fe06b1c2c46bf12f22ad10bd4d04a0f30751a26d77b", + "last_modified": "2026-08-01T22:46:05.886533", + "location": "/home/user/repository" + } + } + + .. _msgid: Message IDs diff --git a/docs/usage/analyze.rst.inc b/docs/usage/analyze.rst.inc index dd652d549b..91f2cfdeff 100644 --- a/docs/usage/analyze.rst.inc +++ b/docs/usage/analyze.rst.inc @@ -17,6 +17,8 @@ borg analyze +-----------------------------------------------------------------------------+----------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------+ | | ``--by-name`` | decompose the whole repository by archive name (not combinable with archive filters) | +-----------------------------------------------------------------------------+----------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------+ + | | ``--json`` | format output as JSON | + +-----------------------------------------------------------------------------+----------------------------------------------+-----------------------------------------------------------------------------------------------------------------------------+ | .. class:: borg-common-opt-ref | | | | :ref:`common_options` | @@ -53,7 +55,8 @@ borg analyze options - --by-name decompose the whole repository by archive name (not combinable with archive filters) + --by-name decompose the whole repository by archive name (not combinable with archive filters) + --json format output as JSON :ref:`common_options` @@ -131,4 +134,13 @@ the sizes of added and removed chunks per direct parent directory, and outputs a You can use that list to find directories with a lot of "activity" — maybe some of these are temporary or cache directories you forgot to exclude. To avoid including these unwanted directories in your backups, you can carefully exclude them in ``borg create`` (for future -backups) or use ``borg recreate`` to recreate existing archives without them. \ No newline at end of file +backups) or use ``borg recreate`` to recreate existing archives without them. + +**JSON output** + +With ``--json``, the same numbers are emitted as a single JSON object instead of the text +report, with raw byte values rather than formatted sizes. The default mode fills the +*dedup_size* and *hotspots* keys, ``--by-name`` fills the *by_name* key. The compression +factor is not included: it is ``stored_size / source_size``. + +See :ref:`json_output` for the object's structure. \ No newline at end of file diff --git a/src/borg/archiver/_common.py b/src/borg/archiver/_common.py index 77787c26a5..6d3cee1089 100644 --- a/src/borg/archiver/_common.py +++ b/src/borg/archiver/_common.py @@ -280,6 +280,7 @@ def wrapper(self, args, repository, manifest, **kwargs): "key_files": "Internals -> Data structures and file formats -> Key files", "borg_key_export": "borg key export --help", "internals_hashindex": "Internals -> Data structures and file formats -> HashIndex", + "json_output": "Internals -> All about JSON: How to develop frontends", } diff --git a/src/borg/archiver/analyze_cmd.py b/src/borg/archiver/analyze_cmd.py index 55330d447b..639fb8a79b 100644 --- a/src/borg/archiver/analyze_cmd.py +++ b/src/borg/archiver/analyze_cmd.py @@ -5,7 +5,7 @@ from ..archive import Archive from ..cache import get_archive_references, list_archive_reference_caches from ..constants import * # NOQA -from ..helpers import bin_to_hex, Error, format_file_size +from ..helpers import basic_json_data, bin_to_hex, Error, format_file_size, json_print from ..helpers import ProgressIndicatorPercent from ..helpers.argparsing import ArgumentParser from ..manifest import Manifest @@ -42,23 +42,40 @@ def __init__(self, args, repository, manifest): def analyze(self): logger.info("Starting archives analysis...") + json_data = {} if self.args.json else None if self.args.by_name: # the decomposition is inherently repository-wide: "shared" and "unreferenced" can only # be determined by looking at every archive, so archive filters must not be applied. filters = ["match_archives", "first", "last", "older", "newer", "oldest", "newest"] if any(getattr(self.args, name, None) for name in filters): raise Error("--by-name analyzes the whole repository and cannot be combined with archive filters.") - self.analyze_by_name() + by_name = self.analyze_by_name() + if json_data is None: + self.report_by_name(by_name) + else: + json_data["by_name"] = by_name else: considered_infos = self.manifest.archives.list_considering(self.args) if not considered_infos: raise Error("No archives match the given selection criteria.") - self.analyze_dedup_size(considered_infos) + dedup_size = self.analyze_dedup_size(considered_infos) + if json_data is None: + self.report_dedup_size(dedup_size) + else: + json_data["dedup_size"] = dedup_size if len(considered_infos) >= 2: self.analyze_hotspots(considered_infos) - self.report_hotspots() + hotspots = self.hotspots() else: logger.info("Skipping hot-spot analysis (needs at least 2 matching archives).") + hotspots = None # not computed, as opposed to computed and empty + if json_data is None: + if hotspots is not None: + self.report_hotspots(hotspots) + else: + json_data["hotspots"] = hotspots + if json_data is not None: + json_print(basic_json_data(self.manifest, extra=json_data)) logger.info("Finished archives analysis.") def fmt(self, value): @@ -101,7 +118,7 @@ def mark_references(self, archive_infos, update_flags): pi.finish() return missing - def analyze_by_name(self) -> None: + def analyze_by_name(self) -> dict: """Decompose the whole repository by archive name. Archives sharing a name form a series, so a name usually groups all backups of one source; @@ -114,6 +131,8 @@ def analyze_by_name(self) -> None: This needs only a single pass over all archives: each chunk records the name that first referenced it, and a reference from a different name sets the F_MULTI bit. + + Returns the decomposition as raw byte values, for report_by_name() or --json. """ all_infos = self.manifest.archives.list() # non-deleted archives if not all_infos: @@ -159,6 +178,29 @@ def update_flags(flags, owner=owner): total_plaintext = shared[0] + sum(v[0] for v in exclusive.values()) total_stored = shared[1] + sum(v[1] for v in exclusive.values()) + + return { + "archives": len(all_infos), + # biggest exclusive consumer first - that is what one would act on + "names": [ + { + "name": name, + "archives": archives_per_name[name], + "source_size": exclusive[name][0], + "stored_size": exclusive[name][1], + } + for name in sorted(names, key=lambda n: exclusive[n][1], reverse=True) + ], + "shared": {"source_size": shared[0], "stored_size": shared[1]}, + "unreferenced": {"stored_size": unref_stored, "chunks": unref_count}, + "total": {"archives": len(all_infos), "source_size": total_plaintext, "stored_size": total_stored}, + "total_chunks": total_count, + "missing_chunks": missing, + } + + def report_by_name(self, data) -> None: + """Print the --by-name decomposition computed by analyze_by_name().""" + names = [entry["name"] for entry in data["names"]] width = min(max([30] + [len(name) for name in names]), 60) def row(label, archives, source, stored, *, source_known=True): @@ -169,19 +211,24 @@ def row(label, archives, source, stored, *, source_known=True): print() print("Repository decomposition by archive name") print("=" * (width + 51)) - print(f"{len(all_infos)} archive(s) with {len(names)} distinct name(s)") + print(f"{data['archives']} archive(s) with {len(names)} distinct name(s)") print() print(f"{'name':<{width}}{'archives':>10}{'source':>14}{'stored':>14}{'compression':>13}") - # biggest exclusive consumer first - that is what one would act on - for name in sorted(names, key=lambda n: exclusive[n][1], reverse=True): - source, stored = exclusive[name] - row(name, archives_per_name[name], source, stored) - row("(shared by 2+ names)", "", shared[0], shared[1]) - row("(unreferenced)", "", 0, unref_stored, source_known=False) + for entry in data["names"]: + row(entry["name"], entry["archives"], entry["source_size"], entry["stored_size"]) + row("(shared by 2+ names)", "", data["shared"]["source_size"], data["shared"]["stored_size"]) + row("(unreferenced)", "", 0, data["unreferenced"]["stored_size"], source_known=False) print("-" * (width + 51)) - row("total (deduplicated)", len(all_infos), total_plaintext, total_stored) + row( + "total (deduplicated)", + data["total"]["archives"], + data["total"]["source_size"], + data["total"]["stored_size"], + ) print() - print(f"Unreferenced: {unref_count} of {total_count} chunks in the repository index.") + print( + f"Unreferenced: {data['unreferenced']['chunks']} of {data['total_chunks']} chunks in the repository index." + ) print() print("Each chunk is counted in exactly one row, so the rows add up to the total.") print("A name row shows what is exclusive to it: no archive of another name references these") @@ -197,8 +244,8 @@ def row(label, archives, source, stored, *, source_known=True): print(f"{'':<12} archives count as unreferenced (borg compact keeps them if the") print(f"{'':<12} repository is damaged).") - def analyze_dedup_size(self, considered_infos) -> None: - """Compute and report the deduplicated size of the considered set of archives. + def analyze_dedup_size(self, considered_infos) -> dict: + """Compute the deduplicated size of the considered set of archives. For both the plaintext (uncompressed source) size and the stored (compressed, as stored in the repository) size, two figures are reported: @@ -221,6 +268,8 @@ def analyze_dedup_size(self, considered_infos) -> None: plaintext size (which is 0 in the repo index) from the per-archive references cache. These in-memory mutations are never persisted: write_chunkindex_to_repo() zeroes flags and size, and close() only serializes F_NEW entries (there are none here). + + Returns the sizes as raw byte values, for report_dedup_size() or --json. """ considered_ids = {info.id for info in considered_infos} all_infos = self.manifest.archives.list() # non-deleted archives; the rest = all - considered @@ -252,28 +301,54 @@ def analyze_dedup_size(self, considered_infos) -> None: # chunk is trivially exclusive to it, so that line would just repeat the deduplicated size. whole_repo = not rest_infos + data = { + "considered_archives": len(considered_infos), + "total_archives": len(all_infos), + "whole_repository": whole_repo, + "deduplicated": {"source_size": set_plaintext, "stored_size": set_stored}, + "unreferenced": {"stored_size": unref_stored, "chunks": unref_count}, + "total_chunks": total_count, + "missing_chunks": missing, + } + if not whole_repo: + data["exclusive"] = {"source_size": excl_plaintext, "stored_size": excl_stored} + return data + + def report_dedup_size(self, data) -> None: + """Print the deduplicated sizes computed by analyze_dedup_size().""" + whole_repo = data["whole_repository"] + def row(label, source, stored, *, source_known=True): sizes = f"{self.fmt(source) if source_known else 'n/a':>14}{self.fmt(stored):>14}" ratio = self.factor(stored, source) if source_known else "n/a" print(f"{label:<26}{sizes}{ratio:>13}") + considered = data["considered_archives"] + total_archives = data["total_archives"] + deduplicated = data["deduplicated"] + unreferenced = data["unreferenced"] + print() if whole_repo: print("Deduplicated size of the whole repository") print("=" * 67) - print(f"Archives: {len(all_infos)} (all archives in the repository)") + print(f"Archives: {total_archives} (all archives in the repository)") else: - print(f"Deduplicated size of the {len(considered_infos)} considered archive(s)") + print(f"Deduplicated size of the {considered} considered archive(s)") print("=" * 67) - print(f"Considered archives: {len(considered_infos)} (of {len(all_infos)} in the repository)") + print(f"Considered archives: {considered} (of {total_archives} in the repository)") print() print(f"{'':26}{'source':>14}{'stored':>14}{'compression':>13}") - row("Deduplicated size:" if whole_repo else "Deduplicated size of set:", set_plaintext, set_stored) + row( + "Deduplicated size:" if whole_repo else "Deduplicated size of set:", + deduplicated["source_size"], + deduplicated["stored_size"], + ) if not whole_repo: - row("Exclusive size of set:", excl_plaintext, excl_stored) - row("Unreferenced chunks:", 0, unref_stored, source_known=False) + row("Exclusive size of set:", data["exclusive"]["source_size"], data["exclusive"]["stored_size"]) + row("Unreferenced chunks:", 0, unreferenced["stored_size"], source_known=False) print() - print(f"Unreferenced: {unref_count} of {total_count} chunks in the repository index.") + print(f"Unreferenced: {unreferenced['chunks']} of {data['total_chunks']} chunks in the repository index.") print() if whole_repo: print(f"{'source':<12} = uncompressed source data size (each chunk counted once)") @@ -341,13 +416,21 @@ def analyze_path_change(path): if directory_path not in base: analyze_path_change(directory_path) - def report_hotspots(self): + def hotspots(self) -> list: + """The hot spots collected by analyze_hotspots(), busiest directory first.""" + return [ + {"path": directory_path, "size": self.difference_by_path[directory_path]} + for directory_path in sorted( + self.difference_by_path, key=lambda p: self.difference_by_path[p], reverse=True + ) + ] + + def report_hotspots(self, hotspots): print() print("chunks added or removed by directory path") print("=========================================") - for directory_path in sorted(self.difference_by_path, key=lambda p: self.difference_by_path[p], reverse=True): - difference = self.difference_by_path[directory_path] - print(f"{directory_path}: {difference}") + for hotspot in hotspots: + print(f"{hotspot['path']}: {hotspot['size']}") class AnalyzeMixIn: @@ -420,6 +503,15 @@ def build_parser_analyze(self, subparsers, common_parser, mid_common_parser): are temporary or cache directories you forgot to exclude. To avoid including these unwanted directories in your backups, you can carefully exclude them in ``borg create`` (for future backups) or use ``borg recreate`` to recreate existing archives without them. + + **JSON output** + + With ``--json``, the same numbers are emitted as a single JSON object instead of the text + report, with raw byte values rather than formatted sizes. The default mode fills the + *dedup_size* and *hotspots* keys, ``--by-name`` fills the *by_name* key. The compression + factor is not included: it is ``stored_size / source_size``. + + See :ref:`json_output` for the object's structure. """ ) subparser = ArgumentParser(parents=[common_parser], description=self.do_analyze.__doc__, epilog=analyze_epilog) @@ -430,4 +522,5 @@ def build_parser_analyze(self, subparsers, common_parser, mid_common_parser): action="store_true", help="decompose the whole repository by archive name (not combinable with archive filters)", ) + subparser.add_argument("--json", action="store_true", help="format output as JSON") define_archive_filters_group(subparser) diff --git a/src/borg/testsuite/archiver/analyze_cmd_test.py b/src/borg/testsuite/archiver/analyze_cmd_test.py index 7afd3f3797..38d4967057 100644 --- a/src/borg/testsuite/archiver/analyze_cmd_test.py +++ b/src/borg/testsuite/archiver/analyze_cmd_test.py @@ -1,10 +1,11 @@ +import json import pathlib import re import pytest from ...constants import * # NOQA -from ...helpers import Error +from ...helpers import Error, format_file_size from . import cmd, generate_archiver_tests, RK_ENCRYPTION pytest_generate_tests = lambda metafunc: generate_archiver_tests(metafunc, kinds="local") # NOQA @@ -192,3 +193,127 @@ def test_analyze_dedup_size_single_archive(archivers, request): assert re.search(r"Deduplicated size of set:\s*1\.00 kB", output) # file1 is also in other-1, so nothing is exclusive to only-1 assert re.search(r"Exclusive size of set:\s*0 B", output) + + +def test_analyze_json(archivers, request): + """--json reports the same numbers as the text report, as raw byte values.""" + archiver = request.getfixturevalue(archivers) + + cmd(archiver, "repo-create", RK_ENCRYPTION) + input_path = pathlib.Path(archiver.input_path) + + # same layout as test_analyze_dedup_size: each file is one 1000 byte chunk. + (input_path / "shared").write_text("s" * 1000) + (input_path / "a_only").write_text("a" * 1000) + cmd(archiver, "create", "userA-1", archiver.input_path) + cmd(archiver, "create", "userA-2", archiver.input_path) + + (input_path / "a_only").unlink() + (input_path / "b_only").write_text("b" * 1000) + cmd(archiver, "create", "userB-1", archiver.input_path) + + json_output = cmd(archiver, "analyze", "-a", "sh:userA-*", "--json") + dedup_size = json.loads(json_output)["dedup_size"] + assert dedup_size["considered_archives"] == 2 + assert dedup_size["total_archives"] == 3 + assert dedup_size["whole_repository"] is False + assert dedup_size["deduplicated"]["source_size"] == 2000 # {shared, a_only} + assert dedup_size["exclusive"]["source_size"] == 1000 # {a_only}, "shared" is also in userB-1 + assert dedup_size["unreferenced"] == {"stored_size": 0, "chunks": 0} + assert dedup_size["missing_chunks"] == 0 + assert dedup_size["total_chunks"] > 0 + + # the stored sizes are compression dependent, so relate them to the text report instead of + # hardcoding them: both columns of a row are what the text report formats from these values. + text = cmd(archiver, "analyze", "-a", "sh:userA-*") + for key, label in [("deduplicated", "Deduplicated size of set:"), ("exclusive", "Exclusive size of set:")]: + source, stored = dedup_size[key]["source_size"], dedup_size[key]["stored_size"] + assert stored > 0 + row = rf"^{re.escape(label)}\s+{re.escape(format_file_size(source))}\s+{re.escape(format_file_size(stored))}\s" + assert re.search(row, text, re.MULTILINE) + + # the text report itself is not printed in JSON mode + assert "Deduplicated size of set:" not in json_output + + +def test_analyze_json_whole_repository(archivers, request): + """Without an archive filter every chunk is trivially exclusive, so no exclusive size is given.""" + archiver = request.getfixturevalue(archivers) + + cmd(archiver, "repo-create", RK_ENCRYPTION) + input_path = pathlib.Path(archiver.input_path) + (input_path / "file1").write_text("x" * 1000) + cmd(archiver, "create", "one", archiver.input_path) + (input_path / "file2").write_text("y" * 1000) + cmd(archiver, "create", "two", archiver.input_path) + + dedup_size = json.loads(cmd(archiver, "analyze", "--json"))["dedup_size"] + assert dedup_size["whole_repository"] is True + assert dedup_size["considered_archives"] == 2 + assert dedup_size["total_archives"] == 2 + assert dedup_size["deduplicated"]["source_size"] == 2000 + assert "exclusive" not in dedup_size + + +def test_analyze_json_hotspots(archivers, request): + """The hot spots are a list of path/size objects, busiest first; null if they were not computed.""" + archiver = request.getfixturevalue(archivers) + + cmd(archiver, "repo-create", RK_ENCRYPTION) + input_path = pathlib.Path(archiver.input_path) + + (input_path / "file1").write_text("1") + cmd(archiver, "create", "archive", archiver.input_path) + + # only one matching archive: nothing to compare against, so hot spots are not computed + assert json.loads(cmd(archiver, "analyze", "-a", "archive", "--json"))["hotspots"] is None + + (input_path / "file2").write_text("22") + cmd(archiver, "create", "archive", archiver.input_path) + + # the 2nd archive added one chunk of 2 bytes below the input directory + hotspots = json.loads(cmd(archiver, "analyze", "-a", "archive", "--json"))["hotspots"] + assert [hotspot for hotspot in hotspots if hotspot["path"].endswith("/input")] == [ + {"path": str(input_path).removeprefix("/"), "size": 2} + ] + # busiest directory first, as in the text report + assert [hotspot["size"] for hotspot in hotspots] == sorted((hotspot["size"] for hotspot in hotspots), reverse=True) + + +def test_analyze_json_by_name(archivers, request): + """--by-name --json decomposes the repository into per-name exclusive, shared and unreferenced.""" + archiver = request.getfixturevalue(archivers) + + cmd(archiver, "repo-create", RK_ENCRYPTION) + input_path = pathlib.Path(archiver.input_path) + + # same layout as test_analyze_by_name + (input_path / "shared").write_text("s" * 1000) + (input_path / "a_only").write_text("a" * 1000) + cmd(archiver, "create", "alpha", archiver.input_path) + cmd(archiver, "create", "alpha", archiver.input_path) + + (input_path / "a_only").unlink() + (input_path / "b_only").write_text("b" * 1000) + cmd(archiver, "create", "beta", archiver.input_path) + + result = json.loads(cmd(archiver, "analyze", "--by-name", "--json")) + assert "dedup_size" not in result and "hotspots" not in result + by_name = result["by_name"] + + assert by_name["archives"] == 3 + assert {entry["name"]: entry["archives"] for entry in by_name["names"]} == {"alpha": 2, "beta": 1} + assert {entry["name"]: entry["source_size"] for entry in by_name["names"]} == {"alpha": 1000, "beta": 1000} + assert by_name["shared"]["source_size"] == 1000 + assert by_name["total"]["archives"] == 3 + assert by_name["total"]["source_size"] == 3000 # 1000 alpha + 1000 beta + 1000 shared + assert by_name["total"]["stored_size"] > 0 + assert by_name["missing_chunks"] == 0 + + # every chunk is counted in exactly one row, so the rows add up to the total + for size in ("source_size", "stored_size"): + assert sum(entry[size] for entry in by_name["names"]) + by_name["shared"][size] == by_name["total"][size] + # biggest exclusive consumer first + assert [entry["stored_size"] for entry in by_name["names"]] == sorted( + (entry["stored_size"] for entry in by_name["names"]), reverse=True + ) From 05509440228ea84a5e7b6059a41dae4a65b50462 Mon Sep 17 00:00:00 2001 From: Parman Mohammadalizadeh Date: Mon, 3 Aug 2026 08:53:04 +0200 Subject: [PATCH 2/2] analyze: fix the hot-spot JSON test on Windows The test rebuilt the expected hot-spot path from the input directory, stripping a leading slash. Archived paths are normalized, and on Windows that also drops the drive colon (C:\Users -> C/Users), so the expectation read D:/a/... where borg had stored D/a/.... Assert the size of the input directory's hot spot by path suffix, like the text-report test above already does, and check the paths against what the text report prints instead of rebuilding them. --- src/borg/testsuite/archiver/analyze_cmd_test.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/borg/testsuite/archiver/analyze_cmd_test.py b/src/borg/testsuite/archiver/analyze_cmd_test.py index 38d4967057..df49bfc124 100644 --- a/src/borg/testsuite/archiver/analyze_cmd_test.py +++ b/src/borg/testsuite/archiver/analyze_cmd_test.py @@ -273,9 +273,13 @@ def test_analyze_json_hotspots(archivers, request): # the 2nd archive added one chunk of 2 bytes below the input directory hotspots = json.loads(cmd(archiver, "analyze", "-a", "archive", "--json"))["hotspots"] - assert [hotspot for hotspot in hotspots if hotspot["path"].endswith("/input")] == [ - {"path": str(input_path).removeprefix("/"), "size": 2} - ] + assert [hotspot["size"] for hotspot in hotspots if hotspot["path"].endswith("/input")] == [2] + # paths and sizes are the ones the text report prints. Archived paths are normalized + # (a Windows "D:/x" is stored as "D/x"), so compare against the report rather than + # rebuilding the path here. + text = cmd(archiver, "analyze", "-a", "archive") + for hotspot in hotspots: + assert f"{hotspot['path']}: {hotspot['size']}" in text # busiest directory first, as in the text report assert [hotspot["size"] for hotspot in hotspots] == sorted((hotspot["size"] for hotspot in hotspots), reverse=True)