dotlayer 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6153afd914718371693784c18c5be35b4f84d2b4ceddda8990efed35e54ad229
4
- data.tar.gz: 192e9da8c37c20627151ee35dc415e1ac59eece7e27edb63bb8aac249e0defd7
3
+ metadata.gz: adde5780e85065e220d516e6f2238a63122c5a4151aea1bee55e98e272c04750
4
+ data.tar.gz: 693c72cb55917e957677c70b43114a6fe61968289f766662fb1198e96bcb283d
5
5
  SHA512:
6
- metadata.gz: 401aa858f83b8f064b9c6fdf39e2621ce2c4ba53f6fa9ae2ba3e9922b7d74ea20a4bf8ab1b247f4515060d2c051ce4e89b9b7c26288e3a07e3fd581b7550f579
7
- data.tar.gz: 6a5aa5a51170cbe973c30854f45b9e1713799cfc8d4d451e058e4a66998390f86eb656c183eda3d99baf95b2334af319cb4ae850f43e593047447e090b1115f8
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.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, 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.
@@ -266,6 +313,16 @@ dotlayer adopt --dry-run ~/.config/lazygit config
266
313
 
267
314
  The last argument is always the package name. Dotlayer finds the first repo containing that package, or falls back to the first repo for new packages.
268
315
 
316
+ ## Development
317
+
318
+ ```sh
319
+ bundle exec rake test # run tests
320
+ bundle exec standardrb # lint
321
+ bundle exec rubycritic lib/ # code quality report
322
+ ```
323
+
324
+ See [Contributing](CONTRIBUTING.md) for full setup, testing, and contribution guidelines.
325
+
269
326
  ## Documentation
270
327
 
271
328
  - [Architecture](docs/architecture.md) — system design, data flow, and class responsibilities
data/lib/dotlayer/cli.rb CHANGED
@@ -13,11 +13,12 @@ module Dotlayer
13
13
  config = Config.new(options[:config])
14
14
 
15
15
  case command
16
- when "status" then Commands::Status.new(config:).run
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
- 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
20
- when "adopt" then run_adopt(config:, argv:, options:)
18
+ when "update" then Commands::Update.new(config:, dry_run: options[:dry_run], verbose: options[:verbose]).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
21
+ when "adopt" then run_adopt(config:, argv:, options:)
21
22
  when "version"
22
23
  puts "dotlayer #{VERSION}"
23
24
  else
@@ -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,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,41 +77,119 @@ module Dotlayer
65
77
  end
66
78
 
67
79
  def check_broken_symlinks
68
- print " Checking for broken symlinks in #{@config.target}... "
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"
72
84
  else
73
85
  warning "#{broken.size} found"
74
86
  broken.each do |link|
75
- target = File.readlink(link) rescue "(deleted)"
87
+ target = begin
88
+ File.readlink(link)
89
+ rescue
90
+ "(deleted)"
91
+ end
76
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)
77
133
  end
78
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
79
167
  end
80
168
 
81
169
  def find_broken_symlinks(target)
82
170
  broken = []
83
- scan_dirs = @packages.flat_map { |repo_path, package|
171
+ managed_roots = [".config", ".local"]
172
+ scan_paths = @packages.flat_map { |repo_path, package|
84
173
  pkg_dir = File.join(repo_path, package)
85
174
  next [] unless Dir.exist?(pkg_dir)
86
175
 
87
- Dir.children(pkg_dir).map { |child| File.join(target, child) }
88
- }.uniq
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)
89
179
 
90
- scan_dirs.each do |dir|
91
- next unless File.exist?(dir) || File.symlink?(dir)
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
92
184
 
93
- if File.symlink?(dir) && !File.exist?(dir)
94
- broken << dir
95
- next
185
+ managed_paths
186
+ .reject { |path| [".", ".."].include?(File.basename(path)) }
187
+ .map { |path| File.join(target, path.delete_prefix("#{pkg_dir}/")) }
96
188
  end
189
+ }.uniq
97
190
 
98
- next unless File.directory?(dir)
99
-
100
- Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
101
- broken << path if File.symlink?(path) && !File.exist?(path)
102
- end
191
+ scan_paths.each do |path|
192
+ broken << path if File.symlink?(path) && !File.exist?(path)
103
193
  end
104
194
 
105
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.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
@@ -23,14 +23,16 @@ module Dotlayer
23
23
  end
24
24
 
25
25
  def repos
