6 changed files with 289 additions and 35 deletions
@ -0,0 +1,157 @@ |
|||
# bsv - Backup, Synchronization, Versioning |
|||
# Copyright (C) 2023 Simon Boyé |
|||
# |
|||
# This program is free software: you can redistribute it and/or modify |
|||
# it under the terms of the GNU Affero General Public License as published by |
|||
# the Free Software Foundation, either version 3 of the License, or |
|||
# (at your option) any later version. |
|||
# |
|||
# This program is distributed in the hope that it will be useful, |
|||
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|||
# GNU Affero General Public License for more details. |
|||
# |
|||
# You should have received a copy of the GNU Affero General Public License |
|||
# along with this program. If not, see <https://www.gnu.org/licenses/>. |
|||
from __future__ import annotations |
|||
|
|||
from enum import Enum |
|||
from os import stat_result |
|||
from pathlib import Path |
|||
import stat |
|||
|
|||
from bsv.object import Digest |
|||
from bsv.repository import Repository, Tree, TreeItem |
|||
from bsv.util import is_bsv_repository, object_type_from_mode |
|||
|
|||
|
|||
class Action(Enum): |
|||
ADD = "add" |
|||
UPDATE = "update" |
|||
IGNORE = "ignore" |
|||
ERROR = "error" |
|||
|
|||
class IgnoreCause(Enum): |
|||
IGNORE_RULE = "ignore_rule" |
|||
UNCHANGED = "unchanged" |
|||
UNSUPPORTED_TYPE = "unsupported_type" |
|||
|
|||
|
|||
class TreeWalker: |
|||
_repo: Repository |
|||
_dry_run: bool = False |
|||
|
|||
def __init__(self, repo: Repository, dry_run: bool=False): |
|||
self._repo = repo |
|||
self._dry_run = dry_run |
|||
|
|||
def add_tree(self, path: Path) -> Digest: |
|||
pstat = path.stat(follow_symlinks=False) |
|||
if self.ignore(path, pstat): |
|||
self.report(Action.IGNORE, path, pstat, IgnoreCause.IGNORE_RULE) |
|||
return Digest() |
|||
return self._add_tree(path, pstat) |
|||
|
|||
def _add_tree(self, path: Path, pstat: stat_result) -> Digest: |
|||
tree = Tree(self._repo, []) |
|||
for item in sorted(path.iterdir()): |
|||
digest = Digest() |
|||
try: |
|||
istat = item.lstat() |
|||
if self.ignore(item, istat): |
|||
self.report(Action.IGNORE, item, istat, IgnoreCause.IGNORE_RULE) |
|||
continue |
|||
object_type = object_type_from_mode(istat.st_mode) |
|||
if object_type == b"slnk": |
|||
digest = self._add_symlink(item, istat) |
|||
elif object_type == b"tree": |
|||
digest = self._add_tree(item, istat) |
|||
elif object_type == b"blob": |
|||
digest = self._add_blob(item, istat) |
|||
else: |
|||
self.report(Action.IGNORE, item, istat, IgnoreCause.UNSUPPORTED_TYPE) |
|||
continue |
|||
except Exception as err: |
|||
self.report(Action.ERROR, item, None, err) |
|||
continue |
|||
|
|||
if digest: |
|||
self.report(Action.ADD, path, pstat) |
|||
tree.items.append(TreeItem( |
|||
digest = digest, |
|||
object_type = object_type, |
|||
size = istat.st_size, |
|||
permissions = stat.S_IMODE(istat.st_mode), |
|||
modification_timestamp = istat.st_mtime_ns, |
|||
name = item.name, |
|||
)) |
|||
|
|||
return self._repo.add_tree(tree, dry_run=self._dry_run) |
|||
|
|||
|
|||
def _add_symlink(self, path: Path, pstat: stat_result) -> Digest: |
|||
# TODO: Store symlink relative to current dir ? |
|||
# * What about symlink that points outside of the backup dirs |
|||
# * Should symlinks that points inside the backup dirs but in another |
|||
# mount-point adjusted ? |
|||
# * Should absolute symlink be restored as absolute ? |
|||
self.report(Action.ADD, path, pstat) |
|||
return self._repo._cas.write( |
|||
b"slnk", |
|||
path.readlink().as_posix().encode("utf-8"), |
|||
dry_run = self._dry_run, |
|||
) |
|||
|
|||
def _add_blob(self, path: Path, pstat: stat_result) -> Digest: |
|||
self.report(Action.ADD, path, pstat) |
|||
with path.open("rb") as stream: |
|||
return self._repo.add_blob(stream, dry_run=self._dry_run) |
|||
|
|||
|
|||
def ignore(self, path: Path, pstat: stat_result) -> bool: |
|||
return is_bsv_repository(path) |
|||
|
|||
def report(self, action: Action, path: Path, pstat: stat_result | None, info: IgnoreCause | Exception | None=None): |
|||
match action, info: |
|||
case (Action.ADD, None): |
|||
print(f"Add: {path}") |
|||
case (Action.IGNORE, IgnoreCause.IGNORE_RULE): |
|||
print(f"Ignore (rule): {path}") |
|||
case (Action.IGNORE, IgnoreCause.UNCHANGED): |
|||
print(f"Ignore (unchanged): {path}") |
|||
case (Action.IGNORE, IgnoreCause.UNSUPPORTED_TYPE) if pstat is not None: |
|||
assert pstat is not None |
|||
print(f"Ignore (unsupported type {path_type_name(pstat)}): {path}") |
|||
case (Action.ERROR, _) if isinstance(info, Exception): |
|||
print(f"Error {info}: {path}") |
|||
case _: |
|||
raise ValueError("TreeWalker.report(): unsupported parameter combination") |
|||
|
|||
|
|||
def path_type_name(pstat: stat_result) -> str: |
|||
parts = [] |
|||
|
|||
if stat.S_ISBLK(pstat.st_mode): |
|||
parts.append("block_device") |
|||
if stat.S_ISCHR(pstat.st_mode): |
|||
parts.append("char_device") |
|||
if stat.S_ISDIR(pstat.st_mode): |
|||
parts.append("dir") |
|||
if stat.S_ISDOOR(pstat.st_mode): |
|||
parts.append("door") |
|||
if stat.S_ISFIFO(pstat.st_mode): |
|||
parts.append("fifo") |
|||
if stat.S_ISLNK(pstat.st_mode): |
|||
parts.append("symlink") |
|||
if stat.S_ISPORT(pstat.st_mode): |
|||
parts.append("port") |
|||
if stat.S_ISREG(pstat.st_mode): |
|||
parts.append("file") |
|||
if stat.S_ISSOCK(pstat.st_mode): |
|||
parts.append("socket") |
|||
if stat.S_ISWHT(pstat.st_mode): |
|||
parts.append("whiteout") |
|||
|
|||
if not parts: |
|||
return "unknown" |
|||
return ", ".join(parts) |
|||
Loading…
Reference in new issue