88from ..archive import Archive
99from ..constants import * # NOQA
1010from ..helpers import BaseFormatter , DiffFormatter , archivename_validator , PathSpec , BorgJsonEncoder
11+ from ..helpers import IncludePatternNeverMatchedWarning , remove_surrogates
12+ from ..item import ItemDiff
1113from ..manifest import Manifest
1214from ..logger import create_logger
1315
@@ -87,11 +89,75 @@ def print_text_output(diff, formatter):
8789 diffs_iter = Archive .compare_archives_iter (
8890 archive1 , archive2 , matcher , can_compare_chunk_ids = can_compare_chunk_ids
8991 )
90- # Conversion to string and filtering for diff.equal to save memory if sorting
92+ # Filter out equal items early (keep as generator; listify only if sorting)
9193 diffs = (diff for diff in diffs_iter if not diff .equal (args .content_only ))
9294
93- if args .sort :
94- diffs = sorted (diffs , key = lambda diff : diff .path )
95+ sort_specs = []
96+ if args .sort_by :
97+ for spec in args .sort_by .split ("," ):
98+ spec = spec .strip ()
99+ if spec :
100+ sort_specs .append (spec )
101+
102+ def key_for (field : str , d : "ItemDiff" ):
103+ # strip direction markers if present
104+ if field and field [0 ] in ("<" , ">" ):
105+ field = field [1 :]
106+ # path
107+ if field in (None , "" , "path" ):
108+ return remove_surrogates (d .path )
109+ # compute size_* from changes
110+ if field in ("size_diff" , "size_added" , "size_removed" ):
111+ added = removed = 0
112+ ch = d .changes ().get ("content" )
113+ if ch is not None :
114+ info = ch .to_dict ()
115+ t = info .get ("type" )
116+ if t == "modified" :
117+ added = info .get ("added" , 0 )
118+ removed = info .get ("removed" , 0 )
119+ elif t and t .startswith ("added" ):
120+ added = info .get ("added" , info .get ("size" , 0 ))
121+ removed = 0
122+ elif t and t .startswith ("removed" ):
123+ added = 0
124+ removed = info .get ("removed" , info .get ("size" , 0 ))
125+ if field == "size_diff" :
126+ return added - removed
127+ if field == "size_added" :
128+ return added
129+ if field == "size_removed" :
130+ return removed
131+ # timestamp diffs
132+ if field in ("ctime_diff" , "mtime_diff" ):
133+ ts = field .split ("_" )[0 ]
134+ t1 = d ._item1 .get (ts , 0 )
135+ t2 = d ._item2 .get (ts , 0 )
136+ return t2 - t1
137+ # size of item in archive2
138+ if field == "size" :
139+ it = d ._item2
140+ if it is None or it .get ("deleted" ):
141+ return 0
142+ return it .get_size ()
143+ # direct attributes from current item (prefer item2)
144+ it = d ._item2 or d ._item1
145+ attr_defaults = {"user" : "" , "group" : "" , "uid" : - 1 , "gid" : - 1 , "ctime" : 0 , "mtime" : 0 }
146+ if field in attr_defaults :
147+ if it is None :
148+ return attr_defaults [field ]
149+ return it .get (field , attr_defaults [field ])
150+ raise ValueError (f"Invalid field name: { field } " )
151+
152+ if sort_specs :
153+ diffs = list (diffs )
154+ # Apply stable sorts from last to first
155+ for spec in reversed (sort_specs ):
156+ desc = False
157+ field = spec
158+ if field and field [0 ] in ("<" , ">" ):
159+ desc = field [0 ] == ">"
160+ diffs .sort (key = lambda di : key_for (field , di ), reverse = desc )
95161
96162 formatter = DiffFormatter (format , args .content_only )
97163 for diff in diffs :
@@ -112,7 +178,7 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
112178 """
113179 This command finds differences (file contents, metadata) between ARCHIVE1 and ARCHIVE2.
114180
115- For more help on include/exclude patterns, see the :ref:`borg_patterns` command output .
181+ For more help on include/exclude patterns, see the output of the :ref:`borg_patterns` command.
116182
117183 .. man NOTES
118184
@@ -149,7 +215,84 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
149215 """
150216 )
151217 + DiffFormatter .keys_help ()
218+ + textwrap .dedent (
219+ """
220+
221+ What is compared
222+ +++++++++++++++++
223+ For each matching item in both archives, Borg reports:
224+
225+ - Content changes: total added/removed bytes within files. If chunker parameters are comparable,
226+ Borg compares chunk IDs quickly; otherwise, it compares the content.
227+ - Metadata changes: user, group, mode, and other metadata shown inline, like
228+ "[old_mode -> new_mode]" for mode changes. Use ``--content-only`` to suppress metadata changes.
229+ - Added/removed items: printed as "added SIZE path" or "removed SIZE path".
230+
231+ Output formats
232+ ++++++++++++++
233+ The default (text) output shows one line per changed path, e.g.::
234+
235+ +135 B -252 B [ -rw-r--r-- -> -rwxr-xr-x ] path/to/file
236+
237+ JSON Lines output (``--json-lines``) prints one JSON object per changed path, e.g.::
238+
239+ {"path": "PATH", "changes": [
240+ {"type": "modified", "added": BYTES, "removed": BYTES},
241+ {"type": "mode", "old_mode": "-rw-r--r--", "new_mode": "-rwxr-xr-x"},
242+ {"type": "added", "size": SIZE},
243+ {"type": "removed", "size": SIZE}
244+ ]}
245+
246+ Sorting
247+ ++++++++
248+ Use ``--sort-by FIELDS`` where FIELDS is a comma-separated list of fields.
249+ Sorts are applied stably from last to first in the given list. Prepend ">" for
250+ descending, "<" (or no prefix) for ascending, for example ``--sort-by=">size_added,path"``.
251+ Supported fields include:
252+
253+ - path: the item path
254+ - size_added: total bytes added for the item content
255+ - size_removed: total bytes removed for the item content
256+ - size_diff: size_added - size_removed (net content change)
257+ - size: size of the item as stored in ARCHIVE2 (0 for removed items)
258+ - user, group, uid, gid, ctime, mtime: taken from the item state in ARCHIVE2 when present
259+ - ctime_diff, mtime_diff: timestamp difference (ARCHIVE2 - ARCHIVE1)
260+
261+ Performance considerations
262+ ++++++++++++++++++++++++++
263+ diff automatically detects whether the archives were created with the same chunker
264+ parameters. If so, only chunk IDs are compared, which is very fast.
265+ """
266+ )
152267 )
268+
269+ def diff_sort_spec_validator (s ):
270+ if not isinstance (s , str ):
271+ raise argparse .ArgumentTypeError ("unsupported sort field (not a string)" )
272+ allowed = {
273+ "path" ,
274+ "size_added" ,
275+ "size_removed" ,
276+ "size_diff" ,
277+ "size" ,
278+ "user" ,
279+ "group" ,
280+ "uid" ,
281+ "gid" ,
282+ "ctime" ,
283+ "mtime" ,
284+ "ctime_diff" ,
285+ "mtime_diff" ,
286+ }
287+ parts = [p .strip () for p in s .split ("," ) if p .strip ()]
288+ if not parts :
289+ raise argparse .ArgumentTypeError ("unsupported sort field: empty spec" )
290+ for spec in parts :
291+ field = spec [1 :] if spec and spec [0 ] in (">" , "<" ) else spec
292+ if field not in allowed :
293+ raise argparse .ArgumentTypeError (f"unsupported sort field: { field } " )
294+ return "," .join (parts )
295+
153296 subparser = subparsers .add_parser (
154297 "diff" ,
155298 parents = [common_parser ],
@@ -172,7 +315,6 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
172315 action = "store_true" ,
173316 help = "override the check of chunker parameters" ,
174317 )
175- subparser .add_argument ("--sort" , dest = "sort" , action = "store_true" , help = "Sort the output lines by file path." )
176318 subparser .add_argument (
177319 "--format" ,
178320 metavar = "FORMAT" ,
@@ -181,6 +323,12 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
181323 help = 'specify format for differences between archives (default: "{change} {path}{NL}")' ,
182324 )
183325 subparser .add_argument ("--json-lines" , action = "store_true" , help = "Format output as JSON Lines." )
326+ subparser .add_argument (
327+ "--sort-by" ,
328+ dest = "sort_by" ,
329+ type = diff_sort_spec_validator ,
330+ help = "Sort output by comma-separated fields (e.g., '>size_added,path')." ,
331+ )
184332 subparser .add_argument (
185333 "--content-only" ,
186334 action = "store_true" ,
0 commit comments