PyHardLinkBackup 1.7.2__tar.gz → 1.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PKG-INFO +30 -17
  2. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/__init__.py +1 -1
  3. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/backup.py +37 -9
  4. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_app/phlb.py +8 -0
  5. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/compare_backup.py +44 -4
  6. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/rebuild_databases.py +12 -2
  7. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_backup.py +258 -26
  8. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_compare_backup.py +2 -0
  9. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/filesystem.py +76 -8
  10. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/rich_utils.py +8 -25
  11. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/tests/test_file_hash_database.py +12 -2
  12. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/tests/test_file_size_database.py +15 -2
  13. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/tests/test_filesystem.py +44 -1
  14. pyhardlinkbackup-1.8.0/PyHardLinkBackup/utilities/tyro_cli_shared_args.py +29 -0
  15. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/README.md +29 -16
  16. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/docs/README.md +11 -1
  17. pyhardlinkbackup-1.7.2/PyHardLinkBackup/utilities/tyro_cli_shared_args.py +0 -12
  18. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.editorconfig +0 -0
  19. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.github/workflows/tests.yml +0 -0
  20. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.gitignore +0 -0
  21. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.idea/.gitignore +0 -0
  22. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.pre-commit-config.yaml +0 -0
  23. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.pre-commit-hooks.yaml +0 -0
  24. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.run/Template Python tests.run.xml +0 -0
  25. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.run/Unittests - __all__.run.xml +0 -0
  26. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.run/cli.py --help.run.xml +0 -0
  27. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.run/dev-cli update.run.xml +0 -0
  28. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.run/only DocTests.run.xml +0 -0
  29. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.run/only DocWrite.run.xml +0 -0
  30. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/.venv-app/lib/python3.12/site-packages/cli_base/tests/shell_complete_snapshots/.gitignore +0 -0
  31. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/__main__.py +0 -0
  32. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_app/__init__.py +0 -0
  33. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/__init__.py +0 -0
  34. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/benchmark.py +0 -0
  35. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/code_style.py +0 -0
  36. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/packaging.py +0 -0
  37. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/shell_completion.py +0 -0
  38. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/testing.py +0 -0
  39. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/cli_dev/update_readme_history.py +0 -0
  40. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/constants.py +0 -0
  41. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/logging_setup.py +0 -0
  42. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/__init__.py +0 -0
  43. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_doc_write.py +0 -0
  44. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_doctests.py +0 -0
  45. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_project_setup.py +0 -0
  46. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_readme.py +0 -0
  47. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_readme_history.py +0 -0
  48. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/tests/test_rebuild_database.py +0 -0
  49. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/__init__.py +0 -0
  50. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/file_hash_database.py +0 -0
  51. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/file_size_database.py +0 -0
  52. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/humanize.py +0 -0
  53. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/sha256sums.py +0 -0
  54. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/tee.py +0 -0
  55. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/tests/__init__.py +0 -0
  56. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/PyHardLinkBackup/utilities/tests/unittest_utilities.py +0 -0
  57. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/cli.py +0 -0
  58. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/dev-cli.py +0 -0
  59. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/dist/.gitignore +0 -0
  60. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/docs/about-docs.md +0 -0
  61. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/noxfile.py +0 -0
  62. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/pyproject.toml +0 -0
  63. {pyhardlinkbackup-1.7.2 → pyhardlinkbackup-1.8.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyHardLinkBackup
3
- Version: 1.7.2
3
+ Version: 1.8.0
4
4
  Summary: HardLink/Deduplication Backups with Python
5
5
  Project-URL: Documentation, https://github.com/jedie/PyHardLinkBackup
6
6
  Project-URL: Source, https://github.com/jedie/PyHardLinkBackup
@@ -35,6 +35,8 @@ Some aspects:
35
35
  Limitations:
36
36
 
37
37
  - Requires a filesystem that supports hardlinks (e.g., btrfs, zfs, ext4, APFS, NTFS with limitations).
38
+ - Empty directories are not backed up.
39
+
38
40
 
39
41
  ## installation
40
42
 
@@ -64,19 +66,23 @@ usage: phlb backup [-h] [BACKUP OPTIONS]
64
66
 
65
67
  Backup the source directory to the destination directory using hard links for deduplication.
66
68
 
67
- ╭─ positional arguments ──────────────────────────────────────────────────────────────────────────────────────╮
68
- │ source Source directory to back up. (required)
69
- │ destination Destination directory for the backup. (required)
70
- ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
71
- ╭─ options ───────────────────────────────────────────────────────────────────────────────────────────────────╮
72
- │ -h, --help show this help message and exit
73
- │ --excludes [STR [STR ...]]
74
- List of directories to exclude from backup. (default: __pycache__ .cache .temp .tmp .tox .nox)
75
- │ --verbosity {debug,info,warning,error}
76
- Log level for console logging. (default: warning)
77
- │ --log-file-level {debug,info,warning,error}
78
- Log level for the log file (default: info)
79
- ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
69
+ ╭─ positional arguments ───────────────────────────────────────────────────────────────────────────────────────────────╮
70
+ │ source Source directory to back up. (required)
71
+ │ destination Destination directory for the backup. (required)
72
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
73
+ ╭─ options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
74
+ │ -h, --help show this help message and exit
75
+ │ --name {None}|STR Optional name for the backup (used to create a subdirectory in the backup destination). If not
76
+ provided, the name of the source directory is used. (default: None)
77
+ │ --one-file-system, --no-one-file-system
78
+ Do not cross filesystem boundaries. (default: True)
79
+ │ --excludes [STR [STR ...]]
80
+ List of directories to exclude from backup. (default: __pycache__ .cache .temp .tmp .tox .nox)
81
+ │ --verbosity {debug,info,warning,error} │
82
+ │ Log level for console logging. (default: warning) │
83
+ │ --log-file-level {debug,info,warning,error} │
84
+ │ Log level for the log file (default: info) │
85
+ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
80
86
  ```
81
87
  [comment]: <> (✂✂✂ auto generated backup help end ✂✂✂)
82
88
 
@@ -283,10 +289,20 @@ Overview of main changes:
283
289
 
284
290
  [comment]: <> (✂✂✂ auto generated history start ✂✂✂)
285
291
 
292
+ * [v1.8.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.3...v1.8.0)
293
+ * 2026-01-22 - Add optional "--name" to enforce a name for the backup sub directory
294
+ * 2026-01-22 - Do not cross filesystem boundaries as default
295
+ * 2026-01-22 - Display progress also for large unique file copy
296
+ * 2026-01-22 - Optimize progress bars for smaller screens
297
+ * [v1.7.3](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.2...v1.7.3)
298
+ * 2026-01-21 - Handle directory symlinks correct
286
299
  * [v1.7.2](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.1...v1.7.2)
287
300
  * 2026-01-21 - Display "Remaining time" to files and sizes, too.
288
301
  * [v1.7.1](https://github.com/jedie/PyHardLinkBackup/compare/v1.7.0...v1.7.1)
289
302
  * 2026-01-19 - Update requirements to fix problems under Windows
303
+
304
+ <details><summary>Expand older history entries ...</summary>
305
+
290
306
  * [v1.7.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.6.0...v1.7.0)
291
307
  * 2026-01-19 - Speedup and enhance unittest
292
308
  * 2026-01-17 - Remove unfinished copied files on errors
@@ -300,9 +316,6 @@ Overview of main changes:
300
316
  * 2026-01-17 - Fix flaky test, because of terminal size
301
317
  * 2026-01-17 - Bugfix: Don't hash new large files twice
302
318
  * 2026-01-17 - Use compare also in backup tests
303
-
304
- <details><summary>Expand older history entries ...</summary>
305
-
306
319
  * [v1.5.0](https://github.com/jedie/PyHardLinkBackup/compare/v1.4.1...v1.5.0)
307
320
  * 2026-01-17 - NEW: Compare command to verify source tree with last backup
308
321
  * [v1.4.1](https://github.com/jedie/PyHardLinkBackup/compare/v1.4.0...v1.4.1)
@@ -3,5 +3,5 @@
3
3
  """
4
4
 
5
5
  # See https://packaging.python.org/en/latest/specifications/version-specifiers/
6
- __version__ = '1.7.2'
6
+ __version__ = '1.8.0'
7
7
  __author__ = 'Jens Diemer <PyHardLinkBackup@jensdiemer.de>'
@@ -16,11 +16,13 @@ from PyHardLinkBackup.utilities.file_size_database import FileSizeDatabase
16
16
  from PyHardLinkBackup.utilities.filesystem import (
17
17
  RemoveFileOnError,
18
18
  copy_and_hash,
19
+ copy_with_progress,
19
20
  hash_file,
20
21
  humanized_fs_scan,
21
22
  iter_scandir_files,
22
23
  read_and_hash_file,
23
24
  supports_hardlinks,
25
+ verbose_path_stat,
24
26
  )
25
27
  from PyHardLinkBackup.utilities.humanize import PrintTimingContextManager, human_filesize
26
28
  from PyHardLinkBackup.utilities.rich_utils import DisplayFileTreeProgress
@@ -52,6 +54,16 @@ class BackupResult:
52
54
  error_count: int = 0
53
55
 
54
56
 
57
+ def copy_symlink(src_path: Path, dst_path: Path) -> None:
58
+ """
59
+ Copy file and directory symlinks.
60
+ """
61
+ target_is_directory = src_path.is_dir()
62
+ logger.debug('Copy symlink: %s to %s (is directory: %r)', src_path, dst_path, target_is_directory)
63
+ target = os.readlink(src_path)
64
+ dst_path.symlink_to(target, target_is_directory=target_is_directory)
65
+
66
+
55
67
  def backup_one_file(
56
68
  *,
57
69
  src_root: Path,
@@ -74,8 +86,7 @@ def backup_one_file(
74
86
  size = entry.stat().st_size
75
87
  except FileNotFoundError as err:
76
88
  logger.warning(f'Broken symlink {src_path}: {err.__class__.__name__}: {err}')
77
- target = os.readlink(src_path)
78
- dst_path.symlink_to(target)
89
+ copy_symlink(src_path, dst_path)
79
90
  backup_result.symlink_files += 1
80
91
  return
81
92
 
@@ -88,9 +99,7 @@ def backup_one_file(
88
99
  return
89
100
 
90
101
  if entry.is_symlink():
91
- logger.debug('Copy symlink: %s to %s', src_path, dst_path)
92
- target = os.readlink(src_path)
93
- dst_path.symlink_to(target)
102
+ copy_symlink(src_path, dst_path)
94
103
  backup_result.symlink_files += 1
95
104
  return
96
105
 
@@ -141,7 +150,7 @@ def backup_one_file(
141
150
  backup_result.hardlinked_size += size
142
151
  else:
143
152
  logger.info('Copy unique file: %s to %s', src_path, dst_path)
144
- shutil.copyfile(src_path, dst_path)
153
+ copy_with_progress(src_path, dst_path, progress=progress, total_size=size)
145
154
  hash_db[file_hash] = dst_path
146
155
  backup_result.copied_files += 1
147
156
  backup_result.copied_size += size
@@ -163,6 +172,8 @@ def backup_tree(
163
172
  *,
164
173
  src_root: Path,
165
174
  backup_root: Path,
175
+ backup_name: str | None,
176
+ one_file_system: bool,
166
177
  excludes: tuple[str, ...],
167
178
  log_manager: LoggingManager,
168
179
  ) -> BackupResult:
@@ -172,12 +183,17 @@ def backup_tree(
172
183
  print(f'Please check source directory: "{src_root}"\n')
173
184
  sys.exit(1)
174
185
 
186
+ src_stat = verbose_path_stat(src_root)
187
+ src_device_id = src_stat.st_dev
188
+
175
189
  backup_root = backup_root.resolve()
176
190
  if not backup_root.is_dir():
177
191
  print('Error: Backup directory does not exist!')
178
192
  print(f'Please create "{backup_root}" directory first and start again!\n')
179
193
  sys.exit(1)
180
194
 
195
+ verbose_path_stat(backup_root)
196
+
181
197
  if not os.access(backup_root, os.W_OK):
182
198
  print('Error: No write access to backup directory!')
183
199
  print(f'Please check permissions for backup directory: "{backup_root}"\n')
@@ -191,13 +207,20 @@ def backup_tree(
191
207
  # Step 1: Scan source directory:
192
208
  excludes: set = set(excludes)
193
209
  with PrintTimingContextManager('Filesystem scan completed in'):
194
- src_file_count, src_total_size = humanized_fs_scan(src_root, excludes=excludes)
210
+ src_file_count, src_total_size = humanized_fs_scan(
211
+ path=src_root,
212
+ one_file_system=one_file_system,
213
+ src_device_id=src_device_id,
214
+ excludes=excludes,
215
+ )
195
216
 
196
217
  phlb_conf_dir = backup_root / '.phlb'
197
218
  phlb_conf_dir.mkdir(parents=False, exist_ok=True)
198
219
 
199
220
  timestamp = datetime.datetime.now().strftime('%Y-%m-%d-%H%M%S')
200
- backup_main_dir = backup_root / src_root.name
221
+ if not backup_name:
222
+ backup_name = src_root.name
223
+ backup_main_dir = backup_root / backup_name
201
224
  backup_dir = backup_main_dir / timestamp
202
225
  backup_dir.mkdir(parents=True, exist_ok=False)
203
226
 
@@ -220,7 +243,12 @@ def backup_tree(
220
243
  backup_result = BackupResult(backup_dir=backup_dir, log_file=log_file)
221
244
 
222
245
  next_update = 0
223
- for entry in iter_scandir_files(src_root, excludes=excludes):
246
+ for entry in iter_scandir_files(
247
+ path=src_root,
248
+ one_file_system=one_file_system,
249
+ src_device_id=src_device_id,
250
+ excludes=excludes,
251
+ ):
224
252
  try:
225
253
  backup_one_file(
226
254
  src_root=src_root,
@@ -17,7 +17,9 @@ from PyHardLinkBackup.logging_setup import (
17
17
  )
18
18
  from PyHardLinkBackup.utilities.tyro_cli_shared_args import (
19
19
  DEFAULT_EXCLUDE_DIRECTORIES,
20
+ TyroBackupNameArgType,
20
21
  TyroExcludeDirectoriesArgType,
22
+ TyroOneFileSystemArgType,
21
23
  )
22
24
 
23
25
 
@@ -41,6 +43,8 @@ def backup(
41
43
  ),
42
44
  ],
43
45
  /,
46
+ name: TyroBackupNameArgType = None,
47
+ one_file_system: TyroOneFileSystemArgType = True,
44
48
  excludes: TyroExcludeDirectoriesArgType = DEFAULT_EXCLUDE_DIRECTORIES,
45
49
  verbosity: TyroConsoleLogLevelArgType = DEFAULT_CONSOLE_LOG_LEVEL,
46
50
  log_file_level: TyroLogFileLevelArgType = DEFAULT_LOG_FILE_LEVEL,
@@ -55,6 +59,8 @@ def backup(
55
59
  backup_tree(
56
60
  src_root=src,
57
61
  backup_root=dst,
62
+ backup_name=name,
63
+ one_file_system=one_file_system,
58
64
  excludes=excludes,
59
65
  log_manager=log_manager,
60
66
  )
@@ -77,6 +83,7 @@ def compare(
77
83
  ),
78
84
  ],
79
85
  /,
86
+ one_file_system: TyroOneFileSystemArgType = True,
80
87
  excludes: TyroExcludeDirectoriesArgType = DEFAULT_EXCLUDE_DIRECTORIES,
81
88
  verbosity: TyroConsoleLogLevelArgType = DEFAULT_CONSOLE_LOG_LEVEL,
82
89
  log_file_level: TyroLogFileLevelArgType = DEFAULT_LOG_FILE_LEVEL,
@@ -91,6 +98,7 @@ def compare(
91
98
  compare_backup.compare_tree(
92
99
  src_root=src,
93
100
  backup_root=dst,
101
+ one_file_system=one_file_system,
94
102
  excludes=excludes,
95
103
  log_manager=log_manager,
96
104
  )
@@ -15,6 +15,7 @@ from PyHardLinkBackup.utilities.filesystem import (
15
15
  hash_file,
16
16
  humanized_fs_scan,
17
17
  iter_scandir_files,
18
+ verbose_path_stat,
18
19
  )
19
20
  from PyHardLinkBackup.utilities.humanize import PrintTimingContextManager, human_filesize
20
21
  from PyHardLinkBackup.utilities.rich_utils import DisplayFileTreeProgress
@@ -55,13 +56,21 @@ def compare_one_file(
55
56
  compare_result: CompareResult,
56
57
  progress: DisplayFileTreeProgress,
57
58
  ) -> None:
58
- src_size = entry.stat().st_size
59
+ if entry.is_file():
60
+ # For the progress bars:
61
+ compare_result.total_file_count += 1
62
+
63
+ try:
64
+ src_size = entry.stat().st_size
65
+ except FileNotFoundError as err:
66
+ logger.warning(f'Broken symlink {entry.path}: {err.__class__.__name__}: {err}')
67
+ return
59
68
 
60
69
  # For the progress bars:
61
- compare_result.total_file_count += 1
62
70
  compare_result.total_size += src_size
63
71
 
64
72
  src_path = Path(entry.path)
73
+
65
74
  dst_path = compare_dir / src_path.relative_to(src_root)
66
75
 
67
76
  if not dst_path.exists():
@@ -69,6 +78,24 @@ def compare_one_file(
69
78
  compare_result.src_file_new_count += 1
70
79
  return
71
80
 
81
+ if src_path.is_dir():
82
+ if not src_path.is_symlink():
83
+ raise RuntimeError(f'Internal error - Directory found: {src_path=}')
84
+
85
+ # compare directory symlink targets:
86
+ src_target = src_path.readlink()
87
+ dst_target = dst_path.readlink()
88
+ if src_target != dst_target:
89
+ logger.warning(
90
+ 'Source directory symlink %s target %s differs from compare symlink %s target %s',
91
+ src_path,
92
+ src_target,
93
+ dst_path,
94
+ dst_target,
95
+ )
96
+ compare_result.error_count += 1
97
+ return
98
+
72
99
  dst_size = dst_path.stat().st_size
73
100
  if src_size != dst_size:
74
101
  logger.warning(
@@ -123,6 +150,7 @@ def compare_tree(
123
150
  *,
124
151
  src_root: Path,
125
152
  backup_root: Path,
153
+ one_file_system: bool,
126
154
  excludes: tuple[str, ...],
127
155
  log_manager: LoggingManager,
128
156
  ) -> CompareResult:
@@ -155,9 +183,16 @@ def compare_tree(
155
183
  log_file = compare_main_dir / f'{now_timestamp}-compare.log'
156
184
  log_manager.start_file_logging(log_file)
157
185
 
186
+ src_device_id = verbose_path_stat(src_root).st_dev
187
+
158
188
  excludes: set = set(excludes)
159
189
  with PrintTimingContextManager('Filesystem scan completed in'):
160
- src_file_count, src_total_size = humanized_fs_scan(src_root, excludes=excludes)
190
+ src_file_count, src_total_size = humanized_fs_scan(
191
+ path=src_root,
192
+ one_file_system=one_file_system,
193
+ src_device_id=src_device_id,
194
+ excludes=excludes,
195
+ )
161
196
 
162
197
  with DisplayFileTreeProgress(
163
198
  description=f'Compare {src_root}...',
@@ -171,7 +206,12 @@ def compare_tree(
171
206
  compare_result = CompareResult(last_timestamp=last_timestamp, compare_dir=compare_dir, log_file=log_file)
172
207
 
173
208
  next_update = 0
174
- for entry in iter_scandir_files(src_root, excludes=excludes):
209
+ for entry in iter_scandir_files(
210
+ path=src_root,
211
+ one_file_system=one_file_system,
212
+ src_device_id=src_device_id,
213
+ excludes=excludes,
214
+ ):
175
215
  try:
176
216
  compare_one_file(
177
217
  src_root=src_root,
@@ -113,7 +113,12 @@ def rebuild(
113
113
  log_manager.start_file_logging(log_file=backup_root / f'{timestamp}-rebuild.log')
114
114
 
115
115
  with PrintTimingContextManager('Filesystem scan completed in'):
116
- file_count, total_size = humanized_fs_scan(backup_root, excludes={'.phlb'})
116
+ file_count, total_size = humanized_fs_scan(
117
+ path=backup_root,
118
+ one_file_system=False,
119
+ src_device_id=None,
120
+ excludes={'.phlb'},
121
+ )
117
122
 
118
123
  # We should ignore all files in the root backup directory itself
119
124
  # e.g.: Our *-summary.txt and *.log files
@@ -134,7 +139,12 @@ def rebuild(
134
139
  rebuild_result = RebuildResult()
135
140
 
136
141
  next_update = 0
137
- for entry in iter_scandir_files(backup_root, excludes={'.phlb'}):
142
+ for entry in iter_scandir_files(
143
+ path=backup_root,
144
+ one_file_system=False,
145
+ src_device_id=None,
146
+ excludes={'.phlb'},
147
+ ):
138
148
  try:
139
149
  rebuild_one_file(
140
150
  backup_root=backup_root,