dotsync 0.2.3 → 0.3.0

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: c060e4f84252048c1b038e350efcf900bc5b62b6bab5285dc9b1a2a9d0d0da0f
4
- data.tar.gz: 5bef34fde923690c3ffaf68b3d5ed77d2195006d91d8b4f2c59853533c285339
3
+ metadata.gz: 710780a2db7e818a04b5eca4d4546b3fb6f085fc4b0eaba11df1bcab6bfc0ebd
4
+ data.tar.gz: 4e41f76afbbce3996c723e681d9af06d6babd2e69533b6b67ac687fe15e06864
5
5
  SHA512:
6
- metadata.gz: 7049966bedd67e25b008ad9a7bae1697f54d189dc7f666bd42495a242af68b8c9494ead4c3e5310e500ce516db47270d5ae0f0382035b50df5d09543a631192f
7
- data.tar.gz: 7abedab0ed8d49946e5b832ad38390968f17c6a7e32da3a3d774a47117767f1eec9dc3cbefd20a2f815eed5b091dcab73c4e9612b21d67fb1b7f85a0b8499ed1
6
+ metadata.gz: 57e5da2e7b69b306a01709a5d6ebcc2e2d7cfd7fdd767bae7ab2bbc9957034abbd6dca04a9d0d237b6cd442ae6ac570c57bce15761ebabe865dc2730b1fb3558
7
+ data.tar.gz: adf1fb97f5fa7ca956653617c2d574f556e9bb6c9ed7407384e480de7f6694bd770776eed99d8a0474d7b1399d8f0616948e8e4d12e4aab0f242681371c2ab9f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [0.3.0] - 2026-02-14
2
+
3
+ **New Features:**
4
+ - Add `include` directive for config file composition
5
+ - Base config holds shared content; machine overlays contain only the delta
6
+ - `include = "base.toml"` — resolves path relative to the config file's directory
7
+ - Deep merge semantics: arrays concatenate, hashes merge recursively, scalars overlay wins
8
+ - Chained includes are rejected to keep behavior predictable
9
+ - Cache invalidation tracks included file's mtime and size alongside overlay file
10
+ - Zero downstream changes — `BaseConfig`, `PullActionConfig`, `PushActionConfig`, `SyncMappings` all see a plain merged hash
11
+
12
+ **New Files:**
13
+ - `lib/dotsync/utils/config_merger.rb` — ConfigMerger utility with resolve, deep merge, and include validation
14
+ - `spec/dotsync/utils/config_merger_spec.rb` — Comprehensive tests for ConfigMerger
15
+
16
+ **Documentation:**
17
+ - Add "Config Includes" section to README with usage examples and merge semantics
18
+ - Update "Per-Machine Configuration Files" section with include-based example
19
+
20
+ **Testing:**
21
+ - Add ConfigMerger specs: no-include passthrough, array concatenation, hash deep merge, scalar override, include key consumption, relative path resolution, missing file error, chained include rejection, non-string include error, base/overlay key preservation, empty overlay, include_path accessor
22
+ - Add include-aware ConfigCache specs: mtime/size invalidation, deleted include, metadata contains include stats, no-cache mode with includes
23
+ - Add end-to-end BaseConfig spec: base.toml + overlay.toml with include → to_h returns merged result
24
+
1
25
  ## [0.2.3] - 2026-02-08
2
26
 
3
27
  **New Features:**
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dotsync (0.2.3)
4
+ dotsync (0.3.0)
5
5
  fileutils (~> 1.7.3)
6
6
  find (~> 0.2.0)
7
7
  listen (~> 3.9.0)
data/README.md CHANGED
@@ -19,6 +19,7 @@ Dotsync is a powerful Ruby gem for managing and synchronizing your dotfiles acro
19
19
  - **Smart Filtering**: Use `force`, `only`, and `ignore` options to precisely control what gets synced
20
20
  - **Automatic Backups**: Pull operations create timestamped backups for easy recovery
21
21
  - **Live Watching**: Continuously monitor and sync changes in real-time with `watch` command
22
+ - **Config Includes**: Compose configs from a shared base + machine-specific overlays with `include`
22
23
  - **Post-Sync Hooks**: Run commands automatically after files change (e.g., codesigning, chmod, service reload)
