From 8dc1af6cd2015a980a6ee918fd7e9438c3080567 Mon Sep 17 00:00:00 2001 From: deadc0de6 <8973919+deadc0de6@users.noreply.github.com> Date: Wed, 14 May 2025 15:57:53 +0200 Subject: [PATCH] feature for #458 --- docs/config/config-dotfiles.md | 24 +++++++++++- dotdrop/dotdrop.py | 42 +++++++++++++------- dotdrop/dotfile.py | 10 ++++- dotdrop/ftree.py | 9 ++++- dotdrop/installer.py | 71 ++++++++++++++++++++++++++++++++-- dotdrop/updater.py | 1 + 6 files changed, 137 insertions(+), 20 deletions(-) diff --git a/docs/config/config-dotfiles.md b/docs/config/config-dotfiles.md index a8dec7c..122f4a4 100644 --- a/docs/config/config-dotfiles.md +++ b/docs/config/config-dotfiles.md @@ -10,6 +10,7 @@ Entry | Description `actions` | List of action keys that need to be defined in the **actions** entry below (See [actions](config-actions.md)) `chmod` | Defines the file permissions in octal notation to apply during installation or the special keyword `preserve` (See [permissions](config-file.md#permissions)) `cmpignore` | List of patterns to ignore when comparing (enclose in quotes when using wildcards; see [ignore patterns](config-file.md#ignore-patterns)) +`handle_dir_as_block` | When true, directories are handled as a single block during update operations instead of processing each file individually (defaults to false) `ignore_missing_in_dotdrop` | Ignore missing files in dotdrop when comparing and importing (see [Ignore missing](config-file.md#ignore-missing)) `ignoreempty` | If true, an empty template will not be deployed (defaults to the value of `ignoreempty`) `instignore` | List of patterns to ignore when installing (enclose in quotes when using wildcards; see [ignore patterns](config-file.md#ignore-patterns)) @@ -216,4 +217,25 @@ profiles: - f_test ``` -Make sure to quote the link value in the config file. \ No newline at end of file +Make sure to quote the link value in the config file. + +## Handle directories as blocks + +When managing dotfiles that are directories, dotdrop normally processes each file and subdirectory individually. This allows for precise control over the contents, showing individual file differences, and selectively updating files. +However, in some cases, you may prefer to treat an entire directory as a single unit. + +For these scenarios, you can use the `handle_dir_as_block` option on specific dotfiles: + +```yaml +dotfiles: + d_config: + src: app + dst: ~/.config/app + handle_dir_as_block: true +``` + +When this option is enabled: +- During **install** operations, the entire directory will be replaced as a whole, rather than updating individual files +- This option has **no effect** on **compare** operations, which will always show file-by-file differences + +This option defaults to `false` and can be set on any dotfile that represents a directory. It has no effect on dotfiles that are regular files. \ No newline at end of file diff --git a/dotdrop/dotdrop.py b/dotdrop/dotdrop.py index 98c3d00..6aa2adb 100644 --- a/dotdrop/dotdrop.py +++ b/dotdrop/dotdrop.py @@ -232,12 +232,20 @@ def _dotfile_install(opts, dotfile, tmpdir=None): LinkTypes.RELATIVE, LinkTypes.ABSOLUTE ): # nolink|relative|absolute|link_children - ret, err = inst.install(templ, dotfile.src, dotfile.dst, - dotfile.link, - actionexec=pre_actions_exec, - is_template=is_template, - ignore=ignores, - chmod=dotfile.chmod) + asblock = False + if hasattr(dotfile, 'handle_dir_as_block'): + asblock = True + ret, err = inst.install( + templ, + dotfile.src, + dotfile.dst, + dotfile.link, + actionexec=pre_actions_exec, + is_template=is_template, + ignore=ignores, + chmod=dotfile.chmod, + handle_dir_as_block=asblock, + ) else: # nolink src = dotfile.src @@ -250,13 +258,21 @@ def _dotfile_install(opts, dotfile, tmpdir=None): src = tmp # make sure to re-evaluate if is template is_template = dotfile.template and Templategen.path_is_template(src) - ret, err = inst.install(templ, src, dotfile.dst, - LinkTypes.NOLINK, - actionexec=pre_actions_exec, - noempty=dotfile.noempty, - ignore=ignores, - is_template=is_template, - chmod=dotfile.chmod) + asblock = False + if hasattr(dotfile, "handle_dir_as_block"): + asblock = True + ret, err = inst.install( + templ, + src, + dotfile.dst, + LinkTypes.NOLINK, + actionexec=pre_actions_exec, + noempty=dotfile.noempty, + ignore=ignores, + is_template=is_template, + chmod=dotfile.chmod, + handle_dir_as_block=asblock, + ) if tmp: tmp = os.path.join(opts.dotpath, tmp) if os.path.exists(tmp): diff --git a/dotdrop/dotfile.py b/dotdrop/dotfile.py index e586809..eb419f3 100644 --- a/dotdrop/dotfile.py +++ b/dotdrop/dotfile.py @@ -17,13 +17,14 @@ class Dotfile(DictParser): key_trans_install = 'trans_install' key_trans_update = 'trans_update' key_template = 'template' + key_handle_dir_as_block = 'handle_dir_as_block' def __init__(self, key, dst, src, actions=None, trans_install=None, trans_update=None, link=LinkTypes.NOLINK, noempty=False, cmpignore=None, upignore=None, instignore=None, template=True, chmod=None, - ignore_missing_in_dotdrop=False): + ignore_missing_in_dotdrop=False, handle_dir_as_block=False): """ constructor @key: dotfile key @@ -39,6 +40,7 @@ class Dotfile(DictParser): @instignore: patterns to ignore when installing @template: template this dotfile @chmod: file permission + @handle_dir_as_block: handle directory as a single block """ self.actions = actions or [] self.dst = dst @@ -54,6 +56,7 @@ class Dotfile(DictParser): self.template = template self.chmod = chmod self.ignore_missing_in_dotdrop = ignore_missing_in_dotdrop + self.handle_dir_as_block = handle_dir_as_block if self.link != LinkTypes.NOLINK and \ ( @@ -96,6 +99,7 @@ class Dotfile(DictParser): """patch dict""" value['noempty'] = value.get(cls.key_noempty, False) value['template'] = value.get(cls.key_template, True) + value['handle_dir_as_block'] = value.get(cls.key_handle_dir_as_block, False) # remove old entries value.pop(cls.key_noempty, None) return value @@ -121,6 +125,8 @@ class Dotfile(DictParser): msg += f', chmod:{self.chmod:o}' else: msg += f', chmod:\"{self.chmod}\"' + if self.handle_dir_as_block: + msg += f', handle_dir_as_block:{self.handle_dir_as_block}' return msg def prt(self): @@ -136,6 +142,8 @@ class Dotfile(DictParser): out += f'\n{indent}chmod: \"{self.chmod:o}\"' else: out += f'\n{indent}chmod: \"{self.chmod}\"' + if self.handle_dir_as_block: + out += f'\n{indent}handle_dir_as_block: \"{self.handle_dir_as_block}\"' out += f'\n{indent}pre-action:' some = self.get_pre_actions() diff --git a/dotdrop/ftree.py b/dotdrop/ftree.py index 4d6c888..e769f43 100644 --- a/dotdrop/ftree.py +++ b/dotdrop/ftree.py @@ -18,10 +18,11 @@ class FTreeDir: directory tree for comparison """ - def __init__(self, path, ignores=None, debug=False): + def __init__(self, path, ignores=None, debug=False, handle_dir_as_block=False): self.path = path self.ignores = ignores self.debug = debug + self.handle_dir_as_block = handle_dir_as_block self.entries = [] self.log = Logger(debug=self.debug) if os.path.exists(path) and os.path.isdir(path): @@ -33,6 +34,12 @@ class FTreeDir: ignore empty directory test for ignore pattern """ + # if directory should be handled as a block, just add the directory itself + if self.handle_dir_as_block: + self.log.dbg(f'handle as block: {self.path}') + self.entries.append(self.path) + return + for root, dirs, files in os.walk(self.path, followlinks=True): for file in files: fpath = os.path.join(root, file) diff --git a/dotdrop/installer.py b/dotdrop/installer.py index d3233bf..6e8ac1a 100644 --- a/dotdrop/installer.py +++ b/dotdrop/installer.py @@ -79,7 +79,7 @@ class Installer: def install(self, templater, src, dst, linktype, actionexec=None, noempty=False, ignore=None, is_template=True, - chmod=None): + chmod=None, handle_dir_as_block=False): """ install src to dst @@ -92,6 +92,7 @@ class Installer: @ignore: pattern to ignore when installing @is_template: this dotfile is a template @chmod: rights to apply if any + @handle_dir_as_block: if True, handle directories as a single block return - True, None : success @@ -139,7 +140,8 @@ class Installer: actionexec=actionexec, noempty=noempty, ignore=ignore, is_template=is_template, - chmod=chmod) + chmod=chmod, + handle_dir_as_block=handle_dir_as_block) if self.remove_existing_in_dir and ins: self._remove_existing_in_dir(dst, ins) else: @@ -602,7 +604,7 @@ class Installer: def _copy_dir(self, templater, src, dst, actionexec=None, noempty=False, ignore=None, is_template=True, - chmod=None): + chmod=None, handle_dir_as_block=False): """ install src to dst when is a directory @@ -617,6 +619,68 @@ class Installer: fails """ self.log.dbg(f'deploy dir {src}') + self.log.dbg(f'handle_dir_as_block: {handle_dir_as_block}') + + # Handle directory as a block if option is enabled + if handle_dir_as_block: + self.log.dbg(f'handling directory {src} as a block for installation') + dst_dotfiles = [] + + # Ask user for confirmation if safe mode is on + if os.path.exists(dst): + msg = f'Overwrite entire directory \"{dst}\" with \"{src}\"?' + if self.safe and not self.log.ask(msg): + return False, 'aborted', [] + + # Remove existing directory completely + if self.dry: + self.log.dry(f'would rm -r {dst}') + else: + self.log.dbg(f'rm -r {dst}') + if not removepath(dst, logger=self.log): + msg = f'unable to remove {dst}, do manually' + self.log.warn(msg) + return False, msg, [] + + # Create parent directory if needed + parent_dir = os.path.dirname(dst) + if not os.path.exists(parent_dir): + if self.dry: + self.log.dry(f'would mkdir -p {parent_dir}') + else: + if not self._create_dirs(parent_dir): + err = f'error creating directory for {dst}' + return False, err, [] + + # Copy directory recursively + if self.dry: + self.log.dry(f'would cp -r {src} {dst}') + return True, None, [dst] + else: + try: + # Execute pre actions + ret, err = self._exec_pre_actions(actionexec) + if not ret: + return False, err, [] + + # Copy the directory as a whole + shutil.copytree(src, dst) + + # Record all files that were installed + for root, _, files in os.walk(dst): + for file in files: + path = os.path.join(root, file) + dst_dotfiles.append(path) + + if not self.comparing: + self.log.sub(f'installed directory {src} to {dst} as a block') + return True, None, dst_dotfiles + except (shutil.Error, OSError) as exc: + err = f'{src} installation failed: {exc}' + self.log.warn(err) + return False, err, [] + + # Regular directory installation (file by file) # default to nothing installed and no error ret = False dst_dotfiles = [] @@ -644,7 +708,6 @@ class Installer: if res: # something got installed - ret = True else: # is directory diff --git a/dotdrop/updater.py b/dotdrop/updater.py index e482237..e23ee12 100644 --- a/dotdrop/updater.py +++ b/dotdrop/updater.py @@ -39,6 +39,7 @@ class Updater: @debug: enable debug @ignore: pattern to ignore when updating @showpatch: show patch if dotfile to update is a template + @ignore_missing_in_dotdrop: ignore missing files in dotdrop """ self.dotpath = dotpath self.variables = variables