-
Notifications
You must be signed in to change notification settings - Fork 0
/
sym
executable file
·182 lines (163 loc) · 6.49 KB
/
sym
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
#!/usr/bin/env python3
import os
import os.path as path
import sys
import argparse
from fnmatch import fnmatchcase
def log(msg, v):
if not v:
return
print(msg)
def walk(start, ignore_patterns, verbose=False):
with os.scandir(start) as it:
for entry in it:
fp = entry.path
if any(fnmatchcase(fp, glob) for glob in ignore_patterns):
log(f"IGNORE: {fp}", verbose)
continue
if entry.is_dir():
yield from walk(fp, ignore_patterns, verbose)
elif entry.is_file(follow_symlinks=False):
yield fp
parser = argparse.ArgumentParser()
parser.add_argument(
"source",
type=str,
nargs="+",
help="source directory containing files to be symlinked",
)
parser.add_argument(
"--exclude",
dest="ignore_patterns",
type=str,
action="append",
help="exclude pattern (case-sensitive shell-style wildcard), refer to python's fnmatch module",
)
parser.add_argument(
"-t",
"--target",
metavar="TARGET",
type=str,
help="target directory to create symlinks in (default: $HOME)",
default=path.expanduser("~"),
)
parser.add_argument(
"-d", "--delete", action="store_true", help="remove symlinks instead of create"
)
parser.add_argument(
"--dry-run",
action="store_true",
help="implies -v, but does not perform any actual operations.",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="print symlinks as they are created or removed",
)
args = parser.parse_args()
source_dirs = []
for source_dir in args.source:
if not path.isdir(source_dir):
sys.exit(f"source directory {source_dir} is not a directory or does not exist")
if not source_dir.endswith("/"):
source_dir += "/"
source_dirs.append(source_dir)
if not path.isdir(args.target):
sys.exit(f"target directory {args.target} is not a directory or does not exist")
target_dir = args.target
verbose = args.verbose or args.dry_run
dry_run = args.dry_run
delete_mode = args.delete
ignore_patterns = tuple(args.ignore_patterns or ())
# pass #1: collect symlink jobs from all sources and detect conflicts
symlink_names, symlink_dests, conflicts = [], [], []
for source_dir in source_dirs:
for filepath in walk(source_dir, ignore_patterns, verbose):
# say we're given dotfiles/mpv as a source directory, and it contains .config/mpv/mpv.conf
# say we have a target dir of ~, and dotfiles/mpv full path is ~/somewhere/dotfiles/mpv
# (the $PWD is ~/somewhere)
# we want to create the relative symlink at (the name/path) ~/.config/mpv/mpv.conf,
# pointing to ../../../somewhere/dotfiles/.config/mpv/mpv.conf
# the symlink name is target dir + filepath without the parent source_dir
# it is guaranteed that the filepath has source_dir at the beginning,
# so removing that # chars from the beginning should be correct.
name = path.join(target_dir, filepath[len(source_dir) :])
if name in symlink_names:
conflicts.append(
f"sym is trying to create more than one {name}, refusing to create duplicate symlinks."
)
continue
# the symlink dest is filepath, relative to the name's directory.
# conversion to absolute paths is necessary for relpath to work here.
dest_abspath = path.abspath(filepath)
dest = path.relpath(dest_abspath, path.dirname(path.abspath(name)))
exists = path.exists(name)
islink = path.islink(name)
realpath = path.realpath(name)
if not delete_mode:
# if the symlink we are trying to create exists...
if exists:
# ...but is a symlink to the valid file we are trying to symlink, ignore it.
if islink and (realpath == dest_abspath):
continue
# otherwise, conflict.
conflicts.append(
f"{name} already exists. sym cannot create symlinks if there is an existing file."
)
continue
else:
# if the symlink doesn't exist, we obviously don't need to try and delete it.
if not exists:
continue
# but if it does, there is potential for conflicts (deleting something we shouldn't be deleting)
if not islink:
conflicts.append(f"{name} is not a symlink, so refusing to remove.")
continue
if os.readlink(name)[0] == "/":
conflicts.append(
f"{name} is an absolute symlink. sym only creates relative symlinks, so refusing to remove."
)
continue
if realpath != dest_abspath:
conflicts.append(
f"{name} is a relative symlink, but resolves to {realpath} instead of the expected {dest_abspath}, so refusing to remove."
)
continue
symlink_names.append(name)
symlink_dests.append(dest)
for c in conflicts:
print("CONFLICT:", c, file=sys.stderr)
if conflicts and not dry_run:
sys.exit("sym will not start until all conflicts are resolved.")
if dry_run:
# XXX: this doesn't include RMDIR because that depends on actual filesystem operations at the moment
# and dry-run code to autodetect those operations isn't worth the added complexity right now
print("dry-run; the following operations are what would have been executed.")
# pass #2: perform operations
if not delete_mode:
for name, dest in zip(symlink_names, symlink_dests):
# ensure accomodating directory exists for the new symlink
name_dir = path.dirname(name)
if not path.isdir(name_dir):
log(f"MKDIRS: {name_dir}", verbose)
if not dry_run:
os.makedirs(name_dir, exist_ok=True)
# create the symlink
log(f"LINK: {name} -> {dest}", verbose)
if not dry_run:
os.symlink(dest, name)
else:
for name in symlink_names:
# remove the symlink
log(f"UNLINK: {name}", verbose)
if not dry_run:
os.unlink(name)
# cleanup as many empty parent dirs as possible
name_dir = path.dirname(name)
while not os.listdir(name_dir):
log(f"RMDIR: {name_dir}", verbose)
if not dry_run:
os.rmdir(name_dir)
# this is safe because this is a calculation; not dependent on filesystem state
name_dir = path.dirname(name_dir)