77from ._common import with_repository , build_matcher , Highlander
88from ..archive import Archive
99from ..constants import * # NOQA
10- from ..helpers import BaseFormatter , DiffFormatter , archivename_validator , PathSpec , BorgJsonEncoder
10+ from ..helpers import (
11+ BaseFormatter ,
12+ DiffFormatter ,
13+ archivename_validator ,
14+ PathSpec ,
15+ BorgJsonEncoder ,
16+ IncludePatternNeverMatchedWarning ,
17+ remove_surrogates ,
18+ )
1119from ..manifest import Manifest
1220from ..logger import create_logger
1321
@@ -87,11 +95,81 @@ def print_text_output(diff, formatter):
8795 diffs_iter = Archive .compare_archives_iter (
8896 archive1 , archive2 , matcher , can_compare_chunk_ids = can_compare_chunk_ids
8997 )
90- # Conversion to string and filtering for diff.equal to save memory if sorting
98+ # Filter out equal items early (keep as generator; listify only if sorting)
9199 diffs = (diff for diff in diffs_iter if not diff .equal (args .content_only ))
92100
93- if args .sort :
94- diffs = sorted (diffs , key = lambda diff : diff .path )
101+ # Enhanced sorting support: --sort-by takes precedence; legacy --sort sorts by path
102+ sort_specs = []
103+ if getattr (args , "sort_by" , None ):
104+ # Comma-separated list
105+ for spec in str (args .sort_by ).split ("," ):
106+ spec = spec .strip ()
107+ if spec :
108+ sort_specs .append (spec )
109+ elif getattr (args , "sort" , False ):
110+ sort_specs = ["path" ]
111+
112+ def key_for (field : str , d : "ItemDiff" ):
113+ # strip direction markers if present
114+ if field and field [0 ] in ("<" , ">" ):
115+ field = field [1 :]
116+ # path
117+ if field in (None , "" , "path" ):
118+ return remove_surrogates (d .path )
119+ # compute size_* from changes
120+ if field in ("size_diff" , "size_added" , "size_removed" ):
121+ added = removed = 0
122+ ch = d .changes ().get ("content" )
123+ if ch is not None :
124+ info = ch .to_dict ()
125+ t = info .get ("type" )
126+ if t == "modified" :
127+ added = info .get ("added" , 0 )
128+ removed = info .get ("removed" , 0 )
129+ elif t and t .startswith ("added" ):
130+ added = info .get ("added" , info .get ("size" , 0 ))
131+ removed = 0
132+ elif t and t .startswith ("removed" ):
133+ added = 0
134+ removed = info .get ("removed" , info .get ("size" , 0 ))
135+ if field == "size_diff" :
136+ return added - removed
137+ if field == "size_added" :
138+ return added
139+ if field == "size_removed" :
140+ return removed
141+ # timestamp diffs
142+ if field in ("ctime_diff" , "mtime_diff" ):
143+ it1 = getattr (d , "_item1" , None )
144+ it2 = getattr (d , "_item2" , None )
145+ base = field .split ("_" )[0 ]
146+ t2 = it2 .get (base , 0 ) if it2 is not None else 0
147+ t1 = it1 .get (base , 0 ) if it1 is not None else 0
148+ return t2 - t1
149+ # size of item in archive2
150+ if field == "size" :
151+ it2 = getattr (d , "_item2" , None )
152+ if it2 is None or it2 .get ("deleted" ):
153+ return 0
154+ return it2 .get_size ()
155+ # direct attributes from current item (prefer item2)
156+ it = getattr (d , "_item2" , None ) or getattr (d , "_item1" , None )
157+ attr_defaults = {"user" : "" , "group" : "" , "uid" : - 1 , "gid" : - 1 , "ctime" : 0 , "mtime" : 0 }
158+ if field in attr_defaults :
159+ if it is None :
160+ return attr_defaults [field ]
161+ return it .get (field , attr_defaults [field ])
162+ raise ValueError (f"Invalid field name: { field } " )
163+
164+ if sort_specs :
165+ diffs = list (diffs )
166+ # Apply stable sorts from last to first
167+ for spec in reversed (sort_specs ):
168+ desc = False
169+ field = spec
170+ if field and field [0 ] in ("<" , ">" ):
171+ desc = field [0 ] == ">"
172+ diffs .sort (key = lambda di : key_for (field , di ), reverse = desc )
95173
96174 formatter = DiffFormatter (format , args .content_only )
97175 for diff in diffs :
@@ -149,7 +227,87 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
149227 """
150228 )
151229 + DiffFormatter .keys_help ()
230+ + textwrap .dedent (
231+ """
232+
233+ What is compared
234+ +++++++++++++++++
235+ For each matching item in both archives, Borg reports:
236+
237+ - Content changes: total added/removed bytes within files. If chunker parameters are comparable,
238+ Borg compares chunk IDs quickly; otherwise, it compares the content.
239+ - Metadata changes: user, group, mode, and other metadata shown inline like
240+ "[old_mode -> new_mode]" for mode changes. Use ``--content-only`` to suppress metadata changes.
241+ - Added/removed items: printed as "added SIZE path" or "removed SIZE path".
242+
243+ Output formats
244+ ++++++++++++++
245+ The default (text) output shows one line per changed path, e.g.::
246+
247+ +135 B -252 B [ -rw-r--r-- -> -rwxr-xr-x ] path/to/file
248+
249+ JSON Lines output (``--json-lines``) prints one JSON object per changed path, e.g.::
250+
251+ {"path": "PATH", "changes": [
252+ {"type": "modified", "added": BYTES, "removed": BYTES},
253+ {"type": "mode", "old_mode": "-rw-r--r--", "new_mode": "-rwxr-xr-x"},
254+ {"type": "added", "size": SIZE},
255+ {"type": "removed", "size": SIZE}
256+ ]}
257+
258+ Sorting
259+ ++++++++
260+ Use ``--sort-by FIELDS`` where FIELDS is a comma-separated list of fields.
261+ Sorts are applied stably from last to first in the given list. Prepend ">" for
262+ descending, "<" (or no prefix) for ascending, for example ``--sort-by=">size_added,path"``.
263+ Supported fields include:
264+
265+ - path: the item path
266+ - size_added: total bytes added for the item content
267+ - size_removed: total bytes removed for the item content
268+ - size_diff: size_added - size_removed (net content change)
269+ - size: size of the item as stored in ARCHIVE2 (0 for removed items)
270+ - user, group, uid, gid, ctime, mtime: taken from the item state in ARCHIVE2 when present
271+ - ctime_diff, mtime_diff: timestamp difference (archive2 - archive1)
272+
273+ The ``--sort`` option is deprecated and only sorts by path.
274+
275+ Performance considerations
276+ ++++++++++++++++++++++++++
277+ For archives created with Borg 1.1 or newer, diff automatically detects whether
278+ the archives were created with the same chunker parameters. If so, only chunk IDs
279+ are compared, which is very fast.
280+ """
281+ )
152282 )
283+
284+ def diff_sort_spec_validator (s ):
285+ if not isinstance (s , str ):
286+ raise argparse .ArgumentTypeError ("unsupported sort field (not a string)" )
287+ allowed = {
288+ "path" ,
289+ "size_added" ,
290+ "size_removed" ,
291+ "size_diff" ,
292+ "size" ,
293+ "user" ,
294+ "group" ,
295+ "uid" ,
296+ "gid" ,
297+ "ctime" ,
298+ "mtime" ,
299+ "ctime_diff" ,
300+ "mtime_diff" ,
301+ }
302+ parts = [p .strip () for p in s .split ("," ) if p .strip ()]
303+ if not parts :
304+ raise argparse .ArgumentTypeError ("unsupported sort field: empty spec" )
305+ for spec in parts :
306+ field = spec [1 :] if spec and spec [0 ] in (">" , "<" ) else spec
307+ if field not in allowed :
308+ raise argparse .ArgumentTypeError (f"unsupported sort field: { field } " )
309+ return "," .join (parts )
310+
153311 subparser = subparsers .add_parser (
154312 "diff" ,
155313 parents = [common_parser ],
@@ -172,7 +330,12 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
172330 action = "store_true" ,
173331 help = "override the check of chunker parameters" ,
174332 )
175- subparser .add_argument ("--sort" , dest = "sort" , action = "store_true" , help = "Sort the output lines by file path." )
333+ subparser .add_argument (
334+ "--sort" ,
335+ dest = "sort" ,
336+ action = "store_true" ,
337+ help = "Sort the output lines by file path (deprecated, use --sort-by=path)." ,
338+ )
176339 subparser .add_argument (
177340 "--format" ,
178341 metavar = "FORMAT" ,
@@ -181,6 +344,12 @@ def build_parser_diff(self, subparsers, common_parser, mid_common_parser):
181344 help = 'specify format for differences between archives (default: "{change} {path}{NL}")' ,
182345 )
183346 subparser .add_argument ("--json-lines" , action = "store_true" , help = "Format output as JSON Lines." )
347+ subparser .add_argument (
348+ "--sort-by" ,
349+ dest = "sort_by" ,
350+ type = diff_sort_spec_validator ,
351+ help = "Sort output by comma-separated fields (e.g., '>size_added,path')." ,
352+ )
184353 subparser .add_argument (
185354 "--content-only" ,
186355 action = "store_true" ,
0 commit comments