From da89a52c68aaa63a3e542e3ae403b41ed598fb45 Mon Sep 17 00:00:00 2001 From: Thomas Waldmann Date: Thu, 5 Nov 2026 20:18:57 +0100 Subject: [PATCH] diff: fix --numeric-ids having no effect The option was parsed but never used: compare_archives_iter() built each ItemDiff without passing numeric_ids, so ItemDiff always compared and reported the user/group names, never uid/gid. Thread numeric_ids from the CLI through compare_archives_iter() into ItemDiff, and add a test asserting that with --numeric-ids the owner changes compare and report numeric uid/gid instead of names. Backport of #10243 (master commit ace81741f). Co-Authored-By: Claude Opus 5.5 --- src/borg/archive.py | 5 +++- src/borg/archiver.py | 3 ++- src/borg/testsuite/archiver.py | 45 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/borg/archive.py b/src/borg/archive.py index bc40277388..ebdddc003a 100644 --- a/src/borg/archive.py +++ b/src/borg/archive.py @@ -1220,12 +1220,14 @@ def chunk_decref(id, stats, part=False): logger.warning('borg check --repair is required to free all space.') @staticmethod - def compare_archives_iter(print_warning, archive1, archive2, matcher=None, can_compare_chunk_ids=False, content_only=False): + def compare_archives_iter(print_warning, archive1, archive2, matcher=None, can_compare_chunk_ids=False, content_only=False, + numeric_ids=False): """ Yields tuples with a path and an ItemDiff instance describing changes/indicating equality. :param matcher: PatternMatcher class to restrict results to only matching paths. :param can_compare_chunk_ids: Whether --chunker-params are the same for both archives. + :param numeric_ids: Whether to compare/report numeric uid/gid instead of user/group names. """ def hardlink_master_seen(item): @@ -1249,6 +1251,7 @@ def compare_items(item1, item2): return ItemDiff(item1, item2, archive1.pipeline.fetch_many([c.id for c in item1.get('chunks', [])]), archive2.pipeline.fetch_many([c.id for c in item2.get('chunks', [])]), + numeric_ids=numeric_ids, can_compare_chunk_ids=can_compare_chunk_ids, content_only=content_only) diff --git a/src/borg/archiver.py b/src/borg/archiver.py index 86ba261e98..2a25d0e8e7 100644 --- a/src/borg/archiver.py +++ b/src/borg/archiver.py @@ -1249,7 +1249,8 @@ def print_text_output(diff, path): matcher = self.build_matcher(args.patterns, args.paths) - diffs = Archive.compare_archives_iter(self.print_warning, archive1, archive2, matcher, can_compare_chunk_ids=can_compare_chunk_ids, content_only=args.content_only) + diffs = Archive.compare_archives_iter(self.print_warning, archive1, archive2, matcher, can_compare_chunk_ids=can_compare_chunk_ids, content_only=args.content_only, + numeric_ids=args.numeric_ids) diffs = ((path, diff) for path, diff in diffs if not diff.equal) # Sorting support diff --git a/src/borg/testsuite/archiver.py b/src/borg/testsuite/archiver.py index e43a341d4d..fdff9104db 100644 --- a/src/borg/testsuite/archiver.py +++ b/src/borg/testsuite/archiver.py @@ -5752,6 +5752,51 @@ def test_sort_validation(self): out = self.cmd('diff', self.repository_location + '::a', 'a', '--sort-by=invalid_field', fork=True, exit_code=2) assert 'unsupported sort field' in out or 'invalid choice' in out or 'unsupported' in out + def _create_archive_with_items(self, name, items): + # Inject raw Items directly into an archive, bypassing "borg create", so the owner + # metadata (uid/gid/user/group) can be controlled exactly (without needing root). + repository = Repository(self.repository_path, exclusive=True) + with repository: + manifest, key = Manifest.load(repository, Manifest.NO_OPERATION_CHECK) + with Cache(repository, key, manifest) as cache: + archive = Archive(repository, key, manifest, name, cache=cache, create=True) + for item in items: + archive.items_buffer.add(item) + archive.save() + + def test_numeric_ids(self): + self.cmd('init', '--encryption=repokey', self.repository_location) + + def item(path, uid, gid, user, group): + return Item(path=path, mode=stat.S_IFREG | 0o644, mtime=0, ctime=0, + uid=uid, gid=gid, user=user, group=group, chunks=[]) + + self._create_archive_with_items('a1', [ + item('names_changed', 1000, 1000, 'alice', 'alice'), + item('ids_changed', 1000, 1000, 'alice', 'alice'), + ]) + self._create_archive_with_items('a2', [ + item('names_changed', 1000, 1000, 'bob', 'bob'), # same uid/gid, different names + item('ids_changed', 2000, 2000, 'alice', 'alice'), # different uid/gid, same names + ]) + + def changes_by_path(output): + return {j['path']: j['changes'] for j in (json.loads(line) for line in output.splitlines() if line)} + + # without --numeric-ids, the user/group names are compared and reported + output = self.cmd('diff', self.repository_location + '::a1', 'a2', '--json-lines') + changes = changes_by_path(output) + assert {'type': 'owner', 'old_user': 'alice', 'old_group': 'alice', + 'new_user': 'bob', 'new_group': 'bob'} in changes['names_changed'] + assert 'ids_changed' not in changes # names unchanged, so the item counts as equal + + # with --numeric-ids, the numeric uid/gid are compared and reported instead + output = self.cmd('diff', self.repository_location + '::a1', 'a2', '--json-lines', '--numeric-ids') + changes = changes_by_path(output) + assert {'type': 'owner', 'old_user': 1000, 'old_group': 1000, + 'new_user': 2000, 'new_group': 2000} in changes['ids_changed'] + assert 'names_changed' not in changes # uid/gid unchanged, so the item counts as equal + def test_time_diffs(self): self.cmd('init', '--encryption=repokey', self.repository_location) self.create_regular_file("test_file", size=10)