26
- @repos ||= @data.fetch("repos", [{ "path" => "~/.public_dotfiles" }]).filter_map do |entry|
26
+ @repos ||= @data.fetch("repos", [{"path" => "~/.public_dotfiles"}]).filter_map do |entry|
27
27
  path = entry["path"]&.to_s
28
28
  next if path.nil? || path.empty?
29
29
 
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
@@ -17,7 +23,7 @@ module Dotlayer
17
23
  def detect_os
18
24
  case RbConfig::CONFIG["host_os"]
19
25
  when /darwin/ then "macos"
20
- when /linux/ then "linux"
26
+ when /linux/ then "linux"
21
27
  else "unknown"
22
28
  end
23
29
  end
@@ -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
@@ -1,5 +1,3 @@
1
- require "set"
2
-
3
1
  module Dotlayer
4
2
  class Resolver
5
3
  def initialize(config:, detection:)
@@ -26,7 +24,7 @@ module Dotlayer
26
24
  # Repos with explicit packages always use layered resolution,
27
25
  # even if the directories don't exist yet
28
26
  if repo.packages || base_packages.any? { |pkg| Dir.exist?(File.join(repo.path, pkg)) }
29
- resolve_layered_repo(repo.path, base_packages)
27
+ resolve_layered_repo(repo, base_packages)
30
28
  else
31
29
  resolve_all_packages(repo.path)
32
30
  end
@@ -34,7 +32,8 @@ module Dotlayer
34
32
 
35
33
  # Repos with base packages use layered convention matching
36
34
  # plus standalone directories that don't match any layer pattern
37
- def resolve_layered_repo(repo_path, base_packages)
35
+ def resolve_layered_repo(repo, base_packages)
36
+ repo_path = repo.path
38
37
  packages = []
39
38
 
40
39
  base_packages.each do |pkg|
@@ -42,10 +41,10 @@ module Dotlayer
42
41
  end
43
42
 
44
43
  dirs = top_level_dirs(repo_path)
45
- layers, matched_dirs = resolve_layers(repo_path, base_packages, dirs)
44
+ layers, matched_dirs = resolve_layers(repo, base_packages, dirs)
46
45
  packages.concat(layers)
47
46
 
48
- standalone = resolve_standalone(repo_path, base_packages, matched_dirs, dirs)
47
+ standalone = resolve_standalone(repo, base_packages, matched_dirs, dirs)
49
48
  packages.concat(standalone)
50
49
  end
51
50
 
@@ -56,41 +55,82 @@ module Dotlayer
56
55
  .map { |d| [repo_path, d] }
57
56
  end
58
57
 
59
- def resolve_layers(repo_path, base_packages, dirs)
58
+ def resolve_layers(repo, base_packages, dirs)
59
+ repo_path = repo.path
60
60
  os_packages = []
61
61
  distro_packages = []
62
62
  distro_profile_packages = []
63
+ machine_packages = []
64
+ os_machine_packages = []
65
+ distro_machine_packages = []
66
+ distro_profile_machine_packages = []
63
67
  group_packages = []
64
- matched_dirs = Set.new(base_packages)
68
+ matched_dirs = base_packages.dup
65
69
 
66
70
  dirs.each do |dir|
67
71
  next if base_packages.include?(dir)
68
72
 
69
73
  case dir
70
- when suffix("-#{@detection.os}")
71
- 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]
72
76
  matched_dirs << dir
73
- when *@detection.distros.map { |d| suffix("-#{d}") }
74
- 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]
75
85
  matched_dirs << dir
76
86
  when *@detection.distros.map { |d| suffix("-#{d}-#{@detection.profile}") }
77
87
  distro_profile_packages << [repo_path, dir]
78
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
79
95
  when *@detection.groups.map { |g| suffix("-#{g}") }
80
96
  group_packages << [repo_path, dir]
81
97
  matched_dirs << dir
82
98
  end
83
99
  end
84
100
 
85
- 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
86
105
  [layers, matched_dirs]
87
106
  end
88
107
 
89
- 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
+
90
130
  dirs
91
131
  .reject { |d| matched_dirs.include?(d) || layer_variant?(d, base_packages) }
92
132
  .sort
93
- .map { |d| [repo_path, d] }
133
+ .map { |d| [repo.path, d] }
94
134
  end
95
135
 
96
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
23
- $stderr.puts " #{args.join(" ")}"
24
+ if !@quiet && (@verbose || @dry_run)
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.1"
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.2.1
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.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.3
59
+ rubygems_version: 4.0.20
57
60
  specification_version: 4
58
61
  summary: Layered dotfiles management with GNU Stow
59
62
  test_files: []