Skip to content
25 changes: 24 additions & 1 deletion docs/config/config-dotfiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
`dir_as_block` | List of glob patterns to match directories that should be handled as a single block during install operations (see [ignore patterns](config-file.md#ignore-patterns)).
`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))
Expand Down Expand Up @@ -216,4 +217,26 @@ profiles:
- f_test
```

Make sure to quote the link value in the config 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 `dir_as_block` option on specific dotfiles:

```yaml
dotfiles:
d_config:
src: app
dst: ~/.config/app
dir_as_block:
- "*app"
- "*otherdir*"
```

Note:
- During **install** operations, any directory matching a pattern in `dir_as_block` will be replaced as a whole
- This option has no effect on **compare** operations, which will always show file-by-file differences
- This option has no effect on dotfiles that are regular files
36 changes: 23 additions & 13 deletions dotdrop/dotdrop.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,12 +232,17 @@ 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)
ret, err = inst.install(
templ,
dotfile.src,
dotfile.dst,
dotfile.link,
actionexec=pre_actions_exec,
is_template=is_template,
ignore=ignores,
chmod=dotfile.chmod,
dir_as_block=dotfile.dir_as_block,
)
else:
# nolink
src = dotfile.src
Expand All @@ -250,13 +255,18 @@ 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)
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,
dir_as_block=dotfile.dir_as_block,
)
if tmp:
tmp = os.path.join(opts.dotpath, tmp)
if os.path.exists(tmp):
Expand Down
12 changes: 11 additions & 1 deletion dotdrop/dotfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@ class Dotfile(DictParser):
key_trans_install = 'trans_install'
key_trans_update = 'trans_update'
key_template = 'template'
key_dir_as_block = '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, dir_as_block=None):
"""
constructor
@key: dotfile key
Expand All @@ -39,6 +40,7 @@ def __init__(self, key, dst, src,
@instignore: patterns to ignore when installing
@template: template this dotfile
@chmod: file permission
@dir_as_block: handle directory matching patterns as a single block
"""
self.actions = actions or []
self.dst = dst
Expand All @@ -54,6 +56,7 @@ def __init__(self, key, dst, src,
self.template = template
self.chmod = chmod
self.ignore_missing_in_dotdrop = ignore_missing_in_dotdrop
self.dir_as_block = dir_as_block or []

if self.link != LinkTypes.NOLINK and \
(
Expand Down Expand Up @@ -96,6 +99,8 @@ def _adjust_yaml_keys(cls, value):
"""patch dict"""
value['noempty'] = value.get(cls.key_noempty, False)
value['template'] = value.get(cls.key_template, True)
value['dir_as_block'] = value.get(
cls.key_dir_as_block, [])
# remove old entries
value.pop(cls.key_noempty, None)
return value
Expand All @@ -121,6 +126,8 @@ def __str__(self):
msg += f', chmod:{self.chmod:o}'
else:
msg += f', chmod:\"{self.chmod}\"'
if self.dir_as_block:
msg += f', dir_as_block:{self.dir_as_block}'
return msg

def prt(self):
Expand All @@ -136,6 +143,9 @@ def prt(self):
out += f'\n{indent}chmod: \"{self.chmod:o}\"'
else:
out += f'\n{indent}chmod: \"{self.chmod}\"'
if self.dir_as_block:
out += (f'\n{indent}dir_as_block: '
f'"{self.dir_as_block}"')

out += f'\n{indent}pre-action:'
some = self.get_pre_actions()
Expand Down
137 changes: 127 additions & 10 deletions dotdrop/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
handle the installation of dotfiles
"""

# pylint: disable=C0302

import os
import errno
import shutil
Expand Down Expand Up @@ -79,7 +81,7 @@ def __init__(self, base='.', create=True, backup=True,
def install(self, templater, src, dst, linktype,
actionexec=None, noempty=False,
ignore=None, is_template=True,
chmod=None):
chmod=None, dir_as_block=None):
"""
install src to dst

Expand All @@ -92,12 +94,14 @@ def install(self, templater, src, dst, linktype,
@ignore: pattern to ignore when installing
@is_template: this dotfile is a template
@chmod: rights to apply if any
@dir_as_block: handle directories matching pattern as a single block

return
- True, None : success
- False, error_msg : error
- False, None : ignored
"""
dir_as_block = dir_as_block or []
if not src or not dst:
# fake dotfile
self.log.dbg('fake dotfile installed')
Expand Down Expand Up @@ -131,15 +135,50 @@ def install(self, templater, src, dst, linktype,
isdir = os.path.isdir(src)
self.log.dbg(f'install {src} to {dst}')
self.log.dbg(f'\"{src}\" is a directory: {isdir}')
self.log.dbg(f'dir_as_block: {dir_as_block}')

treat_as_block = (
isdir and
linktype == LinkTypes.NOLINK and
self._must_treat_dir_as_block(src, dir_as_block)
)
self.log.dbg(
f'dir_as_block patterns: {dir_as_block}, '
f'treat_as_block: {treat_as_block}'
)
if treat_as_block:
self.log.dbg(
f'handling directory {src} '
'as a block for installation'
)
ret, err, ins = self._copy_dir(
templater, src, dst,
actionexec=actionexec,
noempty=noempty, ignore=ignore,
is_template=is_template,
chmod=chmod,
dir_as_block=True,
dir_as_block_patterns=dir_as_block,
)
if self.remove_existing_in_dir and ins:
self._remove_existing_in_dir(dst, ins)
return self._log_install(ret, err)

if linktype == LinkTypes.NOLINK:
# normal file
if isdir:
ret, err, ins = self._copy_dir(templater, src, dst,
actionexec=actionexec,
noempty=noempty, ignore=ignore,
is_template=is_template,
chmod=chmod)
ret, err, ins = self._copy_dir(
templater,
src,
dst,
actionexec=actionexec,
noempty=noempty,
ignore=ignore,
is_template=is_template,
chmod=chmod,
dir_as_block=False,
dir_as_block_patterns=dir_as_block,
)
if self.remove_existing_in_dir and ins:
self._remove_existing_in_dir(dst, ins)
else:
Expand Down Expand Up @@ -194,6 +233,14 @@ def install(self, templater, src, dst, linktype,

return self._log_install(ret, err)

def _must_treat_dir_as_block(self, path, patterns):
"""returns true if directory path matches block patterns"""
if not patterns:
return False
return must_ignore([path], patterns,
debug=self.debug,
strict=True)

def _apply_chmod_after_install(self, src, dst, ret, err,
chmod=None,
is_sub=False,
Expand Down Expand Up @@ -602,7 +649,8 @@ def _copy_file(self, templater, src, dst,
def _copy_dir(self, templater, src, dst,
actionexec=None, noempty=False,
ignore=None, is_template=True,
chmod=None):
chmod=None, dir_as_block=False,
dir_as_block_patterns=None):
"""
install src to dst when is a directory

Expand All @@ -617,6 +665,72 @@ def _copy_dir(self, templater, src, dst,
fails
"""
self.log.dbg(f'deploy dir {src}')
self.log.dbg(f'dir_as_block: {dir_as_block}')
self.log.dbg(f'dir_as_block_patterns: {dir_as_block_patterns}')

dir_as_block_patterns = dir_as_block_patterns or []

# Handle directory as a block if option is enabled
if 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]
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 = []
Expand Down Expand Up @@ -644,7 +758,6 @@ def _copy_dir(self, templater, src, dst,

if res:
# something got installed

ret = True
else:
# is directory
Expand All @@ -655,7 +768,12 @@ def _copy_dir(self, templater, src, dst,
actionexec=actionexec,
noempty=noempty,
ignore=ignore,
is_template=is_template)
is_template=is_template,
dir_as_block=self._must_treat_dir_as_block(
fpath,
dir_as_block_patterns,
),
dir_as_block_patterns=dir_as_block_patterns)
dst_dotfiles.extend(subs)
if not res and err:
# error occured
Expand Down Expand Up @@ -804,7 +922,6 @@ def _write(self, src, dst, content=None,
########################################################
# helpers
########################################################

@classmethod
def _get_tmp_file_vars(cls, src, dst):
tmp = {}
Expand Down
7 changes: 6 additions & 1 deletion scripts/check-syntax.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,12 @@ done
# check other python scripts
echo "-----------------------------------------"
echo "checking other python scripts with pylint"
find . -name "*.py" -not -path "./dotdrop/*" -not -regex "\./\.?v?env/.*" | while read -r script; do
find . \
-path "./dotdrop" -prune -o \
-path "./.venv" -prune -o \
-path "./venv" -prune -o \
-path "./build" -prune -o \
-name "*.py" -print | while read -r script; do
echo "checking ${script}"
pylint -sn \
--disable=W0012 \
Expand Down
Loading
Loading