dotlayer 0.2.2 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0489157f2a066cbd4729f51ad5dff3800e75c0144061ea85f49cd4005bcb297
4
- data.tar.gz: 21e4467266812d237318d0ca70aab7491ae3e87dcf95d66dd2db1c85c74a5e65
3
+ metadata.gz: 6cc8bde13229782ad14103053a087d775df340ce8c7d120201bdd435b0385ad7
4
+ data.tar.gz: 3922e94b846518b408738a05f4872d0bbac4aaa6d303c4f9df545c80ee17ed76
5
5
  SHA512:
6
- metadata.gz: 8062c90a687287e5f0e1e2205064f537be913f5e6a91f844d3d3cdc994d280eeb14428f808ebd2e5e268bd2f01ff603f1be431bbcac2abcff0ff77d5d20eb58b
7
- data.tar.gz: 6bcc1d1000022e64bda327a7a60a15b65bf77b2c4e394a6077482204bdb2a7ae46911f2502d5e95f50c1c30f65cf1388dc6ee5296aa364f3bbeb4011c92fc60b
6
+ metadata.gz: 6a1df6c95a538c11bcab8b2fec4c4db2ffd760c1dc993cfabb3743e198da9399172d31e320373097b66e571d3353a1770e0a27ddb7ff8252de546b543e2f0dbe
7
+ data.tar.gz: 8f2b0df93647c194ae9a02a387e0e810e0accdeec10af9803b2bb186d30b934644472ff60635954cb85616e64e4ceb287c54635658861678065f1a8f175ad1c7
data/CHANGELOG.md ADDED
@@ -0,0 +1,60 @@
1
+ # Changelog
2
+
3
+ All notable changes to dotlayer. Versions follow [Semantic Versioning](https://semver.org/).
4
+
5
+ ## 0.3.2 — 2026-11-05
6
+
7
+ ### Added
8
+
9
+ - `doctor` reports managed files that are no longer links. Tools that save by
10
+ writing a temp file and renaming it, or with `sed -i`, replace a stowed link
11
+ with a plain file, and changes in the repo stop reaching the machine. Before,
12
+ only `install` noticed, by failing with conflicts. The check asks Stow in
13
+ simulate mode, so it follows Stow's own ignore rules.
14
+
15
+ ## 0.3.1 — 2026-11-05
16
+
17
+ First RubyGems release since 0.2.2: it includes everything listed under 0.3.0,
18
+ which was tagged but never published.
19
+
20
+ ### Changed
21
+
22
+ - `doctor`'s dangling-link scan skips other git checkouts under the target
23
+ (any directory with a `.git`, like `~/src/*`), unless a package ships files
24
+ into that path. Stow only links into a checkout in that case, and checkouts
25
+ are most of the directories in a typical home dir: `doctor` went from 5.9s to
26
+ 0.9s on a development machine.
27
+
28
+ ## 0.3.0 — 2026-11-04 (tagged, not published)
29
+
30
+ ### Added
31
+
32
+ - Machine layers: `config-<machine>`, `config-<os>-<machine>`,
33
+ `config-<distro>-<machine>` and `config-<distro>-<profile>-<machine>`. The
34
+ machine tag comes from `$DOTLAYER_MACHINE`, then `machines.<tag>.detect`
35
+ commands in `dotlayer.yml`, then `hostname -s`.
36
+ - `dotlayer diff`: shows what `install` would link, unlink or conflict on, and
37
+ which system files would change, without changing anything.
38
+ - `doctor` scans the whole target dir for links into a configured repo whose
39
+ destination no longer exists, and prints an `rm` command for them. It never
40
+ deletes anything. `--depth N` limits the scan (default 6).
41
+
42
+ ### Changed
43
+
44
+ - Stow runs with `--no-folding`: every file gets its own link, never a link to
45
+ a whole directory.
46
+ - `doctor`'s broken-symlink check covers only managed `.config` and `.local`
47
+ paths, and the dangling-link scan skips `.local/state` (app backups).
48
+ - System files: parent directories are created when missing, and a failed copy
49
+ fails the install.
50
+ - Requires Ruby 3.3 or newer; CI tests Ruby 3.3 through 4.0 and installs Stow.
51
+
52
+ ### Fixed
53
+
54
+ - Profile detection no longer crashes on macOS, where `hostnamectl` doesn't
55
+ exist.
56
+ - `diff` conflict handling works with older Stow versions.
57
+
58
+ ## 0.2.2 and earlier
59
+
60
+ 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.2, GNU Stow
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 four things about your system:
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. **Group layer** — `config-mycompany`
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/ ← standalone: work tooling
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. Everything else (`claude`, `work`, `scripts`) is stowed as-is in alphabetical order.
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 Show what would be done without making changes
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, that managed config paths have no broken symlinks, and that every managed file is still a link: an app that saves by replacing the file leaves a plain file there, which `install` would refuse to overwrite. 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 "doctor" then Commands::Doctor.new(config:).run
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
- doctor Check for broken symlinks, conflicts, missing deps
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
- def initialize(config:, detector: nil)
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,8 @@ module Dotlayer
21
30
  check_repos_exist
22
31
  check_packages_exist(@packages)
23
32
  check_broken_symlinks
33
+ check_unlinked_files
34
+ check_dangling_repo_links
24
35
 
25
36
  puts
26
37
  if @issues.empty?
@@ -28,14 +39,16 @@ module Dotlayer
28
39
  else
29
40
  error "#{@issues.size} issue(s) found:"
30
41
  @issues.each { |issue| puts " - #{issue}" }
42
+ @hints.each { |hint| puts " #{hint}" }
31
43
  end
44
+ @issues.empty?
32
45
  end
33
46
 
34
47
  private
35
48
 
36
49
  def check_stow_installed
37
50
  print " Checking stow... "
38
- if system("which", "stow", out: File::NULL, err: File::NULL)
51
+ if (@stow_installed = system("which", "stow", out: File::NULL, err: File::NULL))
39
52
  ok "installed"
40
53
  else
41
54
  error "missing"
@@ -65,7 +78,7 @@ module Dotlayer
65
78
  end
66
79
 
67
80
  def check_broken_symlinks
68
- print " Checking for broken symlinks in #{@config.target}... "
81
+ print " Checking for broken symlinks in managed config paths... "
69
82
  broken = find_broken_symlinks(@config.target)
70
83
  if broken.empty?
71
84
  ok "none"
@@ -78,32 +91,135 @@ module Dotlayer
78
91
  "(deleted)"
79
92
  end
80
93
  @issues << "Broken symlink: #{link} -> #{target}"
94
+ @reported_links << link
81
95
  end
82
96
  end
83
97
  end
84
98
 
99
+ # Tools that save by writing a temp file and renaming it (or `sed -i`)
100
+ # replace a stowed link with a plain file, and the repo stops reaching
101
+ # the machine. Stow's simulate mode reports those as conflicts, so ask
102
+ # it instead of re-implementing its ignore rules.
103
+ def check_unlinked_files
104
+ return unless @stow_installed
105
+
106
+ print " Checking that managed files are still links... "
107
+ stow = Stow.new(target: @config.target, dry_run: true, quiet: true)
108
+ conflicts = @packages.flat_map { |repo_path, package|
109
+ next [] if !Dir.exist?(File.join(repo_path, package)) || stow.restow(repo_path, package)
110
+
111
+ conflicts = stow.last_output.to_s.each_line(chomp: true).filter_map { |line| Diff::CONFLICT_LINE.match(line)&.[](1) }
112
+ conflicts = [stow.last_error] if conflicts.empty?
113
+ conflicts.map { |conflict| [repo_path, package, conflict] }
114
+ }
115
+
116
+ if conflicts.empty?
117
+ ok "yes"
118
+ return
119
+ end
120
+
121
+ warning "#{conflicts.size} not linked"
122
+ conflicts.each do |repo_path, package, conflict|
123
+ @issues << "#{File.basename(repo_path)}/#{package}: #{conflict}"
124
+ end
125
+ @hints << "Compare each file with its repo copy (dotlayer diff lists them), keep what you need, delete the plain file, then run dotlayer install."
126
+ end
127
+
128
+ # Report-only scan of the whole target dir for links into a configured
129
+ # repo whose destination no longer exists. Never follows directory
130
+ # symlinks and never deletes anything.
131
+ def check_dangling_repo_links
132
+ print " Checking for dangling links into repos (depth #{@max_depth})... "
133
+ dangling = find_dangling_repo_links(@config.target)
134
+ if dangling.empty?
135
+ ok "none"
136
+ return
137
+ end
138
+
139
+ warning "#{dangling.size} found"
140
+ dangling.each do |link, target|
141
+ @issues << "Dangling link into repo: #{link} -> #{target}"
142
+ end
143
+ @hints << "To remove dangling links: rm #{dangling.keys.map { |link| Shellwords.escape(link) }.join(" ")}"
144
+ end
145
+
146
+ def find_dangling_repo_links(target)
147
+ repo_roots = @config.repos.map(&:path)
148
+ @shipped_dirs = shipped_dirs(repo_roots)
149
+ found = {}
150
+ scan_dir(target, target, 0, repo_roots, found)
151
+ found.except(*@reported_links)
152
+ end
153
+
154
+ def scan_dir(dir, root, depth, repo_roots, found)
155
+ Dir.children(dir).sort.each do |name|
156
+ path = File.join(dir, name)
157
+ if File.symlink?(path)
158
+ target = File.readlink(path)
159
+ absolute = File.expand_path(target, dir)
160
+ found[path] = target if into_repo?(absolute, repo_roots) && !File.exist?(path)
161
+ elsif File.directory?(path) && depth < @max_depth && !skip_dir?(path, root, repo_roots)
162
+ scan_dir(path, root, depth + 1, repo_roots, found)
163
+ end
164
+ end
165
+ rescue SystemCallError
166
+ nil
167
+ end
168
+
169
+ def into_repo?(path, repo_roots)
170
+ repo_roots.any? { |repo| path.start_with?("#{repo}/") }
171
+ end
172
+
173
+ def skip_dir?(path, root, repo_roots)
174
+ relative = path.delete_prefix("#{root}/")
175
+ SKIP_DIRS.include?(File.basename(path)) ||
176
+ SKIP_PATHS.include?(relative) ||
177
+ repo_roots.include?(path) ||
178
+ foreign_checkout?(path, relative)
179
+ end
180
+
181
+ # Another git checkout (~/src/*, ~/work/*) holds stow links only where a
182
+ # package ships files into it, so skip it otherwise. Checkouts are most
183
+ # of the files under a home dir, and the scan is far faster without them.
184
+ def foreign_checkout?(path, relative)
185
+ File.exist?(File.join(path, ".git")) && !@shipped_dirs.include?(relative)
186
+ end
187
+
188
+ # Every directory any package in the repos ships, relative to the target.
189
+ # Covers packages that don't apply to this machine, since their old
190
+ # links can still dangle here.
191
+ def shipped_dirs(repo_roots)
192
+ repo_roots.flat_map { |repo|
193
+ Dir.glob("*/", base: repo).flat_map do |package|
194
+ Dir.glob("**/", File::FNM_DOTMATCH, base: File.join(repo, package)).map { |dir| dir.chomp("/") }
195
+ end
196
+ }.to_set
197
+ end
198
+
85
199
  def find_broken_symlinks(target)
86
200
  broken = []
87
- scan_dirs = @packages.flat_map { |repo_path, package|
201
+ managed_roots = [".config", ".local"]
202
+ scan_paths = @packages.flat_map { |repo_path, package|
88
203
  pkg_dir = File.join(repo_path, package)
89
204
  next [] unless Dir.exist?(pkg_dir)
90
205
 
91
- Dir.children(pkg_dir).map { |child| File.join(target, child) }
92
- }.uniq
206
+ managed_roots.flat_map do |root_name|
207
+ root_path = File.join(pkg_dir, root_name)
208
+ next [] unless File.exist?(root_path) || File.symlink?(root_path)
93
209
 
94
- scan_dirs.each do |dir|
95
- next unless File.exist?(dir) || File.symlink?(dir)
210
+ managed_paths = [root_path]
211
+ if File.directory?(root_path)
212
+ managed_paths.concat(Dir.glob(File.join(root_path, "**", "*"), File::FNM_DOTMATCH))
213
+ end
96
214
 
97
- if File.symlink?(dir) && !File.exist?(dir)
98
- broken << dir
99
- next
215
+ managed_paths
216
+ .reject { |path| [".", ".."].include?(File.basename(path)) }
217
+ .map { |path| File.join(target, path.delete_prefix("#{pkg_dir}/")) }
100
218
  end
219
+ }.uniq
101
220
 
102
- next unless File.directory?(dir)
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
221
+ scan_paths.each do |path|
222
+ broken << path if File.symlink?(path) && !File.exist?(path)
107
223
  end
108
224
 
109
225
  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.each do |repo_path, package|
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
- unless system("sudo", "cp", source, dest)
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
- system(cmd) ? ok : error("failed")
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.each do |repo_path, package|
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
@@ -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
@@ -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(os: detect_os, profile: detect_profile, distros: detect_distros, groups: detect_groups)
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
@@ -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.dry_run?
42
- warning("dry-run")
43
- elsif stow.restow(repo_path, package)
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
@@ -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.path, base_packages)
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(repo_path, base_packages)
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(repo_path, base_packages, dirs)
44
+ layers, matched_dirs = resolve_layers(repo, base_packages, dirs)
44
45
  packages.concat(layers)
45
46
 
46
- standalone = resolve_standalone(repo_path, base_packages, matched_dirs, dirs)
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(repo_path, base_packages, dirs)
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 = Set.new(base_packages)
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.os}")
69
- os_packages << [repo_path, dir]
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
- distro_packages << [repo_path, dir]
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
- layers = os_packages + distro_packages + distro_profile_packages + group_packages
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 resolve_standalone(repo_path, base_packages, matched_dirs, dirs)
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| [repo_path, 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.2"
2
+ VERSION = "0.3.2"
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.2.2
4
+ version: 0.3.2
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.2'
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.8
59
+ rubygems_version: 4.0.20
57
60
  specification_version: 4
58
61
  summary: Layered dotfiles management with GNU Stow
59
62
  test_files: []