dotsync-cli 1.0.2__py3-none-any.whl
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.
- dotsync/__init__.py +0 -0
- dotsync/__main__.py +1080 -0
- dotsync/args.py +149 -0
- dotsync/calc_ops.py +298 -0
- dotsync/checks.py +41 -0
- dotsync/enums.py +16 -0
- dotsync/file_ops.py +122 -0
- dotsync/flists.py +90 -0
- dotsync/git.py +161 -0
- dotsync/info.py +11 -0
- dotsync/plugin.py +48 -0
- dotsync/plugins/__init__.py +0 -0
- dotsync/plugins/encrypt.py +230 -0
- dotsync/plugins/plain.py +48 -0
- dotsync_cli-1.0.2.dist-info/METADATA +267 -0
- dotsync_cli-1.0.2.dist-info/RECORD +19 -0
- dotsync_cli-1.0.2.dist-info/WHEEL +4 -0
- dotsync_cli-1.0.2.dist-info/entry_points.txt +2 -0
- dotsync_cli-1.0.2.dist-info/licenses/LICENSE +36 -0
dotsync/args.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from dotsync.enums import Actions
|
|
6
|
+
import dotsync.info as info
|
|
7
|
+
|
|
8
|
+
HELP = {
|
|
9
|
+
'verbose': 'increase verbosity level',
|
|
10
|
+
'dry-run': 'do not actually execute any file operations',
|
|
11
|
+
'hard-mode': 'copy files instead of symlinking them',
|
|
12
|
+
'action': 'action to take on active categories',
|
|
13
|
+
'category': 'categories to activate (default: common + hostname)'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
EPILOG = 'See full the documentation at https://dotsync.readthedocs.io/'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CustomHelpFormatter(argparse.RawDescriptionHelpFormatter):
|
|
20
|
+
"""Custom formatter that hides default values containing hostname"""
|
|
21
|
+
def _get_help_string(self, action):
|
|
22
|
+
help_text = action.help
|
|
23
|
+
# Replace %(default)s placeholder
|
|
24
|
+
if '%(default)s' in help_text:
|
|
25
|
+
if action.dest == 'category':
|
|
26
|
+
default = action.default
|
|
27
|
+
if isinstance(default, list) and len(default) == 2 and default[1] == info.hostname:
|
|
28
|
+
help_text = help_text.replace('%(default)s', 'common + hostname')
|
|
29
|
+
else:
|
|
30
|
+
help_text = help_text % {'default': default}
|
|
31
|
+
else:
|
|
32
|
+
help_text = help_text % {'default': action.default}
|
|
33
|
+
return help_text
|
|
34
|
+
|
|
35
|
+
def _format_action(self, action):
|
|
36
|
+
"""Override to hide default display for category argument"""
|
|
37
|
+
# Temporarily remove default to prevent argparse from showing it
|
|
38
|
+
if action.dest == 'category' and hasattr(action, 'default'):
|
|
39
|
+
original_default = action.default
|
|
40
|
+
# Replace with SUPPRESS to hide it from help output
|
|
41
|
+
action.default = argparse.SUPPRESS
|
|
42
|
+
|
|
43
|
+
# Format the action
|
|
44
|
+
result = super()._format_action(action)
|
|
45
|
+
|
|
46
|
+
# Restore original default
|
|
47
|
+
if action.dest == 'category' and 'original_default' in locals():
|
|
48
|
+
action.default = original_default
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Arguments:
|
|
54
|
+
def __init__(self, args=None):
|
|
55
|
+
# construct parser
|
|
56
|
+
parser = argparse.ArgumentParser(prog='dotsync',
|
|
57
|
+
epilog=EPILOG,
|
|
58
|
+
formatter_class=CustomHelpFormatter)
|
|
59
|
+
|
|
60
|
+
# add parser options
|
|
61
|
+
parser.add_argument('--version', action='version',
|
|
62
|
+
version=f'dotsync {info.__version__}')
|
|
63
|
+
parser.add_argument('--verbose', '-v', action='count', default=0,
|
|
64
|
+
help=HELP['verbose'])
|
|
65
|
+
parser.add_argument('--dry-run', action='store_true',
|
|
66
|
+
help=HELP['dry-run'])
|
|
67
|
+
parser.add_argument('--hard', action='store_true',
|
|
68
|
+
help=HELP['hard-mode'])
|
|
69
|
+
parser.add_argument('--encrypt', action='store_true',
|
|
70
|
+
help='encrypt the file (for add command)')
|
|
71
|
+
|
|
72
|
+
parser.add_argument('action', choices=[a.value for a in Actions],
|
|
73
|
+
help=HELP['action'])
|
|
74
|
+
# For 'add' action: category[0] is filepath, category[1] is optional category name
|
|
75
|
+
# For 'encrypt' action: category[0] is filepath
|
|
76
|
+
# For 'unmanage' action: category[0] is filepath
|
|
77
|
+
# For other actions: category is list of category names
|
|
78
|
+
category_help = HELP['category']
|
|
79
|
+
add_help = 'filepath [category] - add new config file to filelist'
|
|
80
|
+
encrypt_help = 'filepath - convert existing config file to encrypted'
|
|
81
|
+
unmanage_help = 'filepath - restore file to home and stop managing it'
|
|
82
|
+
|
|
83
|
+
# For init: category[0] is optional directory path (defaults to ~/.dotfiles)
|
|
84
|
+
init_help = '[directory] - initialize dotsync repository (default: ~/.dotfiles)'
|
|
85
|
+
category_help_extended = f'{category_help} (for "init": {init_help}, for "add": {add_help}, for "encrypt": {encrypt_help}, for "unmanage": {unmanage_help})'
|
|
86
|
+
|
|
87
|
+
parser.add_argument('category', nargs='*',
|
|
88
|
+
default=['common', info.hostname],
|
|
89
|
+
help=category_help_extended)
|
|
90
|
+
|
|
91
|
+
# parse args
|
|
92
|
+
args = parser.parse_args(args)
|
|
93
|
+
|
|
94
|
+
# For init action, category[0] is optional directory path
|
|
95
|
+
if args.action == 'init':
|
|
96
|
+
# Check if user provided a directory (not the default categories)
|
|
97
|
+
# Default is ['common', info.hostname], so if category doesn't match this pattern, it's a directory
|
|
98
|
+
if len(args.category) > 0:
|
|
99
|
+
# If category looks like a path (contains / or starts with ~ or is absolute), it's a directory
|
|
100
|
+
first_arg = args.category[0]
|
|
101
|
+
if ('/' in first_arg or first_arg.startswith('~') or os.path.isabs(first_arg) or
|
|
102
|
+
first_arg not in ['common']):
|
|
103
|
+
# User provided a directory
|
|
104
|
+
self.init_directory = first_arg
|
|
105
|
+
else:
|
|
106
|
+
# Probably default categories, use default directory
|
|
107
|
+
self.init_directory = None
|
|
108
|
+
else:
|
|
109
|
+
# No arguments, use default directory
|
|
110
|
+
self.init_directory = None
|
|
111
|
+
# For add action, category[0] is filepath, category[1] is category name
|
|
112
|
+
elif args.action == 'add':
|
|
113
|
+
if len(args.category) < 1:
|
|
114
|
+
parser.error('add action requires at least one argument: filepath [category]')
|
|
115
|
+
self.add_filepath = args.category[0]
|
|
116
|
+
self.add_category = args.category[1] if len(args.category) > 1 else None
|
|
117
|
+
# For encrypt action, category[0] is filepath
|
|
118
|
+
elif args.action == 'encrypt':
|
|
119
|
+
if len(args.category) < 1:
|
|
120
|
+
parser.error('encrypt action requires filepath argument')
|
|
121
|
+
self.add_filepath = args.category[0]
|
|
122
|
+
self.add_category = None
|
|
123
|
+
# For unmanage action, category[0] is filepath
|
|
124
|
+
elif args.action == 'unmanage':
|
|
125
|
+
if len(args.category) < 1:
|
|
126
|
+
parser.error('unmanage action requires filepath argument')
|
|
127
|
+
self.add_filepath = args.category[0]
|
|
128
|
+
self.add_category = None
|
|
129
|
+
else:
|
|
130
|
+
self.add_filepath = None
|
|
131
|
+
self.add_category = None
|
|
132
|
+
self.init_directory = None
|
|
133
|
+
|
|
134
|
+
# extract settings
|
|
135
|
+
if args.verbose:
|
|
136
|
+
args.verbose = min(args.verbose, 2)
|
|
137
|
+
self.verbose_level = (logging.INFO if args.verbose < 2 else
|
|
138
|
+
logging.DEBUG)
|
|
139
|
+
else:
|
|
140
|
+
self.verbose_level = logging.WARNING
|
|
141
|
+
|
|
142
|
+
self.dry_run = args.dry_run
|
|
143
|
+
self.hard_mode = args.hard
|
|
144
|
+
self.encrypt = getattr(args, 'encrypt', False)
|
|
145
|
+
self.action = Actions(args.action)
|
|
146
|
+
self.categories = args.category
|
|
147
|
+
|
|
148
|
+
def __str__(self):
|
|
149
|
+
return str(vars(self))
|
dotsync/calc_ops.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
from dotsync.file_ops import FileOps
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CalcOps:
|
|
8
|
+
def __init__(self, repo, restore_path, plugin):
|
|
9
|
+
self.repo = str(repo)
|
|
10
|
+
self.restore_path = str(restore_path)
|
|
11
|
+
self.plugin = plugin
|
|
12
|
+
|
|
13
|
+
def update(self, files):
|
|
14
|
+
fops = FileOps(self.repo)
|
|
15
|
+
|
|
16
|
+
for path in files:
|
|
17
|
+
categories = files[path]
|
|
18
|
+
|
|
19
|
+
master = min(categories)
|
|
20
|
+
slaves = [c for c in categories if c != master]
|
|
21
|
+
|
|
22
|
+
# checks if a candidate exists and also checks if the candidate is
|
|
23
|
+
# a link so that its resolved path can be used
|
|
24
|
+
original_path = {}
|
|
25
|
+
|
|
26
|
+
def check_cand(cand):
|
|
27
|
+
cand = os.path.join(cand, path)
|
|
28
|
+
if os.path.isfile(cand):
|
|
29
|
+
if os.path.islink(cand):
|
|
30
|
+
old = cand
|
|
31
|
+
cand = os.path.realpath(cand)
|
|
32
|
+
original_path[cand] = old
|
|
33
|
+
return [cand]
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
candidates = []
|
|
37
|
+
candidates += check_cand(self.restore_path)
|
|
38
|
+
|
|
39
|
+
# candidate not found in restore path, so check elsewhere
|
|
40
|
+
if not candidates:
|
|
41
|
+
for cand in [os.path.join(self.repo, c) for c in categories]:
|
|
42
|
+
candidates += check_cand(cand)
|
|
43
|
+
else:
|
|
44
|
+
logging.debug(f'"{path}" found in restore path, so overriding '
|
|
45
|
+
'any other candidates')
|
|
46
|
+
|
|
47
|
+
if not candidates:
|
|
48
|
+
logging.warning(f'unable to find any candidates for "{path}"')
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
candidates = list(set(candidates))
|
|
52
|
+
if len(candidates) > 1:
|
|
53
|
+
print(f'multiple candidates found for {path}:\n')
|
|
54
|
+
|
|
55
|
+
for i, cand in enumerate(candidates):
|
|
56
|
+
print(f'[{i}] {cand}')
|
|
57
|
+
print('[-1] cancel')
|
|
58
|
+
|
|
59
|
+
while True:
|
|
60
|
+
try:
|
|
61
|
+
choice = int(input('please select the version you '
|
|
62
|
+
'would like to use '
|
|
63
|
+
f'[0-{len(candidates)-1}]: '))
|
|
64
|
+
choice = candidates[choice]
|
|
65
|
+
except (ValueError, EOFError):
|
|
66
|
+
print('invalid choice entered, please try again')
|
|
67
|
+
continue
|
|
68
|
+
break
|
|
69
|
+
source = choice
|
|
70
|
+
|
|
71
|
+
# if one of the candidates is not in the repo and it is not the
|
|
72
|
+
# source it should be deleted manually since it will not be
|
|
73
|
+
# deleted in the slave linking below, as the other candidates
|
|
74
|
+
# would be
|
|
75
|
+
restore_path = os.path.join(self.restore_path, path)
|
|
76
|
+
if restore_path in candidates and source != restore_path:
|
|
77
|
+
fops.remove(restore_path)
|
|
78
|
+
|
|
79
|
+
else:
|
|
80
|
+
source = candidates.pop()
|
|
81
|
+
|
|
82
|
+
master = os.path.join(self.repo, master, path)
|
|
83
|
+
slaves = [os.path.join(self.repo, s, path) for s in slaves]
|
|
84
|
+
|
|
85
|
+
if source != master and not self.plugin.samefile(master, source):
|
|
86
|
+
if os.path.exists(master):
|
|
87
|
+
fops.remove(master)
|
|
88
|
+
# check if source is in repo, if it is not apply the plugin
|
|
89
|
+
if source.startswith(self.repo + os.sep):
|
|
90
|
+
# if the source is one of the slaves, move the source
|
|
91
|
+
# otherwise just copy it because it might have changed into
|
|
92
|
+
# a seperate category - cleanup will remove it if needed
|
|
93
|
+
if source in slaves:
|
|
94
|
+
fops.move(source, master)
|
|
95
|
+
else:
|
|
96
|
+
fops.copy(source, master)
|
|
97
|
+
else:
|
|
98
|
+
fops.plugin(self.plugin.apply, source, master)
|
|
99
|
+
if source in original_path:
|
|
100
|
+
fops.remove(original_path[source])
|
|
101
|
+
else:
|
|
102
|
+
fops.remove(source)
|
|
103
|
+
|
|
104
|
+
for slave in slaves:
|
|
105
|
+
if slave != source:
|
|
106
|
+
if os.path.isfile(slave) or os.path.islink(slave):
|
|
107
|
+
if os.path.realpath(slave) != master:
|
|
108
|
+
fops.remove(slave)
|
|
109
|
+
else:
|
|
110
|
+
# already linked to master so just ignore
|
|
111
|
+
continue
|
|
112
|
+
fops.link(master, slave)
|
|
113
|
+
|
|
114
|
+
return fops
|
|
115
|
+
|
|
116
|
+
def restore(self, files):
|
|
117
|
+
fops = FileOps(self.repo)
|
|
118
|
+
|
|
119
|
+
for path in files:
|
|
120
|
+
categories = files[path]
|
|
121
|
+
master = min(categories)
|
|
122
|
+
source = os.path.join(self.repo, master, path)
|
|
123
|
+
|
|
124
|
+
if not os.path.exists(source):
|
|
125
|
+
logging.debug(f'{source} not found in repo')
|
|
126
|
+
logging.warning(f'unable to find "{path}" in repo, skipping')
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
dest = os.path.join(self.restore_path, path)
|
|
130
|
+
|
|
131
|
+
# Use shared conflict handling logic
|
|
132
|
+
# Import here to avoid circular imports
|
|
133
|
+
import sys
|
|
134
|
+
import os as os_module
|
|
135
|
+
# Get the repo root by going up from repo path
|
|
136
|
+
repo_root = os_module.path.dirname(os_module.path.dirname(self.repo))
|
|
137
|
+
|
|
138
|
+
# Import handle_dest_file_conflict from __main__ if available
|
|
139
|
+
# For now, use inline simplified version that matches restore behavior
|
|
140
|
+
should_proceed, should_copy = self._handle_restore_conflict(source, dest)
|
|
141
|
+
|
|
142
|
+
if not should_proceed:
|
|
143
|
+
continue
|
|
144
|
+
if not should_copy:
|
|
145
|
+
# Files are same, skip
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
fops.plugin(self.plugin.remove, source, dest)
|
|
149
|
+
|
|
150
|
+
return fops
|
|
151
|
+
|
|
152
|
+
def _handle_restore_conflict(self, source, dest):
|
|
153
|
+
"""Handle conflict for restore operation (simpler than unmanage)"""
|
|
154
|
+
if not os.path.exists(dest):
|
|
155
|
+
# Check for dangling symlink
|
|
156
|
+
if os.path.islink(dest):
|
|
157
|
+
os.remove(dest)
|
|
158
|
+
return (True, True)
|
|
159
|
+
|
|
160
|
+
# Check if files are the same
|
|
161
|
+
try:
|
|
162
|
+
if self.plugin.samefile(source, dest):
|
|
163
|
+
logging.debug(f'{dest} is the same file as in the repo, skipping')
|
|
164
|
+
return (True, False) # Skip, don't copy
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
# Check if dest is a symlink to repo
|
|
169
|
+
if os.path.islink(dest):
|
|
170
|
+
link_target = os.readlink(dest)
|
|
171
|
+
repo_abs = os.path.abspath(source)
|
|
172
|
+
if os.path.abspath(link_target) == repo_abs or link_target.startswith(os.path.dirname(os.path.dirname(self.repo)) + os.sep):
|
|
173
|
+
# Symlink to repo, safe to remove
|
|
174
|
+
logging.info(f'{dest} already linked to repo, replacing with new file')
|
|
175
|
+
os.remove(dest)
|
|
176
|
+
return (True, True)
|
|
177
|
+
else:
|
|
178
|
+
# Symlink to somewhere else, ask user (restore behavior)
|
|
179
|
+
a = input(f'{dest} already exists, replace? [Yn] ')
|
|
180
|
+
a = 'y' if not a else a
|
|
181
|
+
if a.lower() == 'y':
|
|
182
|
+
os.remove(dest)
|
|
183
|
+
return (True, True)
|
|
184
|
+
else:
|
|
185
|
+
return (False, False) # Cancelled
|
|
186
|
+
|
|
187
|
+
# Regular file exists - simple prompt for restore
|
|
188
|
+
a = input(f'{dest} already exists, replace? [Yn] ')
|
|
189
|
+
a = 'y' if not a else a
|
|
190
|
+
if a.lower() == 'y':
|
|
191
|
+
os.remove(dest)
|
|
192
|
+
return (True, True)
|
|
193
|
+
else:
|
|
194
|
+
return (False, False) # Cancelled
|
|
195
|
+
|
|
196
|
+
# removes links from restore path that point to the repo
|
|
197
|
+
def clean(self, files):
|
|
198
|
+
fops = FileOps(self.repo)
|
|
199
|
+
|
|
200
|
+
for path in files:
|
|
201
|
+
categories = files[path]
|
|
202
|
+
master = min(categories)
|
|
203
|
+
repo_path = os.path.join(self.repo, master, path)
|
|
204
|
+
|
|
205
|
+
restore_path = os.path.join(self.restore_path, path)
|
|
206
|
+
|
|
207
|
+
if os.path.exists(repo_path) and os.path.exists(restore_path):
|
|
208
|
+
if self.plugin.samefile(repo_path, restore_path):
|
|
209
|
+
fops.remove(restore_path)
|
|
210
|
+
|
|
211
|
+
return fops
|
|
212
|
+
|
|
213
|
+
# will go through the repo and search for files that should no longer be
|
|
214
|
+
# there. accepts a list of filenames that are allowed
|
|
215
|
+
def clean_repo(self, filenames):
|
|
216
|
+
fops = FileOps(self.repo)
|
|
217
|
+
|
|
218
|
+
if not os.path.isdir(self.repo):
|
|
219
|
+
return fops
|
|
220
|
+
|
|
221
|
+
# System files to ignore (e.g., macOS .DS_Store, Windows Thumbs.db)
|
|
222
|
+
SYSTEM_FILES = {'.DS_Store', 'Thumbs.db', '.DS_Store?'}
|
|
223
|
+
|
|
224
|
+
for category in os.listdir(self.repo):
|
|
225
|
+
# Skip system files
|
|
226
|
+
if category in SYSTEM_FILES:
|
|
227
|
+
continue
|
|
228
|
+
|
|
229
|
+
category_path = os.path.join(self.repo, category)
|
|
230
|
+
|
|
231
|
+
# Skip if not a directory (shouldn't happen, but be safe)
|
|
232
|
+
if not os.path.isdir(category_path):
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
# remove empty category folders
|
|
236
|
+
try:
|
|
237
|
+
if not os.listdir(category_path):
|
|
238
|
+
logging.info(f'{category} is empty, removing')
|
|
239
|
+
fops.remove(category)
|
|
240
|
+
continue
|
|
241
|
+
except (OSError, NotADirectoryError):
|
|
242
|
+
# If listdir fails, skip this entry
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
for root, dirs, files in os.walk(category_path):
|
|
246
|
+
# Filter out system files from directories list
|
|
247
|
+
dirs[:] = [d for d in dirs if d not in SYSTEM_FILES]
|
|
248
|
+
|
|
249
|
+
# remove empty directories
|
|
250
|
+
for dname in dirs:
|
|
251
|
+
dname = os.path.join(root, dname)
|
|
252
|
+
if os.path.isdir(dname):
|
|
253
|
+
try:
|
|
254
|
+
if not os.listdir(dname):
|
|
255
|
+
dname = os.path.relpath(dname, self.repo)
|
|
256
|
+
logging.info(f'{dname} is empty, removing')
|
|
257
|
+
fops.remove(dname)
|
|
258
|
+
except (OSError, NotADirectoryError):
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# remove files that are not in the manifest
|
|
262
|
+
for fname in files:
|
|
263
|
+
# Skip system files
|
|
264
|
+
if fname in SYSTEM_FILES:
|
|
265
|
+
continue
|
|
266
|
+
fname = os.path.relpath(os.path.join(root, fname),
|
|
267
|
+
self.repo)
|
|
268
|
+
if fname not in filenames:
|
|
269
|
+
logging.info(f'{fname} is not in the manifest, '
|
|
270
|
+
'removing')
|
|
271
|
+
fops.remove(fname)
|
|
272
|
+
|
|
273
|
+
return fops
|
|
274
|
+
|
|
275
|
+
# goes through the filelist and finds files that have modifications that
|
|
276
|
+
# are not yet in the repo e.g. changes to encrypted files. This should not
|
|
277
|
+
# be used for any calculations, only for informational purposes
|
|
278
|
+
def diff(self, categories):
|
|
279
|
+
diffs = []
|
|
280
|
+
for category in categories:
|
|
281
|
+
category_path = os.path.join(self.repo, category)
|
|
282
|
+
|
|
283
|
+
for root, dirs, files in os.walk(category_path):
|
|
284
|
+
for fname in files:
|
|
285
|
+
fname = os.path.join(root, fname)
|
|
286
|
+
fname = os.path.relpath(fname, category_path)
|
|
287
|
+
|
|
288
|
+
restore_file = os.path.join(self.restore_path, fname)
|
|
289
|
+
category_file = os.path.join(category_path, fname)
|
|
290
|
+
|
|
291
|
+
if not os.path.exists(restore_file):
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
logging.debug(f'checking diff samefile for {restore_file}')
|
|
295
|
+
if not self.plugin.samefile(category_file, restore_file):
|
|
296
|
+
diffs.append(f'modified {restore_file}')
|
|
297
|
+
|
|
298
|
+
return diffs
|
dotsync/checks.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def safety_checks(dir_name, home, init):
|
|
7
|
+
# check that we're not in the user's home folder
|
|
8
|
+
if dir_name == home:
|
|
9
|
+
logging.error('dotsync should not be run inside home folder')
|
|
10
|
+
return False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
subprocess.run(['git', '--version'], check=True,
|
|
14
|
+
stdout=subprocess.PIPE)
|
|
15
|
+
except FileNotFoundError:
|
|
16
|
+
logging.error('"git" command not found in path, needed for proper '
|
|
17
|
+
'dotsync operation')
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
if init:
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
if os.path.isfile(os.path.join(dir_name, 'cryptlist')):
|
|
24
|
+
logging.error('this appears to be an old dotsync repo, please check '
|
|
25
|
+
'the documentation for '
|
|
26
|
+
'instructions on how to migrate your repo to the new '
|
|
27
|
+
'version of dotsync or use the old version of dotsync by '
|
|
28
|
+
'rather running "dotsync.sh"')
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
if not os.path.isdir(os.path.join(dir_name, '.git')):
|
|
32
|
+
logging.error('this does not appear to be a git repo, make sure to '
|
|
33
|
+
'init the repo before running dotsync in this folder')
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
for flist in ['filelist']:
|
|
37
|
+
if not os.path.isfile(os.path.join(dir_name, flist)):
|
|
38
|
+
logging.error(f'unable to locate {flist} in repo')
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
return True
|
dotsync/enums.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Actions(enum.Enum):
|
|
5
|
+
"""Actions ordered by typical usage lifecycle"""
|
|
6
|
+
INIT = 'init' # Initialize dotsync repository (first-time setup)
|
|
7
|
+
ADD = 'add' # Add new config file to filelist
|
|
8
|
+
ENCRYPT = 'encrypt' # Convert existing plain config to encrypted
|
|
9
|
+
UNMANAGE = 'unmanage' # Restore file to home and stop managing it
|
|
10
|
+
LIST = 'list' # List all managed configuration files
|
|
11
|
+
UPDATE = 'update' # Sync config files from home to repository
|
|
12
|
+
RESTORE = 'restore' # Restore config files from repository to home
|
|
13
|
+
DIFF = 'diff' # Show differences between home and repository
|
|
14
|
+
COMMIT = 'commit' # Commit changes to git and optionally push
|
|
15
|
+
CLEAN = 'clean' # Remove files from repository that are no longer managed
|
|
16
|
+
PASSWD = 'passwd' # Change encryption password for encrypted files
|
dotsync/file_ops.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
import enum
|
|
4
|
+
import shutil
|
|
5
|
+
import inspect
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Op(enum.Enum):
|
|
9
|
+
LINK = enum.auto()
|
|
10
|
+
COPY = enum.auto()
|
|
11
|
+
MOVE = enum.auto()
|
|
12
|
+
REMOVE = enum.auto()
|
|
13
|
+
MKDIR = enum.auto()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FileOps:
|
|
17
|
+
def __init__(self, wd):
|
|
18
|
+
self.wd = wd
|
|
19
|
+
self.ops = []
|
|
20
|
+
|
|
21
|
+
def clear(self):
|
|
22
|
+
self.ops = []
|
|
23
|
+
|
|
24
|
+
def check_path(self, path):
|
|
25
|
+
return path if os.path.isabs(path) else os.path.join(self.wd, path)
|
|
26
|
+
|
|
27
|
+
def check_dest_dir(self, path):
|
|
28
|
+
dirname = os.path.dirname(path)
|
|
29
|
+
if not os.path.isdir(self.check_path(dirname)):
|
|
30
|
+
self.mkdir(dirname)
|
|
31
|
+
|
|
32
|
+
def mkdir(self, path):
|
|
33
|
+
logging.debug(f'adding mkdir op for {path}')
|
|
34
|
+
self.ops.append((Op.MKDIR, path))
|
|
35
|
+
|
|
36
|
+
def copy(self, source, dest):
|
|
37
|
+
logging.debug(f'adding cp op for {source} -> {dest}')
|
|
38
|
+
self.check_dest_dir(dest)
|
|
39
|
+
self.ops.append((Op.COPY, (source, dest)))
|
|
40
|
+
|
|
41
|
+
def move(self, source, dest):
|
|
42
|
+
logging.debug(f'adding mv op for {source} -> {dest}')
|
|
43
|
+
self.check_dest_dir(dest)
|
|
44
|
+
self.ops.append((Op.MOVE, (source, dest)))
|
|
45
|
+
|
|
46
|
+
def link(self, source, dest):
|
|
47
|
+
logging.debug(f'adding ln op for {source} <- {dest}')
|
|
48
|
+
self.check_dest_dir(dest)
|
|
49
|
+
self.ops.append((Op.LINK, (source, dest)))
|
|
50
|
+
|
|
51
|
+
def remove(self, path):
|
|
52
|
+
logging.debug(f'adding rm op for {path}')
|
|
53
|
+
self.ops.append((Op.REMOVE, path))
|
|
54
|
+
|
|
55
|
+
def plugin(self, plugin, source, dest):
|
|
56
|
+
logging.debug(f'adding plugin op ({plugin.__qualname__}) for {source} '
|
|
57
|
+
f'-> {dest}')
|
|
58
|
+
self.check_dest_dir(dest)
|
|
59
|
+
self.ops.append((plugin, (source, dest)))
|
|
60
|
+
|
|
61
|
+
def apply(self, dry_run=False):
|
|
62
|
+
for op in self.ops:
|
|
63
|
+
op, path = op
|
|
64
|
+
|
|
65
|
+
if type(path) is tuple:
|
|
66
|
+
src, dest = path
|
|
67
|
+
src, dest = self.check_path(src), self.check_path(dest)
|
|
68
|
+
logging.info(self.str_op(op, (src, dest)))
|
|
69
|
+
else:
|
|
70
|
+
path = self.check_path(path)
|
|
71
|
+
logging.info(self.str_op(op, path))
|
|
72
|
+
|
|
73
|
+
if dry_run:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if op == Op.LINK:
|
|
77
|
+
src = os.path.relpath(src, os.path.join(self.wd,
|
|
78
|
+
os.path.dirname(dest)))
|
|
79
|
+
os.symlink(src, dest)
|
|
80
|
+
elif op == Op.COPY:
|
|
81
|
+
shutil.copyfile(src, dest)
|
|
82
|
+
elif op == Op.MOVE:
|
|
83
|
+
os.rename(src, dest)
|
|
84
|
+
elif op == Op.REMOVE:
|
|
85
|
+
if os.path.isdir(path):
|
|
86
|
+
shutil.rmtree(path)
|
|
87
|
+
else:
|
|
88
|
+
os.remove(path)
|
|
89
|
+
elif op == Op.MKDIR:
|
|
90
|
+
if not os.path.isdir(path):
|
|
91
|
+
os.makedirs(path)
|
|
92
|
+
elif callable(op):
|
|
93
|
+
op(src, dest)
|
|
94
|
+
|
|
95
|
+
self.clear()
|
|
96
|
+
|
|
97
|
+
def append(self, other):
|
|
98
|
+
self.ops += other.ops
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def str_op(self, op, path):
|
|
102
|
+
def strip_wd(p):
|
|
103
|
+
p = str(p)
|
|
104
|
+
wd = str(self.wd)
|
|
105
|
+
return p[len(wd) + 1:] if p.startswith(wd) else p
|
|
106
|
+
|
|
107
|
+
if type(op) is Op:
|
|
108
|
+
op = op.name
|
|
109
|
+
else:
|
|
110
|
+
op = dict(inspect.getmembers(op))['__self__'].strify(op)
|
|
111
|
+
|
|
112
|
+
if type(path) is tuple:
|
|
113
|
+
path = [strip_wd(p) for p in path]
|
|
114
|
+
return f'{op} "{path[0]}" -> "{path[1]}"'
|
|
115
|
+
else:
|
|
116
|
+
return f'{op} "{strip_wd(path)}"'
|
|
117
|
+
|
|
118
|
+
def __str__(self):
|
|
119
|
+
return '\n'.join(self.str_op(*op) for op in self.ops)
|
|
120
|
+
|
|
121
|
+
def __repr__(self):
|
|
122
|
+
return str(self.ops)
|