O(touched) restore for a hardlinked directory backup. The backup was built with ``os.link``, so live files the mutation never touched still share the backup's inode — leave them. Only files the mutation changed need work: new files (no backup counterpart) are removed, modified files
(backup: Path, target: Path)
| 258 | |
| 259 | |
| 260 | def _restore_hardlinked_dir(backup: Path, target: Path) -> None: |
| 261 | """O(touched) restore for a hardlinked directory backup. |
| 262 | |
| 263 | The backup was built with ``os.link``, so live files the mutation never |
| 264 | touched still share the backup's inode — leave them. Only files the |
| 265 | mutation changed need work: new files (no backup counterpart) are removed, |
| 266 | modified files (atomic temp+replace → new inode) and deleted files are |
| 267 | restored from the backup's pre-mutation bytes. This avoids recopying the |
| 268 | whole tree on rollback — the cost that bit ``.openkb/files`` (the blob |
| 269 | store) and large concept/entity trees on every failed add. |
| 270 | |
| 271 | Degrades gracefully to a full copy if the backup isn't actually hardlinked |
| 272 | (e.g. the EXDEV/EACCES fallback fired at snapshot time): every file then has |
| 273 | a different inode, so every file is treated as modified and recopied. |
| 274 | """ |
| 275 | |
| 276 | def _file_key(path: Path) -> tuple[int, int]: |
| 277 | st = path.stat() # follows symlinks; these trees hold regular files only |
| 278 | return (st.st_dev, st.st_ino) |
| 279 | |
| 280 | backup_files = {p.relative_to(backup): p for p in backup.rglob("*") if p.is_file()} |
| 281 | |
| 282 | # Pass 1: remove new + modified live regular files; leave untouched ones |
| 283 | # (they share the backup inode) in place. |
| 284 | if target.exists(): |
| 285 | for live in list(target.rglob("*")): |
| 286 | if not live.is_file(): |
| 287 | continue |
| 288 | counterpart = backup_files.get(live.relative_to(target)) |
| 289 | if counterpart is None or _file_key(live) != _file_key(counterpart): |
| 290 | live.unlink() |
| 291 | |
| 292 | # Pass 2: restore modified + deleted files from backup. |
| 293 | for rel, src in backup_files.items(): |
| 294 | dest = target / rel |
| 295 | if not dest.exists() or _file_key(dest) != _file_key(src): |
| 296 | dest.parent.mkdir(parents=True, exist_ok=True) |
| 297 | shutil.copy2(src, dest) |
| 298 | |
| 299 | # Pass 3: prune directories the mutation created that are now empty. |
| 300 | if target.exists(): |
| 301 | for d in sorted( |
| 302 | (p for p in target.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True |
| 303 | ): |
| 304 | if not (backup / d.relative_to(target)).exists() and not any(d.iterdir()): |
| 305 | d.rmdir() |
| 306 | |
| 307 | |
| 308 | def snapshot_paths( |