dotlayer 0.2.2 → 0.3.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +55 -8
- data/lib/dotlayer/cli.rb +7 -3
- data/lib/dotlayer/commands/diff.rb +108 -0
- data/lib/dotlayer/commands/doctor.rb +101 -15
- data/lib/dotlayer/commands/install.rb +21 -13
- data/lib/dotlayer/commands/status.rb +1 -0
- data/lib/dotlayer/commands/update.rb +9 -2
- data/lib/dotlayer/config.rb +12 -2
- data/lib/dotlayer/detector.rb +30 -3
- data/lib/dotlayer/output.rb +4 -4
- data/lib/dotlayer/resolver.rb +55 -13
- data/lib/dotlayer/stow.rb +7 -6
- data/lib/dotlayer.rb +2 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: adde5780e85065e220d516e6f2238a63122c5a4151aea1bee55e98e272c04750
|
|
4
|
+
data.tar.gz: 693c72cb55917e957677c70b43114a6fe61968289f766662fb1198e96bcb283d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fdba914ec98999e747f9eb2b4dc547e60887206ef51b60415d412882b6be1f21ba854418f306f82f80cb1970bd83cba67d13f048235162d5140abb6903082857
|
|
7
|
+
data.tar.gz: 7692ffeb12541dcbffe01c9b3e42e9f7ff1dd39774cf0a49dc23025554c516be1be179b8c7fb745c8f316726638be785056283f658f32452f79ef497e35f8a68
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to dotlayer. Versions follow [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## 0.3.1 — 2026-11-05
|
|
6
|
+
|
|
7
|
+
First RubyGems release since 0.2.2: it includes everything listed under 0.3.0,
|
|
8
|
+
which was tagged but never published.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- `doctor`'s dangling-link scan skips other git checkouts under the target
|
|
13
|
+
(any directory with a `.git`, like `~/src/*`), unless a package ships files
|
|
14
|
+
into that path. Stow only links into a checkout in that case, and checkouts
|
|
15
|
+
are most of the directories in a typical home dir: `doctor` went from 5.9s to
|
|
16
|
+
0.9s on a development machine.
|
|
17
|
+
|
|
18
|
+
## 0.3.0 — 2026-11-04 (tagged, not published)
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Machine layers: `config-<machine>`, `config-<os>-<machine>`,
|
|
23
|
+
`config-<distro>-<machine>` and `config-<distro>-<profile>-<machine>`. The
|
|
24
|
+
machine tag comes from `$DOTLAYER_MACHINE`, then `machines.<tag>.detect`
|
|
25
|
+
commands in `dotlayer.yml`, then `hostname -s`.
|
|
26
|
+
- `dotlayer diff`: shows what `install` would link, unlink or conflict on, and
|
|
27
|
+
which system files would change, without changing anything.
|
|
28
|
+
- `doctor` scans the whole target dir for links into a configured repo whose
|
|
29
|
+
destination no longer exists, and prints an `rm` command for them. It never
|
|
30
|
+
deletes anything. `--depth N` limits the scan (default 6).
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Stow runs with `--no-folding`: every file gets its own link, never a link to
|
|
35
|
+
a whole directory.
|
|
36
|
+
- `doctor`'s broken-symlink check covers only managed `.config` and `.local`
|
|
37
|
+
paths, and the dangling-link scan skips `.local/state` (app backups).
|
|
38
|
+
- System files: parent directories are created when missing, and a failed copy
|
|
39
|
+
fails the install.
|
|
40
|
+
- Requires Ruby 3.3 or newer; CI tests Ruby 3.3 through 4.0 and installs Stow.
|
|
41
|
+
|
|
42
|
+
### Fixed
|
|
43
|
+
|
|
44
|
+
- Profile detection no longer crashes on macOS, where `hostnamectl` doesn't
|
|
45
|
+
exist.
|
|
46
|
+
- `diff` conflict handling works with older Stow versions.
|
|
47
|
+
|
|
48
|
+
## 0.2.2 and earlier
|
|
49
|
+
|
|
50
|
+
See the [git tags](https://github.com/douglas/dotlayer/tags).
|
data/README.md
CHANGED
|
@@ -13,6 +13,7 @@ config-macos/ → stowed on macOS
|
|
|
13
13
|
config-omarchy/ → stowed when Omarchy is detected
|
|
14
14
|
config-omarchy-desktop/ → stowed on Omarchy + desktop profile
|
|
15
15
|
config-omarchy-laptop/ → stowed on Omarchy + laptop profile
|
|
16
|
+
config-omarchy-laptop-t14/ → stowed on Omarchy + laptop profile + t14 machine
|
|
16
17
|
config-mycompany/ → stowed when mycompany group is detected
|
|
17
18
|
```
|
|
18
19
|
|
|
@@ -31,7 +32,7 @@ git clone https://github.com/douglas/dotlayer.git
|
|
|
31
32
|
export PATH="$HOME/src/dotlayer/exe:$PATH"
|
|
32
33
|
```
|
|
33
34
|
|
|
34
|
-
**Requirements:** Ruby >= 3.
|
|
35
|
+
**Requirements:** Ruby >= 3.3, GNU Stow
|
|
35
36
|
|
|
36
37
|
## Quick start
|
|
37
38
|
|
|
@@ -42,10 +43,13 @@ dotlayer status
|
|
|
42
43
|
# Stow all matching packages
|
|
43
44
|
dotlayer install
|
|
44
45
|
|
|
46
|
+
# Preview what install would change (changes nothing)
|
|
47
|
+
dotlayer diff
|
|
48
|
+
|
|
45
49
|
# Pull latest changes and re-stow
|
|
46
50
|
dotlayer update
|
|
47
51
|
|
|
48
|
-
# Check for broken symlinks and missing deps
|
|
52
|
+
# Check for broken or dangling symlinks and missing deps
|
|
49
53
|
dotlayer doctor
|
|
50
54
|
|
|
51
55
|
# Move existing config into a stow package
|
|
@@ -57,12 +61,13 @@ dotlayer adopt --private ~/.config/lazysql config
|
|
|
57
61
|
|
|
58
62
|
## How it works
|
|
59
63
|
|
|
60
|
-
Dotlayer auto-detects
|
|
64
|
+
Dotlayer auto-detects five things about your system:
|
|
61
65
|
|
|
62
66
|
| Detection | Method | Example |
|
|
63
67
|
|-----------|--------|---------|
|
|
64
68
|
| **OS** | `RbConfig::CONFIG["host_os"]` | `linux`, `macos` |
|
|
65
69
|
| **Profile** | `hostnamectl chassis` or `$DOTLAYER_PROFILE` | `desktop`, `laptop` |
|
|
70
|
+
| **Machine** | `$DOTLAYER_MACHINE`, configured shell commands, then `hostname -s` fallback | `t14`, `framework-desktop` |
|
|
66
71
|
| **Distro** | Shell commands from config | `omarchy`, `fedora` |
|
|
67
72
|
| **Group** | Shell commands from config | `work`, `mycompany` |
|
|
68
73
|
|
|
@@ -72,7 +77,11 @@ It then scans your dotfiles repo for directories matching these tags and stows t
|
|
|
72
77
|
2. **OS layer** — `config-linux` or `config-macos`
|
|
73
78
|
3. **Distro layer** — `config-omarchy`
|
|
74
79
|
4. **Distro + profile** — `config-omarchy-desktop`
|
|
75
|
-
5. **
|
|
80
|
+
5. **Machine layer** — `config-t14`
|
|
81
|
+
6. **OS + machine** — `config-linux-t14`
|
|
82
|
+
7. **Distro + machine** — `config-omarchy-t14`
|
|
83
|
+
8. **Distro + profile + machine** — `config-omarchy-laptop-t14`
|
|
84
|
+
9. **Group layer** — `config-mycompany`
|
|
76
85
|
|
|
77
86
|
Each layer can add files but never conflict with earlier layers (Stow constraint).
|
|
78
87
|
|
|
@@ -99,6 +108,11 @@ profiles:
|
|
|
99
108
|
detect: hostnamectl chassis
|
|
100
109
|
env: DOTLAYER_PROFILE
|
|
101
110
|
|
|
111
|
+
machines:
|
|
112
|
+
env: DOTLAYER_MACHINE
|
|
113
|
+
t14:
|
|
114
|
+
detect: model="$(cat /sys/class/dmi/id/product_name /sys/class/dmi/id/product_version 2>/dev/null)"; case "$model" in *T14*) exit 0 ;; *) exit 1 ;; esac
|
|
115
|
+
|
|
102
116
|
distros:
|
|
103
117
|
omarchy:
|
|
104
118
|
detect: test -d ~/.local/share/omarchy
|
|
@@ -162,9 +176,18 @@ repos:
|
|
|
162
176
|
packages:
|
|
163
177
|
- config
|
|
164
178
|
- fonts
|
|
179
|
+
standalone_packages:
|
|
180
|
+
- claude
|
|
181
|
+
- shared
|
|
182
|
+
group_packages:
|
|
183
|
+
mycompany:
|
|
184
|
+
- work
|
|
165
185
|
```
|
|
166
186
|
|
|
167
187
|
The `private: true` flag tells dotlayer which repo to use when you run `dotlayer adopt --private`.
|
|
188
|
+
`standalone_packages` is an allowlist for non-layered packages in that repo.
|
|
189
|
+
`group_packages` lets a group detection rule opt in exact package names like `work`
|
|
190
|
+
that do not follow the `config-mycompany` suffix convention.
|
|
168
191
|
|
|
169
192
|
### What goes where?
|
|
170
193
|
|
|
@@ -176,6 +199,8 @@ The `private: true` flag tells dotlayer which repo to use when you run `dotlayer
|
|
|
176
199
|
config-linux/ ← Linux-specific overrides
|
|
177
200
|
config-omarchy/ ← Omarchy distro overrides
|
|
178
201
|
config-omarchy-desktop/ ← Omarchy + desktop profile
|
|
202
|
+
config-omarchy-laptop/ ← Omarchy + laptop profile
|
|
203
|
+
config-omarchy-laptop-t14/ ← Omarchy + laptop profile + machine model
|
|
179
204
|
git/ ← git config
|
|
180
205
|
zsh/ ← shell config
|
|
181
206
|
bin/ ← personal scripts
|
|
@@ -191,11 +216,14 @@ The `private: true` flag tells dotlayer which repo to use when you run `dotlayer
|
|
|
191
216
|
fonts/ ← licensed fonts
|
|
192
217
|
fonts-linux/ ← Linux-specific font config
|
|
193
218
|
claude/ ← standalone: Claude AI config
|
|
194
|
-
work/ ←
|
|
219
|
+
work/ ← group package: work tooling
|
|
195
220
|
scripts/ ← standalone: work automation scripts
|
|
196
221
|
```
|
|
197
222
|
|
|
198
|
-
Directories that match the per-repo base packages (`config`, `fonts`) get full layer resolution.
|
|
223
|
+
Directories that match the per-repo base packages (`config`, `fonts`) get full layer resolution. If `standalone_packages`
|
|
224
|
+
is omitted, every other top-level directory (`claude`, `scripts`) is stowed as-is in alphabetical order. If it is present,
|
|
225
|
+
only those allowlisted standalone packages are stowed. Exact work packages can be gated behind detected groups with
|
|
226
|
+
`group_packages`.
|
|
199
227
|
|
|
200
228
|
### Adopting files into the right repo
|
|
201
229
|
|
|
@@ -216,6 +244,7 @@ Repos are processed in order. Public packages are stowed first, then private pac
|
|
|
216
244
|
3. `~/.public_dotfiles/config-omarchy/` — distro layer
|
|
217
245
|
4. `~/.private_dotfiles/config/` — private overlays (stowed after public)
|
|
218
246
|
5. `~/.private_dotfiles/config-mycompany/` — work group layer
|
|
247
|
+
6. `~/.private_dotfiles/work/` — exact group package, if configured
|
|
219
248
|
|
|
220
249
|
Each layer adds files — they never conflict because different layers use different file paths within the same directory structure.
|
|
221
250
|
|
|
@@ -233,19 +262,37 @@ dotlayer [options] <command>
|
|
|
233
262
|
|
|
234
263
|
Commands:
|
|
235
264
|
install Detect system, stow all layers, install system files
|
|
265
|
+
diff Show what install would change, without changing anything
|
|
236
266
|
update Pull repos, re-stow all layers
|
|
237
267
|
status Show detected OS, profile, distros, groups, and packages
|
|
238
|
-
doctor Check for broken symlinks, missing repos, missing stow
|
|
268
|
+
doctor Check for broken/dangling symlinks, missing repos, missing stow
|
|
239
269
|
adopt Move config files into a stow package and restow
|
|
240
270
|
version Print version
|
|
241
271
|
|
|
242
272
|
Options:
|
|
243
273
|
-c, --config PATH Config file path
|
|
244
|
-
-n, --dry-run
|
|
274
|
+
-n, --dry-run Run stow in no-op mode to preview changes and conflicts
|
|
245
275
|
-p, --private Use private repo (for adopt command)
|
|
276
|
+
--depth N Max depth for doctor's dangling-link scan (default: 6)
|
|
246
277
|
-v, --verbose Verbose output
|
|
247
278
|
```
|
|
248
279
|
|
|
280
|
+
### diff
|
|
281
|
+
|
|
282
|
+
Shows what `dotlayer install` would change, without changing anything. Packages are resolved exactly like `install`, then stow runs in simulate mode (`stow -n -v`). Output is grouped per package: `LINK` and `UNLINK` operations plus `CONFLICT` entries (stow aborts the whole package on conflicts). Links that already exist are not listed. Linux `system_files` are listed when the destination is missing, its content differs from the source, its mode differs, or it cannot be read (no sudo is used).
|
|
283
|
+
|
|
284
|
+
```sh
|
|
285
|
+
dotlayer diff
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Exit status: `0` when nothing would change, `1` when changes are pending (so it can be used in scripts).
|
|
289
|
+
|
|
290
|
+
### doctor
|
|
291
|
+
|
|
292
|
+
Checks that stow is installed, repos and packages exist, and that managed config paths have no broken symlinks. It also scans the whole target dir (default `~`) for symlinks that point into a configured repo but whose target no longer exists. The scan skips `.cache`, `node_modules`, `.git`, `.local/share/Steam` and the repos themselves. It also skips other git checkouts (any directory with a `.git`, like `~/src/*`), unless a package ships files into that path, since stow only links there in that case. It never follows directory symlinks, and stops at `--depth` levels (default 6). It only reports: each dangling link is listed with its target, followed by a suggested `rm` command.
|
|
293
|
+
|
|
294
|
+
Exit status: `0` when no issues are found, `1` otherwise.
|
|
295
|
+
|
|
249
296
|
### adopt
|
|
250
297
|
|
|
251
298
|
Moves existing config files or directories into a stow package, then restows so the originals become symlinks managed by stow.
|
data/lib/dotlayer/cli.rb
CHANGED
|
@@ -16,7 +16,8 @@ module Dotlayer
|
|
|
16
16
|
when "status" then Commands::Status.new(config:).run
|
|
17
17
|
when "install" then Commands::Install.new(config:, dry_run: options[:dry_run], verbose: options[:verbose]).run
|
|
18
18
|
when "update" then Commands::Update.new(config:, dry_run: options[:dry_run], verbose: options[:verbose]).run
|
|
19
|
-
when "
|
|
19
|
+
when "diff" then exit(1) unless Commands::Diff.new(config:).run
|
|
20
|
+
when "doctor" then exit(1) unless Commands::Doctor.new(config:, max_depth: options[:depth]).run
|
|
20
21
|
when "adopt" then run_adopt(config:, argv:, options:)
|
|
21
22
|
when "version"
|
|
22
23
|
puts "dotlayer #{VERSION}"
|
|
@@ -28,13 +29,14 @@ module Dotlayer
|
|
|
28
29
|
private
|
|
29
30
|
|
|
30
31
|
def parse_global_options(argv)
|
|
31
|
-
options = {dry_run: false, verbose: false, config: nil, private: false}
|
|
32
|
+
options = {dry_run: false, verbose: false, config: nil, private: false, depth: nil}
|
|
32
33
|
|
|
33
34
|
OptionParser.new do |opts|
|
|
34
35
|
opts.on("-c", "--config PATH", "Config file path") { |v| options[:config] = v }
|
|
35
36
|
opts.on("-n", "--dry-run", "Show what would be done") { options[:dry_run] = true }
|
|
36
37
|
opts.on("-v", "--verbose", "Verbose output") { options[:verbose] = true }
|
|
37
38
|
opts.on("-p", "--private", "Use private repo") { options[:private] = true }
|
|
39
|
+
opts.on("--depth N", Integer, "Max depth for doctor's dangling-link scan") { |v| options[:depth] = v }
|
|
38
40
|
end.parse!(argv)
|
|
39
41
|
|
|
40
42
|
options
|
|
@@ -63,7 +65,8 @@ module Dotlayer
|
|
|
63
65
|
install Detect system, stow all layers, install system files
|
|
64
66
|
update Pull repos, re-stow all layers
|
|
65
67
|
status Show detected OS, profile, distros, and packages
|
|
66
|
-
|
|
68
|
+
diff Show what install would change (exit 1 if changes pending)
|
|
69
|
+
doctor Check for broken symlinks, conflicts, missing deps (exit 1 on issues)
|
|
67
70
|
adopt Move config into a stow package and restow
|
|
68
71
|
version Print version
|
|
69
72
|
|
|
@@ -71,6 +74,7 @@ module Dotlayer
|
|
|
71
74
|
-c, --config PATH Config file path (default: dotlayer.yml in repo)
|
|
72
75
|
-n, --dry-run Show what would be done without making changes
|
|
73
76
|
-p, --private Use private repo (for adopt command)
|
|
77
|
+
--depth N Max depth for doctor's dangling-link scan (default: 6)
|
|
74
78
|
-v, --verbose Verbose output
|
|
75
79
|
|
|
76
80
|
Examples:
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module Dotlayer
|
|
2
|
+
module Commands
|
|
3
|
+
# Read-only preview of what `install` would change. Runs stow in
|
|
4
|
+
# simulate mode (-n -v) and compares system files byte-for-byte.
|
|
5
|
+
# #run returns true when nothing is pending, false otherwise.
|
|
6
|
+
class Diff
|
|
7
|
+
include Output
|
|
8
|
+
|
|
9
|
+
LINK_LINE = /\A(LINK|UNLINK): (.+?)(?: => .*?)?( \(reverts previous action\))?\z/
|
|
10
|
+
CONFLICT_LINE = /\A\s+\* (.+)\z/
|
|
11
|
+
|
|
12
|
+
def initialize(config:, detector: nil)
|
|
13
|
+
@config = config
|
|
14
|
+
@detector = detector || Detector.new(config: @config)
|
|
15
|
+
@pending = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
detection = @detector.detect
|
|
20
|
+
packages = Resolver.new(config: @config, detection: detection).resolve
|
|
21
|
+
stow = Stow.new(target: @config.target, dry_run: true, verbose: true, quiet: true)
|
|
22
|
+
|
|
23
|
+
heading "Diff (#{detection.os}/#{detection.profile}/#{detection.machine})"
|
|
24
|
+
puts
|
|
25
|
+
|
|
26
|
+
packages.each { |repo_path, package| diff_package(stow, repo_path, package) }
|
|
27
|
+
diff_system_files if detection.os == "linux"
|
|
28
|
+
|
|
29
|
+
puts
|
|
30
|
+
if @pending.zero?
|
|
31
|
+
ok "No changes pending."
|
|
32
|
+
else
|
|
33
|
+
warning "#{@pending} change(s) pending."
|
|
34
|
+
end
|
|
35
|
+
@pending.zero?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def diff_package(stow, repo_path, package)
|
|
41
|
+
success = stow.restow(repo_path, package)
|
|
42
|
+
plan = parse_plan(stow.last_output.to_s)
|
|
43
|
+
problems = success ? [] : plan[:conflicts]
|
|
44
|
+
problems = [stow.last_error] if !success && problems.empty?
|
|
45
|
+
return if plan[:links].empty? && plan[:unlinks].empty? && problems.empty?
|
|
46
|
+
|
|
47
|
+
puts " #{green(package)} (#{File.basename(repo_path)})"
|
|
48
|
+
plan[:links].each { |path| puts " #{green("LINK")} #{path}" }
|
|
49
|
+
plan[:unlinks].each { |path| puts " #{yellow("UNLINK")} #{path}" }
|
|
50
|
+
problems.each { |problem| puts " #{red("CONFLICT")} #{problem}" }
|
|
51
|
+
@pending += plan[:links].size + plan[:unlinks].size + problems.size
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Restow shows UNLINK then LINK for links that already exist; stow marks
|
|
55
|
+
# the second as "(reverts previous action)", so both cancel out.
|
|
56
|
+
def parse_plan(output)
|
|
57
|
+
links = []
|
|
58
|
+
unlinks = []
|
|
59
|
+
conflicts = []
|
|
60
|
+
|
|
61
|
+
output.each_line(chomp: true) do |line|
|
|
62
|
+
if (match = LINK_LINE.match(line))
|
|
63
|
+
kind, path, reverts = match.captures
|
|
64
|
+
if reverts
|
|
65
|
+
(kind == "LINK") ? unlinks.delete(path) : links.delete(path)
|
|
66
|
+
else
|
|
67
|
+
((kind == "LINK") ? links : unlinks) << path
|
|
68
|
+
end
|
|
69
|
+
elsif (match = CONFLICT_LINE.match(line))
|
|
70
|
+
conflicts << match[1]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{links:, unlinks:, conflicts: conflicts.uniq}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def diff_system_files
|
|
78
|
+
entries = @config.system_files
|
|
79
|
+
first_repo = @config.repos.first
|
|
80
|
+
return if entries.empty? || first_repo.nil?
|
|
81
|
+
|
|
82
|
+
changed = entries.filter_map { |entry| system_file_change(entry, first_repo) }
|
|
83
|
+
return if changed.empty?
|
|
84
|
+
|
|
85
|
+
puts " #{green("system files")}"
|
|
86
|
+
changed.each { |dest, reason| puts " #{yellow("CHANGE")} #{dest} (#{reason})" }
|
|
87
|
+
@pending += changed.size
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def system_file_change(entry, repo)
|
|
91
|
+
source = File.expand_path(entry["source"], repo.path)
|
|
92
|
+
dest = entry["dest"]
|
|
93
|
+
mode = (entry["mode"] || "0644").to_s.to_i(8)
|
|
94
|
+
|
|
95
|
+
return [dest, "source missing: #{source}"] unless File.file?(source)
|
|
96
|
+
return [dest, "missing"] unless File.exist?(dest)
|
|
97
|
+
|
|
98
|
+
reasons = []
|
|
99
|
+
reasons << "content differs" unless File.binread(source) == File.binread(dest)
|
|
100
|
+
actual_mode = File.stat(dest).mode & 0o7777
|
|
101
|
+
reasons << format("mode %04o, want %04o", actual_mode, mode) unless actual_mode == mode
|
|
102
|
+
[dest, reasons.join(", ")] unless reasons.empty?
|
|
103
|
+
rescue Errno::EACCES
|
|
104
|
+
[dest, "unreadable, cannot compare"]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -1,12 +1,21 @@
|
|
|
1
|
+
require "shellwords"
|
|
2
|
+
|
|
1
3
|
module Dotlayer
|
|
2
4
|
module Commands
|
|
3
5
|
class Doctor
|
|
4
6
|
include Output
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
DEFAULT_MAX_DEPTH = 6
|
|
9
|
+
SKIP_DIRS = %w[.cache node_modules .git].freeze
|
|
10
|
+
SKIP_PATHS = %w[.local/share/Steam .local/state].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(config:, detector: nil, max_depth: nil)
|
|
7
13
|
@config = config
|
|
14
|
+
@max_depth = max_depth || DEFAULT_MAX_DEPTH
|
|
8
15
|
@detector = detector || Detector.new(config: @config)
|
|
9
16
|
@issues = []
|
|
17
|
+
@reported_links = []
|
|
18
|
+
@hints = []
|
|
10
19
|
end
|
|
11
20
|
|
|
12
21
|
def run
|
|
@@ -21,6 +30,7 @@ module Dotlayer
|
|
|
21
30
|
check_repos_exist
|
|
22
31
|
check_packages_exist(@packages)
|
|
23
32
|
check_broken_symlinks
|
|
33
|
+
check_dangling_repo_links
|
|
24
34
|
|
|
25
35
|
puts
|
|
26
36
|
if @issues.empty?
|
|
@@ -28,7 +38,9 @@ module Dotlayer
|
|
|
28
38
|
else
|
|
29
39
|
error "#{@issues.size} issue(s) found:"
|
|
30
40
|
@issues.each { |issue| puts " - #{issue}" }
|
|
41
|
+
@hints.each { |hint| puts " #{hint}" }
|
|
31
42
|
end
|
|
43
|
+
@issues.empty?
|
|
32
44
|
end
|
|
33
45
|
|
|
34
46
|
private
|
|
@@ -65,7 +77,7 @@ module Dotlayer
|
|
|
65
77
|
end
|
|
66
78
|
|
|
67
79
|
def check_broken_symlinks
|
|
68
|
-
print " Checking for broken symlinks in
|
|
80
|
+
print " Checking for broken symlinks in managed config paths... "
|
|
69
81
|
broken = find_broken_symlinks(@config.target)
|
|
70
82
|
if broken.empty?
|
|
71
83
|
ok "none"
|
|
@@ -78,32 +90,106 @@ module Dotlayer
|
|
|
78
90
|
"(deleted)"
|
|
79
91
|
end
|
|
80
92
|
@issues << "Broken symlink: #{link} -> #{target}"
|
|
93
|
+
@reported_links << link
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Report-only scan of the whole target dir for links into a configured
|
|
99
|
+
# repo whose destination no longer exists. Never follows directory
|
|
100
|
+
# symlinks and never deletes anything.
|
|
101
|
+
def check_dangling_repo_links
|
|
102
|
+
print " Checking for dangling links into repos (depth #{@max_depth})... "
|
|
103
|
+
dangling = find_dangling_repo_links(@config.target)
|
|
104
|
+
if dangling.empty?
|
|
105
|
+
ok "none"
|
|
106
|
+
return
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
warning "#{dangling.size} found"
|
|
110
|
+
dangling.each do |link, target|
|
|
111
|
+
@issues << "Dangling link into repo: #{link} -> #{target}"
|
|
112
|
+
end
|
|
113
|
+
@hints << "To remove dangling links: rm #{dangling.keys.map { |link| Shellwords.escape(link) }.join(" ")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def find_dangling_repo_links(target)
|
|
117
|
+
repo_roots = @config.repos.map(&:path)
|
|
118
|
+
@shipped_dirs = shipped_dirs(repo_roots)
|
|
119
|
+
found = {}
|
|
120
|
+
scan_dir(target, target, 0, repo_roots, found)
|
|
121
|
+
found.except(*@reported_links)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def scan_dir(dir, root, depth, repo_roots, found)
|
|
125
|
+
Dir.children(dir).sort.each do |name|
|
|
126
|
+
path = File.join(dir, name)
|
|
127
|
+
if File.symlink?(path)
|
|
128
|
+
target = File.readlink(path)
|
|
129
|
+
absolute = File.expand_path(target, dir)
|
|
130
|
+
found[path] = target if into_repo?(absolute, repo_roots) && !File.exist?(path)
|
|
131
|
+
elsif File.directory?(path) && depth < @max_depth && !skip_dir?(path, root, repo_roots)
|
|
132
|
+
scan_dir(path, root, depth + 1, repo_roots, found)
|
|
81
133
|
end
|
|
82
134
|
end
|
|
135
|
+
rescue SystemCallError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def into_repo?(path, repo_roots)
|
|
140
|
+
repo_roots.any? { |repo| path.start_with?("#{repo}/") }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def skip_dir?(path, root, repo_roots)
|
|
144
|
+
relative = path.delete_prefix("#{root}/")
|
|
145
|
+
SKIP_DIRS.include?(File.basename(path)) ||
|
|
146
|
+
SKIP_PATHS.include?(relative) ||
|
|
147
|
+
repo_roots.include?(path) ||
|
|
148
|
+
foreign_checkout?(path, relative)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Another git checkout (~/src/*, ~/work/*) holds stow links only where a
|
|
152
|
+
# package ships files into it, so skip it otherwise. Checkouts are most
|
|
153
|
+
# of the files under a home dir, and the scan is far faster without them.
|
|
154
|
+
def foreign_checkout?(path, relative)
|
|
155
|
+
File.exist?(File.join(path, ".git")) && !@shipped_dirs.include?(relative)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Every directory any package in the repos ships, relative to the target.
|
|
159
|
+
# Covers packages that don't apply to this machine, since their old
|
|
160
|
+
# links can still dangle here.
|
|
161
|
+
def shipped_dirs(repo_roots)
|
|
162
|
+
repo_roots.flat_map { |repo|
|
|
163
|
+
Dir.glob("*/", base: repo).flat_map do |package|
|
|
164
|
+
Dir.glob("**/", File::FNM_DOTMATCH, base: File.join(repo, package)).map { |dir| dir.chomp("/") }
|
|
165
|
+
end
|
|
166
|
+
}.to_set
|
|
83
167
|
end
|
|
84
168
|
|
|
85
169
|
def find_broken_symlinks(target)
|
|
86
170
|
broken = []
|
|
87
|
-
|
|
171
|
+
managed_roots = [".config", ".local"]
|
|
172
|
+
scan_paths = @packages.flat_map { |repo_path, package|
|
|
88
173
|
pkg_dir = File.join(repo_path, package)
|
|
89
174
|
next [] unless Dir.exist?(pkg_dir)
|
|
90
175
|
|
|
91
|
-
|
|
92
|
-
|
|
176
|
+
managed_roots.flat_map do |root_name|
|
|
177
|
+
root_path = File.join(pkg_dir, root_name)
|
|
178
|
+
next [] unless File.exist?(root_path) || File.symlink?(root_path)
|
|
93
179
|
|
|
94
|
-
|
|
95
|
-
|
|
180
|
+
managed_paths = [root_path]
|
|
181
|
+
if File.directory?(root_path)
|
|
182
|
+
managed_paths.concat(Dir.glob(File.join(root_path, "**", "*"), File::FNM_DOTMATCH))
|
|
183
|
+
end
|
|
96
184
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
185
|
+
managed_paths
|
|
186
|
+
.reject { |path| [".", ".."].include?(File.basename(path)) }
|
|
187
|
+
.map { |path| File.join(target, path.delete_prefix("#{pkg_dir}/")) }
|
|
100
188
|
end
|
|
189
|
+
}.uniq
|
|
101
190
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
105
|
-
broken << path if File.symlink?(path) && !File.exist?(path)
|
|
106
|
-
end
|
|
191
|
+
scan_paths.each do |path|
|
|
192
|
+
broken << path if File.symlink?(path) && !File.exist?(path)
|
|
107
193
|
end
|
|
108
194
|
|
|
109
195
|
broken
|
|
@@ -8,6 +8,7 @@ module Dotlayer
|
|
|
8
8
|
@detector = detector || Detector.new(config: @config)
|
|
9
9
|
@dry_run = dry_run
|
|
10
10
|
@verbose = verbose
|
|
11
|
+
@system_failures = 0
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def run
|
|
@@ -16,21 +17,29 @@ module Dotlayer
|
|
|
16
17
|
packages = resolver.resolve
|
|
17
18
|
stow = Stow.new(target: @config.target, dry_run: @dry_run, verbose: @verbose)
|
|
18
19
|
|
|
19
|
-
heading "Installing dotfiles (#{detection.os}/#{detection.profile})"
|
|
20
|
+
heading "Installing dotfiles (#{detection.os}/#{detection.profile}/#{detection.machine})"
|
|
20
21
|
puts
|
|
21
22
|
|
|
22
|
-
packages.
|
|
23
|
-
restow_package(stow, repo_path, package)
|
|
23
|
+
failures = packages.count do |repo_path, package|
|
|
24
|
+
!restow_package(stow, repo_path, package)
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
abort_failed_packages(failures) if failures.positive?
|
|
28
|
+
|
|
26
29
|
install_system_files if detection.os == "linux"
|
|
27
30
|
|
|
28
31
|
puts
|
|
32
|
+
abort "Error: #{@system_failures} system file(s) or hook(s) failed." if @system_failures.positive?
|
|
29
33
|
puts "Done! #{packages.size} package(s) stowed."
|
|
30
34
|
end
|
|
31
35
|
|
|
32
36
|
private
|
|
33
37
|
|
|
38
|
+
def abort_failed_packages(failures)
|
|
39
|
+
puts
|
|
40
|
+
abort "Error: #{failures} package(s) failed to stow."
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
def install_system_files
|
|
35
44
|
return if @config.system_files.empty?
|
|
36
45
|
|
|
@@ -60,17 +69,13 @@ module Dotlayer
|
|
|
60
69
|
next
|
|
61
70
|
end
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
# install -D creates missing parent dirs (e.g. /etc/systemd/system-sleep).
|
|
73
|
+
if system("sudo", "install", "-D", "-m", mode || "0644", source, dest)
|
|
74
|
+
ok
|
|
75
|
+
else
|
|
76
|
+
@system_failures += 1
|
|
64
77
|
error("failed")
|
|
65
|
-
next
|
|
66
78
|
end
|
|
67
|
-
|
|
68
|
-
if mode && !system("sudo", "chmod", mode, dest)
|
|
69
|
-
error("chmod failed")
|
|
70
|
-
next
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
ok
|
|
74
79
|
end
|
|
75
80
|
|
|
76
81
|
run_hooks("after_system_files")
|
|
@@ -104,7 +109,10 @@ module Dotlayer
|
|
|
104
109
|
if @dry_run
|
|
105
110
|
warning("dry-run")
|
|
106
111
|
else
|
|
107
|
-
|
|
112
|
+
next ok if system(cmd)
|
|
113
|
+
|
|
114
|
+
@system_failures += 1
|
|
115
|
+
error("failed")
|
|
108
116
|
end
|
|
109
117
|
end
|
|
110
118
|
end
|
|
@@ -23,6 +23,7 @@ module Dotlayer
|
|
|
23
23
|
heading "System Detection"
|
|
24
24
|
puts " OS: #{detection.os}"
|
|
25
25
|
puts " Profile: #{detection.profile}"
|
|
26
|
+
puts " Machine: #{detection.machine}"
|
|
26
27
|
puts " Distros: #{detection.distros.empty? ? "(none)" : detection.distros.join(", ")}"
|
|
27
28
|
puts " Groups: #{detection.groups.empty? ? "(none)" : detection.groups.join(", ")}"
|
|
28
29
|
puts
|
|
@@ -52,13 +52,20 @@ module Dotlayer
|
|
|
52
52
|
|
|
53
53
|
heading "Re-stowing packages"
|
|
54
54
|
|
|
55
|
-
packages.
|
|
56
|
-
restow_package(stow, repo_path, package, verb: "Restowing")
|
|
55
|
+
failures = packages.count do |repo_path, package|
|
|
56
|
+
!restow_package(stow, repo_path, package, verb: "Restowing")
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
+
abort_failed_packages(failures) if failures.positive?
|
|
60
|
+
|
|
59
61
|
puts
|
|
60
62
|
puts "Done! #{packages.size} package(s) restowed."
|
|
61
63
|
end
|
|
64
|
+
|
|
65
|
+
def abort_failed_packages(failures)
|
|
66
|
+
puts
|
|
67
|
+
abort "Error: #{failures} package(s) failed to restow."
|
|
68
|
+
end
|
|
62
69
|
end
|
|
63
70
|
end
|
|
64
71
|
end
|
data/lib/dotlayer/config.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
|
|
3
3
|
module Dotlayer
|
|
4
|
-
Repo = Data.define(:path, :private, :packages)
|
|
4
|
+
Repo = Data.define(:path, :private, :packages, :standalone_packages, :group_packages)
|
|
5
5
|
|
|
6
6
|
class Config
|
|
7
7
|
DEFAULT_PACKAGES = %w[stow bin git zsh config].freeze
|
|
@@ -30,7 +30,9 @@ module Dotlayer
|
|
|
30
30
|
Repo.new(
|
|
31
31
|
path: File.expand_path(path),
|
|
32
32
|
private: entry["private"] || false,
|
|
33
|
-
packages: entry["packages"]&.freeze
|
|
33
|
+
packages: entry["packages"]&.freeze,
|
|
34
|
+
standalone_packages: entry["standalone_packages"]&.freeze,
|
|
35
|
+
group_packages: entry["group_packages"] || {}
|
|
34
36
|
)
|
|
35
37
|
end.freeze
|
|
36
38
|
end
|
|
@@ -55,6 +57,14 @@ module Dotlayer
|
|
|
55
57
|
@data.fetch("groups", {})
|
|
56
58
|
end
|
|
57
59
|
|
|
60
|
+
def machines
|
|
61
|
+
@data.fetch("machines", {})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def machine_env
|
|
65
|
+
@data.dig("machines", "env") || "DOTLAYER_MACHINE"
|
|
66
|
+
end
|
|
67
|
+
|
|
58
68
|
def system_files
|
|
59
69
|
@data.fetch("system_files", [])
|
|
60
70
|
end
|
data/lib/dotlayer/detector.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "open3"
|
|
2
2
|
|
|
3
3
|
module Dotlayer
|
|
4
|
-
Detection = Data.define(:os, :profile, :distros, :groups)
|
|
4
|
+
Detection = Data.define(:os, :profile, :machine, :distros, :groups)
|
|
5
5
|
|
|
6
6
|
class Detector
|
|
7
7
|
def initialize(config: Config.new)
|
|
@@ -9,7 +9,13 @@ module Dotlayer
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def detect
|
|
12
|
-
Detection.new(
|
|
12
|
+
Detection.new(
|
|
13
|
+
os: detect_os,
|
|
14
|
+
profile: detect_profile,
|
|
15
|
+
machine: detect_machine,
|
|
16
|
+
distros: detect_distros,
|
|
17
|
+
groups: detect_groups
|
|
18
|
+
)
|
|
13
19
|
end
|
|
14
20
|
|
|
15
21
|
private
|
|
@@ -27,13 +33,30 @@ module Dotlayer
|
|
|
27
33
|
return from_env if from_env && !from_env.empty?
|
|
28
34
|
|
|
29
35
|
if @config.profile_detect && !@config.profile_detect.empty?
|
|
30
|
-
output, status = Open3.capture2(@config.profile_detect)
|
|
36
|
+
output, status = Open3.capture2("sh", "-c", @config.profile_detect, err: File::NULL)
|
|
31
37
|
return output.strip if status.success? && !output.strip.empty?
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
"desktop"
|
|
35
41
|
end
|
|
36
42
|
|
|
43
|
+
def detect_machine
|
|
44
|
+
from_env = ENV[@config.machine_env]
|
|
45
|
+
return normalize_tag(from_env) if from_env && !from_env.empty?
|
|
46
|
+
|
|
47
|
+
detected = @config.machines.find do |name, entry|
|
|
48
|
+
next false if name == "env"
|
|
49
|
+
|
|
50
|
+
command_detected?(entry)
|
|
51
|
+
end
|
|
52
|
+
return normalize_tag(detected.first) if detected
|
|
53
|
+
|
|
54
|
+
output, status = Open3.capture2("hostname", "-s", err: File::NULL)
|
|
55
|
+
return normalize_tag(output) if status.success? && !output.strip.empty?
|
|
56
|
+
|
|
57
|
+
"unknown"
|
|
58
|
+
end
|
|
59
|
+
|
|
37
60
|
def detect_distros
|
|
38
61
|
@config.distros.select { |_name, entry| command_detected?(entry) }.keys
|
|
39
62
|
end
|
|
@@ -49,5 +72,9 @@ module Dotlayer
|
|
|
49
72
|
_, status = Open3.capture2e("sh", "-c", cmd)
|
|
50
73
|
status.success?
|
|
51
74
|
end
|
|
75
|
+
|
|
76
|
+
def normalize_tag(value)
|
|
77
|
+
value.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
78
|
+
end
|
|
52
79
|
end
|
|
53
80
|
end
|
data/lib/dotlayer/output.rb
CHANGED
|
@@ -38,12 +38,12 @@ module Dotlayer
|
|
|
38
38
|
|
|
39
39
|
def restow_package(stow, repo_path, package, verb: "Stowing")
|
|
40
40
|
print " #{verb} #{green(package)}... "
|
|
41
|
-
if stow.
|
|
42
|
-
warning("dry-run")
|
|
43
|
-
|
|
44
|
-
ok
|
|
41
|
+
if stow.restow(repo_path, package)
|
|
42
|
+
stow.dry_run? ? warning("dry-run") : ok
|
|
43
|
+
true
|
|
45
44
|
else
|
|
46
45
|
error("failed: #{stow.last_error}")
|
|
46
|
+
false
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
end
|
data/lib/dotlayer/resolver.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Dotlayer
|
|
|
24
24
|
# Repos with explicit packages always use layered resolution,
|
|
25
25
|
# even if the directories don't exist yet
|
|
26
26
|
if repo.packages || base_packages.any? { |pkg| Dir.exist?(File.join(repo.path, pkg)) }
|
|
27
|
-
resolve_layered_repo(repo
|
|
27
|
+
resolve_layered_repo(repo, base_packages)
|
|
28
28
|
else
|
|
29
29
|
resolve_all_packages(repo.path)
|
|
30
30
|
end
|
|
@@ -32,7 +32,8 @@ module Dotlayer
|
|
|
32
32
|
|
|
33
33
|
# Repos with base packages use layered convention matching
|
|
34
34
|
# plus standalone directories that don't match any layer pattern
|
|
35
|
-
def resolve_layered_repo(
|
|
35
|
+
def resolve_layered_repo(repo, base_packages)
|
|
36
|
+
repo_path = repo.path
|
|
36
37
|
packages = []
|
|
37
38
|
|
|
38
39
|
base_packages.each do |pkg|
|
|
@@ -40,10 +41,10 @@ module Dotlayer
|
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
dirs = top_level_dirs(repo_path)
|
|
43
|
-
layers, matched_dirs = resolve_layers(
|
|
44
|
+
layers, matched_dirs = resolve_layers(repo, base_packages, dirs)
|
|
44
45
|
packages.concat(layers)
|
|
45
46
|
|
|
46
|
-
standalone = resolve_standalone(
|
|
47
|
+
standalone = resolve_standalone(repo, base_packages, matched_dirs, dirs)
|
|
47
48
|
packages.concat(standalone)
|
|
48
49
|
end
|
|
49
50
|
|
|
@@ -54,41 +55,82 @@ module Dotlayer
|
|
|
54
55
|
.map { |d| [repo_path, d] }
|
|
55
56
|
end
|
|
56
57
|
|
|
57
|
-
def resolve_layers(
|
|
58
|
+
def resolve_layers(repo, base_packages, dirs)
|
|
59
|
+
repo_path = repo.path
|
|
58
60
|
os_packages = []
|
|
59
61
|
distro_packages = []
|
|
60
62
|
distro_profile_packages = []
|
|
63
|
+
machine_packages = []
|
|
64
|
+
os_machine_packages = []
|
|
65
|
+
distro_machine_packages = []
|
|
66
|
+
distro_profile_machine_packages = []
|
|
61
67
|
group_packages = []
|
|
62
|
-
matched_dirs =
|
|
68
|
+
matched_dirs = base_packages.dup
|
|
63
69
|
|
|
64
70
|
dirs.each do |dir|
|
|
65
71
|
next if base_packages.include?(dir)
|
|
66
72
|
|
|
67
73
|
case dir
|
|
68
|
-
when suffix("-#{@detection.
|
|
69
|
-
|
|
74
|
+
when *@detection.distros.map { |d| suffix("-#{d}-#{@detection.profile}-#{@detection.machine}") }
|
|
75
|
+
distro_profile_machine_packages << [repo_path, dir]
|
|
70
76
|
matched_dirs << dir
|
|
71
|
-
when *@detection.distros.map { |d| suffix("-#{d}") }
|
|
72
|
-
|
|
77
|
+
when *@detection.distros.map { |d| suffix("-#{d}-#{@detection.machine}") }
|
|
78
|
+
distro_machine_packages << [repo_path, dir]
|
|
79
|
+
matched_dirs << dir
|
|
80
|
+
when suffix("-#{@detection.os}-#{@detection.machine}")
|
|
81
|
+
os_machine_packages << [repo_path, dir]
|
|
82
|
+
matched_dirs << dir
|
|
83
|
+
when suffix("-#{@detection.machine}")
|
|
84
|
+
machine_packages << [repo_path, dir]
|
|
73
85
|
matched_dirs << dir
|
|
74
86
|
when *@detection.distros.map { |d| suffix("-#{d}-#{@detection.profile}") }
|
|
75
87
|
distro_profile_packages << [repo_path, dir]
|
|
76
88
|
matched_dirs << dir
|
|
89
|
+
when *@detection.distros.map { |d| suffix("-#{d}") }
|
|
90
|
+
distro_packages << [repo_path, dir]
|
|
91
|
+
matched_dirs << dir
|
|
92
|
+
when suffix("-#{@detection.os}")
|
|
93
|
+
os_packages << [repo_path, dir]
|
|
94
|
+
matched_dirs << dir
|
|
77
95
|
when *@detection.groups.map { |g| suffix("-#{g}") }
|
|
78
96
|
group_packages << [repo_path, dir]
|
|
79
97
|
matched_dirs << dir
|
|
80
98
|
end
|
|
81
99
|
end
|
|
82
100
|
|
|
83
|
-
|
|
101
|
+
group_packages.concat(resolve_group_packages(repo, matched_dirs))
|
|
102
|
+
layers = os_packages + distro_packages + distro_profile_packages +
|
|
103
|
+
machine_packages + os_machine_packages + distro_machine_packages +
|
|
104
|
+
distro_profile_machine_packages + group_packages
|
|
84
105
|
[layers, matched_dirs]
|
|
85
106
|
end
|
|
86
107
|
|
|
87
|
-
def
|
|
108
|
+
def resolve_group_packages(repo, matched_dirs)
|
|
109
|
+
@detection.groups.flat_map do |group|
|
|
110
|
+
Array(repo.group_packages[group]).filter_map do |package|
|
|
111
|
+
next if matched_dirs.include?(package)
|
|
112
|
+
next unless Dir.exist?(File.join(repo.path, package))
|
|
113
|
+
|
|
114
|
+
matched_dirs << package
|
|
115
|
+
[repo.path, package]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def resolve_standalone(repo, base_packages, matched_dirs, dirs)
|
|
121
|
+
if repo.standalone_packages
|
|
122
|
+
return repo.standalone_packages.filter_map do |package|
|
|
123
|
+
next if matched_dirs.include?(package)
|
|
124
|
+
next unless Dir.exist?(File.join(repo.path, package))
|
|
125
|
+
|
|
126
|
+
[repo.path, package]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
88
130
|
dirs
|
|
89
131
|
.reject { |d| matched_dirs.include?(d) || layer_variant?(d, base_packages) }
|
|
90
132
|
.sort
|
|
91
|
-
.map { |d| [
|
|
133
|
+
.map { |d| [repo.path, d] }
|
|
92
134
|
end
|
|
93
135
|
|
|
94
136
|
def top_level_dirs(repo_path)
|
data/lib/dotlayer/stow.rb
CHANGED
|
@@ -2,30 +2,31 @@ require "open3"
|
|
|
2
2
|
|
|
3
3
|
module Dotlayer
|
|
4
4
|
class Stow
|
|
5
|
-
attr_reader :last_error
|
|
5
|
+
attr_reader :last_error, :last_output
|
|
6
6
|
|
|
7
|
-
def initialize(target: "~", dry_run: false, verbose: false)
|
|
7
|
+
def initialize(target: "~", dry_run: false, verbose: false, quiet: false)
|
|
8
8
|
@target = File.expand_path(target)
|
|
9
9
|
@dry_run = dry_run
|
|
10
10
|
@verbose = verbose
|
|
11
|
+
@quiet = quiet
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def dry_run? = @dry_run
|
|
14
15
|
|
|
15
16
|
def restow(repo_path, package)
|
|
16
|
-
args = ["stow", "-R"]
|
|
17
|
+
args = ["stow", "-R", "--no-folding"]
|
|
18
|
+
args << "-n" if @dry_run
|
|
17
19
|
args << "-v" if @verbose
|
|
18
20
|
args << "-d" << repo_path
|
|
19
21
|
args << "-t" << @target
|
|
20
22
|
args << package
|
|
21
23
|
|
|
22
|
-
if @verbose || @dry_run
|
|
24
|
+
if !@quiet && (@verbose || @dry_run)
|
|
23
25
|
warn " #{args.join(" ")}"
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
return true if @dry_run
|
|
27
|
-
|
|
28
28
|
output, status = Open3.capture2e(*args)
|
|
29
|
+
@last_output = output
|
|
29
30
|
@last_error = status.success? ? nil : output.strip
|
|
30
31
|
status.success?
|
|
31
32
|
rescue Errno::ENOENT
|
data/lib/dotlayer.rb
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Dotlayer
|
|
2
|
-
VERSION = "0.
|
|
2
|
+
VERSION = "0.3.1"
|
|
3
3
|
|
|
4
4
|
autoload :CLI, "dotlayer/cli"
|
|
5
5
|
autoload :Config, "dotlayer/config"
|
|
@@ -12,6 +12,7 @@ module Dotlayer
|
|
|
12
12
|
|
|
13
13
|
module Commands
|
|
14
14
|
autoload :Adopt, "dotlayer/commands/adopt"
|
|
15
|
+
autoload :Diff, "dotlayer/commands/diff"
|
|
15
16
|
autoload :Doctor, "dotlayer/commands/doctor"
|
|
16
17
|
autoload :Install, "dotlayer/commands/install"
|
|
17
18
|
autoload :Status, "dotlayer/commands/status"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dotlayer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Douglas Andrade
|
|
@@ -18,12 +18,14 @@ executables:
|
|
|
18
18
|
extensions: []
|
|
19
19
|
extra_rdoc_files: []
|
|
20
20
|
files:
|
|
21
|
+
- CHANGELOG.md
|
|
21
22
|
- LICENSE
|
|
22
23
|
- README.md
|
|
23
24
|
- exe/dotlayer
|
|
24
25
|
- lib/dotlayer.rb
|
|
25
26
|
- lib/dotlayer/cli.rb
|
|
26
27
|
- lib/dotlayer/commands/adopt.rb
|
|
28
|
+
- lib/dotlayer/commands/diff.rb
|
|
27
29
|
- lib/dotlayer/commands/doctor.rb
|
|
28
30
|
- lib/dotlayer/commands/install.rb
|
|
29
31
|
- lib/dotlayer/commands/status.rb
|
|
@@ -39,6 +41,7 @@ licenses:
|
|
|
39
41
|
metadata:
|
|
40
42
|
homepage_uri: https://github.com/douglas/dotlayer
|
|
41
43
|
source_code_uri: https://github.com/douglas/dotlayer
|
|
44
|
+
changelog_uri: https://github.com/douglas/dotlayer/blob/main/CHANGELOG.md
|
|
42
45
|
rdoc_options: []
|
|
43
46
|
require_paths:
|
|
44
47
|
- lib
|
|
@@ -46,14 +49,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
46
49
|
requirements:
|
|
47
50
|
- - ">="
|
|
48
51
|
- !ruby/object:Gem::Version
|
|
49
|
-
version: '3.
|
|
52
|
+
version: '3.3'
|
|
50
53
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
51
54
|
requirements:
|
|
52
55
|
- - ">="
|
|
53
56
|
- !ruby/object:Gem::Version
|
|
54
57
|
version: '0'
|
|
55
58
|
requirements: []
|
|
56
|
-
rubygems_version: 4.0.
|
|
59
|
+
rubygems_version: 4.0.20
|
|
57
60
|
specification_version: 4
|
|
58
61
|
summary: Layered dotfiles management with GNU Stow
|
|
59
62
|
test_files: []
|