23
24
  - **Customizable Output**: Control verbosity and customize icons to match your preferences
24
25
  - **Auto-Updates**: Get notified when new versions are available
@@ -35,6 +36,7 @@ Dotsync is a powerful Ruby gem for managing and synchronizing your dotfiles acro
35
36
  - [Alternative: Unidirectional Mappings](#alternative-unidirectional-mappings)
36
37
  - [Mapping Options (force, only, ignore)](#force-only-and-ignore-options-in-mappings)
37
38
  - [Post-Sync Hooks](#post-sync-hooks)
39
+ - [Config Includes](#config-includes)
38
40
  - [Safety Features](#safety-features)
39
41
  - [Customizing Icons](#customizing-icons)
40
42
  - [Automatic Update Checks](#automatic-update-checks)
@@ -579,6 +581,52 @@ post_pull = "launchctl kickstart -k gui/$(id -u)/com.example"
579
581
  > [!NOTE]
580
582
  > In preview mode (without `--apply`), hooks are displayed as a preview showing what commands would run. Hook failures log errors but do not abort remaining hooks or mappings.
581
583
 
584
+ #### Config Includes
585
+
586
+ Use `include` to compose a config from a shared base file and a machine-specific overlay. This eliminates duplication when multiple machines share most of the same mappings.
587
+
588
+ ```toml
589
+ # dotsync.mbp_personal.toml (overlay — only the delta)
590
+ include = "dotsync.base.toml"
591
+
592
+ # Machine-specific mappings appended to base
593
+ [[sync.xdg_config]]
594
+ path = "claude"
595
+ only = ["settings.json", "instructions", "commands"]
596
+
597
+ [[sync.mappings]]
598
+ local = "$XDG_CONFIG_HOME/opencode/opencode.jsonc"
599
+ remote = "$XDG_CONFIG_HOME_MIRROR/opencode/opencode.mbp_personal.jsonc"
600
+ ```
601
+
602
+ ```toml
603
+ # dotsync.base.toml (shared across all machines)
604
+ [[sync.home]]
605
+ path = ".zshenv"
606
+
607
+ [[sync.xdg_config]]
608
+ only = ["alacritty", "brewfile", "git", "nvim", "zsh"]
609
+ force = true
610
+
611
+ [watch]
612
+ src = "~/.config"
613
+ paths = ["~/.config/nvim/", "~/.config/zsh/"]
614
+ ```
615
+
616
+ **Merge semantics:**
617
+
618
+ | Type | Behavior | Example |
619
+ |------|----------|---------|
620
+ | Arrays (`[[array-of-tables]]`) | Concatenate (base + overlay) | Base `[[sync.home]]` entries + overlay `[[sync.home]]` entries |
621
+ | Hashes (`[tables]`) | Recursive deep merge (overlay wins on leaves) | Overlay `[watch].dest` overrides base `[watch].dest` |
622
+ | Scalars | Overlay wins | Overlay `force = false` overrides base `force = true` |
623
+
624
+ **Rules:**
625
+ - The `include` path is resolved relative to the overlay file's directory
626
+ - Chained includes (an included file that itself has `include`) are not supported
627
+ - The `include` key is consumed and does not appear in the merged config
628
+ - Cache invalidation tracks both the overlay and included file's mtime/size
629
+
582
630
  ### Safety Features
583
631
 
584
632
  Dotsync includes several safety mechanisms to prevent accidental data loss:
@@ -853,21 +901,30 @@ force = true
853
901
 
854
902
  ### Per-Machine Configuration Files
855
903
 
856
- Use different config files for different machines:
904
+ Use `include` to share a base config across machines, with each machine adding only its delta:
905
+
906
+ ```
907
+ dotfiles/xdg_config_home/dotsync/
908
+ dotsync.base.toml # shared mappings, hooks, watch paths
909
+ dotsync.mbp_personal.toml # include + personal-only mappings
910
+ dotsync.mbp_work.toml # include + work-only mappings
911
+ dotsync.mac_mini.toml # include + mac-mini-only mappings
912
+ ```
857
913
 
858
914
  ```toml
859
- # In dotsync.macbook.toml
860
- [[sync.mappings]]
861
- local = "$XDG_CONFIG_HOME/dotsync.toml"
862
- remote = "$XDG_CONFIG_HOME_MIRROR/dotsync/dotsync.macbook.toml"
915
+ # dotsync.mbp_personal.toml — slim overlay
916
+ include = "dotsync.base.toml"
917
+
918
+ [[sync.xdg_config]]
919
+ path = "claude"
920
+ only = ["settings.json", "instructions", "commands"]
863
921
 
864
- # In dotsync.work.toml
865
922
  [[sync.mappings]]
866
923
  local = "$XDG_CONFIG_HOME/dotsync.toml"
867
- remote = "$XDG_CONFIG_HOME_MIRROR/dotsync/dotsync.work.toml"
924
+ remote = "$XDG_CONFIG_HOME_MIRROR/dotsync/dotsync.mbp_personal.toml"
868
925
  ```
869
926
 
870
- Then use `-c` flag to select the appropriate config:
927
+ Each machine's overlay self-references so `ds push` keeps it in sync with the repo. Use `-c` to select a config:
871
928
  ```shell
872
929
  dotsync -c ~/.config/dotsync/dotsync.macbook.toml push --apply
873
930
  ```
@@ -993,21 +1050,31 @@ dotsync -c ~/my-config.toml setup
993
1050
 
994
1051
  ### Releasing a new version
995
1052
 
996
- 1. Update the version number in `lib/dotsync/version.rb`
997
- 2. Add entry to `CHANGELOG.md` documenting changes
998
- 3. Commit all changes: `git add . && git commit -m "Release vX.Y.Z"`
999
- 4. Create annotated tag with changelog extract:
1000
- ```shell
1001
- git tag -a vX.Y.Z -m "Release vX.Y.Z
1053
+ **Automated (recommended):**
1002
1054
 
1003
- <paste relevant CHANGELOG section here>"
1004
- ```
1005
- 5. Push commits and tags: `git push && git push --tags`
1006
- 6. Build and publish gem manually:
1007
- ```shell
1008
- gem build dotsync.gemspec
1009
- gem push dotsync-X.Y.Z.gem
1010
- ```
1055
+ ```shell
1056
+ # 1. Update version in lib/dotsync/version.rb
1057
+ # 2. Commit all changes
1058
+ # 3. Run the full release workflow:
1059
+ rake release:publish
1060
+ ```
1061
+
1062
+ This generates a CHANGELOG entry from commits, opens it for review, commits, creates an annotated tag (with markdown stripped to plain text), and pushes everything.
1063
+
1064
+ **Individual tasks:**
1065
+
1066
+ ```shell
1067
+ rake release:changelog # Generate CHANGELOG entry from commits since last tag
1068
+ rake release:tag # Create annotated tag from CHANGELOG (uses Dotsync::VERSION)
1069
+ rake release:tag[0.3.0] # Create annotated tag for a specific version
1070
+ ```
1071
+
1072
+ **Publishing the gem:**
1073
+
1074
+ ```shell
1075
+ gem build dotsync.gemspec
1076
+ gem push dotsync-X.Y.Z.gem
1077
+ ```
1011
1078
 
1012
1079
  The `release.yml` GitHub Action automatically creates a GitHub Release when a version tag is pushed, extracting release notes from CHANGELOG.md.
1013
1080
 
data/Rakefile CHANGED
@@ -14,14 +14,30 @@ end
14
14
 
15
15
  task default: :spec
16
16
 
17
+ # Strip markdown formatting for plain-text contexts (git tag messages)
18
+ def strip_markdown(text)
19
+ text
20
+ .gsub(/\*\*(.+?)\*\*/, '\1') # **bold** → bold
21
+ .gsub(/`([^`]+)`/, '\1') # `code` → code
22
+ .gsub(/\[([^\]]+)\]\([^)]+\)/, '\1') # [text](url) → text
23
+ end
24
+
25
+ # Load local version.rb, overriding any gem-installed constant
26
+ def local_version
27
+ verbose = $VERBOSE
28
+ $VERBOSE = nil
29
+ load File.expand_path("lib/dotsync/version.rb", __dir__)
30
+ $VERBOSE = verbose
31
+ Dotsync::VERSION
32
+ end
33
+
17
34
  namespace :release do
18
35
  desc "Generate CHANGELOG entry for a new version"
19
36
  # Usage: rake release:changelog[0.2.1]
20
37
  task :changelog, [:version] do |_t, args|
21
38
  version = args[:version]
22
39
  unless version
23
- require_relative "./lib/dotsync/version"
24
- version = Dotsync::VERSION
40
+ version = local_version
25
41
  end
26
42
  version = version.sub(/^v/, "")
27
43
  today = Date.today.strftime("%Y-%m-%d")
@@ -87,8 +103,7 @@ namespace :release do
87
103
  task :tag, [:version] do |_t, args|
88
104
  version = args[:version]
89
105
  unless version
90
- require_relative "./lib/dotsync/version"
91
- version = Dotsync::VERSION
106
+ version = local_version
92
107
  end
93
108
  version = version.sub(/^v/, "")
94
109
  tag_name = "v#{version}"
@@ -109,11 +124,11 @@ namespace :release do
109
124
  abort "Version #{version} not found in CHANGELOG.md\nRun 'rake release:changelog[#{version}]' first."
110
125
  end
111
126
 
112
- date = match[1]
113
- content = match[2].strip
114
- tag_message = "#{version} - #{date}\n\n#{content}"
127
+ content = strip_markdown(match[2].strip)
128
+ tag_message = "Release v#{version}\n\n#{content}"
115
129
 
116
130
  puts "Tagging commit as #{tag_name}..."
131
+ puts "\n--- Tag message ---\n#{tag_message}\n---\n\n"
117
132
  sh "git", "tag", "-a", tag_name, "-m", tag_message
118
133
  puts "Tag created. Push with: git push origin #{tag_name}"
119
134
  end
@@ -123,8 +138,7 @@ namespace :release do
123
138
  task :publish, [:version] do |_t, args|
124
139
  version = args[:version]
125
140
  unless version
126
- require_relative "./lib/dotsync/version"
127
- version = Dotsync::VERSION
141
+ version = local_version
128
142
  end
129
143
  version = version.sub(/^v/, "")
130
144
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "digest"
5
+ require_relative "config_merger"
5
6
 
6
7
  module Dotsync
7
8
  class ConfigCache
@@ -19,7 +20,7 @@ module Dotsync
19
20
 
20
21
  def load
21
22
  # Skip cache if disabled via environment variable
22
- return parse_toml if ENV["DOTSYNC_NO_CACHE"]
23
+ return resolve_config if ENV["DOTSYNC_NO_CACHE"]
23
24
 
24
25
  return parse_and_cache unless valid_cache?
25
26
 
@@ -47,6 +48,15 @@ module Dotsync
47
48
  cache_age_days = (Time.now.to_f - meta["cached_at"]) / 86400
48
49
  return false if cache_age_days > 7
49
50
 
51
+ # Check include file validity if present
52
+ if meta["include_path"]
53
+ return false unless File.exist?(meta["include_path"])
54
+
55
+ include_stat = File.stat(meta["include_path"])
56
+ return false if include_stat.mtime.to_f != meta["include_mtime"]
57
+ return false if include_stat.size != meta["include_size"]
58
+ end
59
+
50
60
  true
51
61
  rescue StandardError
52
62
  # Any error in validation means invalid cache
@@ -54,7 +64,7 @@ module Dotsync
54
64
  end
55
65
 
56
66
  def parse_and_cache
57
- config = parse_toml
67
+ config = resolve_config
58
68
 
59
69
  # Write cache files
60
70
  FileUtils.mkdir_p(@cache_dir)
@@ -67,6 +77,12 @@ module Dotsync
67
77
  config
68
78
  end
69
79
 
80
+ def resolve_config
81
+ raw = parse_toml
82
+ @merger = ConfigMerger.new(raw, @config_path)
83
+ @merger.resolve
84
+ end
85
+
70
86
  def parse_toml
71
87
  require "toml-rb"
72
88
  TomlRB.load_file(@config_path)
@@ -74,13 +90,22 @@ module Dotsync
74
90
 
75
91
  def build_metadata
76
92
  source_stat = File.stat(@config_path)
77
- {
93
+ meta = {
78
94
  source_path: @config_path,
79
95
  source_size: source_stat.size,
80
96
  source_mtime: source_stat.mtime.to_f,
81
97
  cached_at: Time.now.to_f,
82
98
  dotsync_version: Dotsync::VERSION
83
99
  }
100
+
101
+ if @merger&.include_path
102
+ include_stat = File.stat(@merger.include_path)
103
+ meta[:include_path] = @merger.include_path
104
+ meta[:include_mtime] = include_stat.mtime.to_f
105
+ meta[:include_size] = include_stat.size
106
+ end
107
+
108
+ meta
84
109
  end
85
110
  end
86
111
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "toml-rb"
4
+
5
+ module Dotsync
6
+ class ConfigMerger
7
+ attr_reader :include_path
8
+
9
+ def self.resolve(config_hash, config_path)
10
+ new(config_hash, config_path).resolve
11
+ end
12
+
13
+ def initialize(config_hash, config_path)
14
+ @config = config_hash
15
+ @config_path = config_path
16
+ @include_path = nil
17
+ end
18
+
19
+ def resolve
20
+ return @config unless @config.key?("include")
21
+
22
+ validate_include_value!
23
+ @include_path = resolve_include_path
24
+ validate_include_exists!
25
+
26
+ base_config = load_base_config
27
+ validate_no_chained_includes!(base_config)
28
+
29
+ merged = deep_merge(base_config, overlay)
30
+ merged
31
+ end
32
+
33
+ private
34
+ def validate_include_value!
35
+ unless @config["include"].is_a?(String)
36
+ raise ConfigError, "Config Error: 'include' must be a string path, got #{@config["include"].class}"
37
+ end
38
+ end
39
+
40
+ def resolve_include_path
41
+ include_value = @config["include"]
42
+ config_dir = File.dirname(@config_path)
43
+ File.expand_path(include_value, config_dir)
44
+ end
45
+
46
+ def validate_include_exists!
47
+ unless File.exist?(@include_path)
48
+ raise ConfigError, "Config Error: Included file not found: #{@include_path}"
49
+ end
50
+ end
51
+
52
+ def load_base_config
53
+ TomlRB.load_file(@include_path)
54
+ end
55
+
56
+ def validate_no_chained_includes!(base_config)
57
+ if base_config.key?("include")
58
+ raise ConfigError, "Config Error: Chained includes are not supported (found 'include' in #{@include_path})"
59
+ end
60
+ end
61
+
62
+ def overlay
63
+ @config.reject { |key, _| key == "include" }
64
+ end
65
+
66
+ def deep_merge(base, overlay)
67
+ base.merge(overlay) do |_key, base_val, overlay_val|
68
+ if base_val.is_a?(Hash) && overlay_val.is_a?(Hash)
69
+ deep_merge(base_val, overlay_val)
70
+ elsif base_val.is_a?(Array) && overlay_val.is_a?(Array)
71
+ base_val + overlay_val
72
+ else
73
+ overlay_val
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dotsync
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/dotsync.rb CHANGED
@@ -31,6 +31,7 @@ require_relative "dotsync/utils/file_transfer"
31
31
  require_relative "dotsync/utils/directory_differ"
32
32
  require_relative "dotsync/utils/version_checker"
33
33
  require_relative "dotsync/utils/config_cache"
34
+ require_relative "dotsync/utils/config_merger"
34
35
  require_relative "dotsync/utils/parallel"
35
36
  require_relative "dotsync/utils/hook_runner"
36
37
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dotsync
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sáenz
@@ -334,6 +334,7 @@ files:
334
334
  - lib/dotsync/runner.rb
335
335
  - lib/dotsync/tasks/actions.rake
336
336
  - lib/dotsync/utils/config_cache.rb
337
+ - lib/dotsync/utils/config_merger.rb
337
338
  - lib/dotsync/utils/content_diff.rb
338
339
  - lib/dotsync/utils/directory_differ.rb
339
340
  - lib/dotsync/utils/file_transfer.rb