-
Notifications
You must be signed in to change notification settings - Fork 0
/
dotfile-link
executable file
·151 lines (115 loc) · 4.3 KB
/
dotfile-link
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
#!/usr/bin/env python3.12
from collections.abc import Iterable
from dataclasses import asdict, dataclass
from os import environ
from pathlib import Path
from sys import stderr
import tomllib
TOOL = 'dotfiles'
DEFAULT_CONFIG=f"""\
ignore = ["/{TOOL}.toml", "/.*"]
autodot = true
repository = "~/.local/share/{TOOL}"
target = "~"
"""
@dataclass
class Config:
ignore: list[str]
autodot: bool
repository: Path
target: Path
@classmethod
def from_kw(cls, **kw):
for key in 'repository', 'target':
if key in kw:
kw[key] = Path(kw[key]).expanduser()
return cls(**kw)
def eprint(msg):
print(msg, file=stderr)
def eexit(msg):
eprint(msg)
sys.exit(1)
def load_config(path: Path) -> dict:
"Load a toml config from file *path*"
try:
src = path.read_text()
except FileNotFoundError:
return {}
except Exception as ex:
eexit(f"{file}: {ex}")
try:
config = tomllib.loads(src)
except tomllib.TOMLDecodeError as ex:
eexit(f"{path}: {ex}")
return config
def user_config() -> Config:
"Return usable config from XDG_CONFIG_DIR or defaults"
# start with base config
base = tomllib.loads(DEFAULT_CONFIG)
path = Path(environ.get('XDG_CONFIG_DIR', '~/.config')).expanduser()
base.update(load_config(path / 'config.toml'))
return Config.from_kw(**base)
def dir_config(path: Path, base=(), file=f"{TOOL}.toml") -> Config:
"Return usable config from a directory *path*"
base = asdict(base) if base else ()
return Config.from_kw(**dict(base, **load_config(path / file)))
def folder_contents(folder: Path, config: Config) -> Iterable[Path, Path]:
ignored = set()
for ignore in config.ignore:
ignored.update(folder.glob(ignore.lstrip('/')))
for dirpath, dirnames, filenames in Path.walk(folder):
if dirpath in ignored:
dirnames.clear() # stop walk() from recursing
continue
for filepath in (dirpath / fn for fn in filenames):
if filepath in ignored:
continue
yield filepath.relative_to(folder), filepath
def target_folder_contents(folder_contents: Iterable[Path, Path],
target: Path,
config: Config) -> Iterable[Path, Path, Path]:
for relative, source in folder_contents:
if not config.autodot or relative.parts[0].startswith('.'):
yield relative, source, target / relative
else:
dotted = Path(f".{relative.parts[0]}", *relative.parts[1:])
yield relative, source, target / dotted
def folder_mapping(folder: Path, config: Config) -> Iterable[Path, Path, Path]:
"Return all non-ignored *folder* files mapped to destinations."
target = config.target
folder_config = dir_config(folder, base=config)
sources = folder_contents(folder, folder_config)
return target_folder_contents(sources, target, folder_config)
def process_repository(config: Config):
noop = new = error = newdir = 0
folders = set()
for d in config.repository.iterdir():
if d.is_dir() and not d.name.startswith('.'):
folders.add(d)
for folder in sorted(folders):
msg = lambda m: eprint(f"{folder.name}: {m}")
mapping = folder_mapping(folder, config)
for _, source, destination in mapping:
relative = destination.relative_to(config.target)
if destination.is_symlink():
if destination.resolve() != source:
msg(f"skipping {relative}: file is symlink pointing to {destination}")
error +=1
else:
noop += 1
continue
elif destination.exists():
msg(f"skipping {relative}: a non-managed file is in the way")
error +=1
continue
if not destination.parent.exists():
destination.parent.mkdir(parents=True)
newdir += 1
msg(f"linking {relative}")
destination.symlink_to(source)
new += 1
print(f"{noop} unchanged, {new} new links, "
f"{newdir} directories created, {error} errors.")
if __name__ == '__main__':
config = user_config()
process_repository(config)