dotsync 0.2.2 → 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: 0da622d8ef82d600401b7464c4ad28be7872fa942c252d00273378bcbdab2788
4
- data.tar.gz: db97edbc75bb40ddd8353d97d2b3d255da9caa298dde368a58963e1f000bbc2d
3
+ metadata.gz: 710780a2db7e818a04b5eca4d4546b3fb6f085fc4b0eaba11df1bcab6bfc0ebd
4
+ data.tar.gz: 4e41f76afbbce3996c723e681d9af06d6babd2e69533b6b67ac687fe15e06864
5
5
  SHA512:
6
- metadata.gz: f1f7fd2a2bb4d39df4c8a4286fb4ce750b55f5e3d4b0fb1a1bfa6a7902f5e6106919bb4dd1981057c583931fd4bf6534c64dc8161175caf38159bcb9dd5f93d5
7
- data.tar.gz: a79078d32865500b76f5a27959bc426523a73ea31ec966aee7085b4786e024f9b7716dce1cb014323848228c64706618b91a9dfcd1a8ff5913817f165996c426
6
+ metadata.gz: 57e5da2e7b69b306a01709a5d6ebcc2e2d7cfd7fdd767bae7ab2bbc9957034abbd6dca04a9d0d237b6cd442ae6ac570c57bce15761ebabe865dc2730b1fb3558
7
+ data.tar.gz: adf1fb97f5fa7ca956653617c2d574f556e9bb6c9ed7407384e480de7f6694bd770776eed99d8a0474d7b1399d8f0616948e8e4d12e4aab0f242681371c2ab9f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,58 @@
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
+
25
+ ## [0.2.3] - 2026-02-08
26
+
27
+ **New Features:**
28
+ - Add per-mapping post-sync hooks that run commands after files are transferred
29
+ - `post_sync` — runs after sync in both directions (`[[sync]]` mappings)
30
+ - `post_push` — runs only after push (`[[sync]]` and `[[push]]` mappings)
31
+ - `post_pull` — runs only after pull (`[[sync]]` and `[[pull]]` mappings)
32
+ - Template variables: `{files}` (shell-quoted changed dest paths), `{src}`, `{dest}`
33
+ - Hooks only execute when files actually changed and only with `--apply`
34
+ - Hook failures log errors but do not abort remaining hooks or mappings
35
+ - Preview mode shows what commands would run without executing
36
+ - Add `HookError` error class for hook-related errors
37
+ - Add hook icon (󰜎) to mappings legend with custom icon support
38
+
39
+ **New Files:**
40
+ - `lib/dotsync/utils/hook_runner.rb` — HookRunner utility with execute, preview, and template expansion
41
+ - `spec/dotsync/utils/hook_runner_spec.rb` — Comprehensive tests for HookRunner
42
+
43
+ **Documentation:**
44
+ - Add "Post-Sync Hooks" section to README with hook types, examples, template variables, and real-world use cases
45
+ - Add post-sync hooks to Key Features and Table of Contents
46
+
47
+ **Testing:**
48
+ - Add 35 new test examples covering hooks across all layers
49
+ - HookRunner: template expansion, multiple commands, failure handling, shell-escaped paths, preview mode
50
+ - Mapping: hooks attribute, has_hooks?, hook icon display
51
+ - SyncMappings: direction resolution, array concatenation, shorthand hooks, validation of invalid keys
52
+ - PushActionConfig/PullActionConfig: hook extraction, validation of unidirectional constraints
53
+ - PushAction/PullAction: hook execution with changes, skipped without changes, skipped in dry-run
54
+ - Total: 467 examples, 0 failures | Line: 96.45% | Branch: 82.86%
55
+
1
56
  ## [0.2.2] - 2025-02-07
2
57
 
3
58
  **New Features:**
@@ -17,7 +72,6 @@
17
72
  - Add integration tests for glob patterns in FileTransfer (including force mode)
18
73
  - Add integration tests for glob patterns in DirectoryDiffer
19
74
  - All 432 tests pass with 96.29% line coverage
20
-
21
75
  ## [0.2.1] - 2025-02-06
22
76
 
23
77
  **Performance Optimizations:**
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dotsync (0.2.2)
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,8 @@ 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`
23
+ - **Post-Sync Hooks**: Run commands automatically after files change (e.g., codesigning, chmod, service reload)
22
24
  - **Customizable Output**: Control verbosity and customize icons to match your preferences
23
25
  - **Auto-Updates**: Get notified when new versions are available
24
26
 
@@ -33,6 +35,8 @@ Dotsync is a powerful Ruby gem for managing and synchronizing your dotfiles acro
33
35
  - [Bidirectional Sync Mappings (Recommended)](#bidirectional-sync-mappings-recommended)
34
36
  - [Alternative: Unidirectional Mappings](#alternative-unidirectional-mappings)
35
37
  - [Mapping Options (force, only, ignore)](#force-only-and-ignore-options-in-mappings)
38
+ - [Post-Sync Hooks](#post-sync-hooks)
39
+ - [Config Includes](#config-includes)
36
40
  - [Safety Features](#safety-features)
37
41
  - [Customizing Icons](#customizing-icons)
38
42
  - [Automatic Update Checks](#automatic-update-checks)
@@ -499,6 +503,130 @@ This configuration:
499
503
 
500
504
  These options apply when the source is a directory and are relevant for both `push` and `pull` operations.
501
505
 
506
+ #### Post-Sync Hooks
507
+
508
+ Hooks let you run commands automatically after a mapping's files are transferred. Hooks only execute when files actually changed, and only when using `--apply`.
509
+
510
+ ##### Hook Types
511
+
512
+ | Hook | Description | Valid in |
513
+ |------|-------------|----------|
514
+ | `post_sync` | Runs after sync in both directions | `[[sync]]` mappings |
515
+ | `post_push` | Runs only after push | `[[sync]]` and `[[push]]` mappings |
516
+ | `post_pull` | Runs only after pull | `[[sync]]` and `[[pull]]` mappings |
517
+
518
+ For sync mappings, hooks are resolved by direction:
519
+ - **Push**: `post_sync` + `post_push` commands
520
+ - **Pull**: `post_sync` + `post_pull` commands
521
+
522
+ ##### Examples
523
+
524
+ **Single command (shorthand mapping):**
525
+ ```toml
526
+ [[sync.xdg_bin]]
527
+ force = true
528
+
529
+ [sync.xdg_bin.hooks]
530
+ post_sync = "codesign -s - {files}"
531
+ ```
532
+
533
+ **Multiple commands (explicit sync):**
534
+ ```toml
535
+ [[sync.mappings]]
536
+ local = "$XDG_CONFIG_HOME/scripts"
537
+ remote = "$XDG_CONFIG_HOME_MIRROR/scripts"
538
+
539
+ [sync.mappings.hooks]
540
+ post_sync = ["codesign -s - {files}", "chmod 700 {files}"]
541
+ post_pull = "launchctl kickstart -k gui/$(id -u)/com.example.service"
542
+ ```
543
+
544
+ **Unidirectional mapping:**
545
+ ```toml
546
+ [[pull.mappings]]
547
+ src = "$DOTFILES_DIR/scripts"
548
+ dest = "$HOME/Scripts"
549
+
550
+ [pull.mappings.hooks]
551
+ post_pull = ["codesign -s - {files}", "chmod 700 {files}"]
552
+ ```
553
+
554
+ ##### Template Variables
555
+
556
+ | Variable | Description |
557
+ |----------|-------------|
558
+ | `{files}` | Shell-quoted paths of changed destination files |
559
+ | `{src}` | The mapping's source path |
560
+ | `{dest}` | The mapping's destination path |
561
+
562
+ ##### Real-World Examples
563
+
564
+ ```toml
565
+ # Codesign scripts after pulling (macOS Ventura+ requirement for LaunchAgents)
566
+ [[sync.xdg_bin]]
567
+ force = true
568
+
569
+ [sync.xdg_bin.hooks]
570
+ post_pull = "codesign -s - {files}"
571
+
572
+ # Reload a LaunchAgent after pulling config changes
573
+ [[pull.mappings]]
574
+ src = "$DOTFILES_DIR/LaunchAgents/com.example.plist"
575
+ dest = "$HOME/Library/LaunchAgents/com.example.plist"
576
+
577
+ [pull.mappings.hooks]
578
+ post_pull = "launchctl kickstart -k gui/$(id -u)/com.example"
579
+ ```
580
+
581
+ > [!NOTE]
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.
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
+
502
630
  ### Safety Features
503
631
 
504
632
  Dotsync includes several safety mechanisms to prevent accidental data loss:
@@ -773,21 +901,30 @@ force = true
773
901
 
774
902
  ### Per-Machine Configuration Files
775
903
 
776
- 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
+ ```
777
913
 
778
914
  ```toml
779
- # In dotsync.macbook.toml
780
- [[sync.mappings]]
781
- local = "$XDG_CONFIG_HOME/dotsync.toml"
782
- 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"]
783
921
 
784
- # In dotsync.work.toml
785
922
  [[sync.mappings]]
786
923
  local = "$XDG_CONFIG_HOME/dotsync.toml"
787
- remote = "$XDG_CONFIG_HOME_MIRROR/dotsync/dotsync.work.toml"
924
+ remote = "$XDG_CONFIG_HOME_MIRROR/dotsync/dotsync.mbp_personal.toml"
788
925
  ```
789
926
 
790
- 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:
791
928
  ```shell
792
929
  dotsync -c ~/.config/dotsync/dotsync.macbook.toml push --apply
793
930
  ```
@@ -913,21 +1050,31 @@ dotsync -c ~/my-config.toml setup
913
1050
 
914
1051
  ### Releasing a new version
915
1052
 
916
- 1. Update the version number in `lib/dotsync/version.rb`
917
- 2. Add entry to `CHANGELOG.md` documenting changes
918
- 3. Commit all changes: `git add . && git commit -m "Release vX.Y.Z"`
919
- 4. Create annotated tag with changelog extract:
920
- ```shell
921
- git tag -a vX.Y.Z -m "Release vX.Y.Z
1053
+ **Automated (recommended):**
922
1054
 
923
- <paste relevant CHANGELOG section here>"
924
- ```
925
- 5. Push commits and tags: `git push && git push --tags`
926
- 6. Build and publish gem manually:
927
- ```shell
928
- gem build dotsync.gemspec
929
- gem push dotsync-X.Y.Z.gem
930
- ```
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
+ ```
931
1078
 
932
1079
  The `release.yml` GitHub Action automatically creates a GitHub Release when a version tag is pushed, extracting release notes from CHANGELOG.md.
933
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
 
@@ -23,8 +23,9 @@ module Dotsync
23
23
 
24
24
  MAPPINGS_LEGEND = [
25
25
  [Icons.force, "The source will overwrite the destination"],
26
- [Icons.only, "Paths designated explicitly as source only"],
27
- [Icons.ignore, "Paths configured to be ignored in the destination"],
26
+ [Icons.only, "Filtered by 'only' whitelist"],
27
+ [Icons.ignore, "Filtered by 'ignore' blacklist"],
28
+ [Icons.hook, "Post-sync hooks configured"],
28
29
  [Icons.invalid, "Invalid paths detected in the source or destination"]
29
30
  ]
30
31
 
@@ -135,6 +136,42 @@ module Dotsync
135
136
  end
136
137
  end
137
138
 
139
+ def execute_hooks
140
+ valid_mappings.each_with_index do |mapping, idx|
141
+ next unless mapping.has_hooks?
142
+
143
+ differ = differs[idx]
144
+ changed_files = differ.additions + differ.modifications
145
+ next if changed_files.empty?
146
+
147
+ runner = Dotsync::HookRunner.new(mapping: mapping, changed_files: changed_files, logger: logger)
148
+ runner.execute
149
+ end
150
+ end
151
+
152
+ def show_hooks_preview
153
+ hooks_to_run = []
154
+
155
+ valid_mappings.each_with_index do |mapping, idx|
156
+ next unless mapping.has_hooks?
157
+
158
+ differ = differs[idx]
159
+ changed_files = differ.additions + differ.modifications
160
+ next if changed_files.empty?
161
+
162
+ runner = Dotsync::HookRunner.new(mapping: mapping, changed_files: changed_files, logger: logger)
163
+ hooks_to_run.concat(runner.preview)
164
+ end
165
+
166
+ return if hooks_to_run.empty?
167
+
168
+ info("Hooks to run:", icon: :hook)
169
+ hooks_to_run.each do |command|
170
+ logger.log(" #{command}")
171
+ end
172
+ logger.log("")
173
+ end
174
+
138
175
  private
139
176
  # Computes diffs for all valid mappings.
140
177
  #
@@ -16,6 +16,7 @@ module Dotsync
16
16
  show_mappings if output_sections[:mappings]
17
17
  show_differences_legend if has_differences? && output_sections[:differences_legend]
18
18
  show_differences(diff_content: output_sections[:diff_content]) if output_sections[:differences]
19
+ show_hooks_preview if output_sections[:differences]
19
20
 
20
21
  return unless options[:apply]
21
22
 
@@ -32,6 +33,7 @@ module Dotsync
32
33
  end
33
34
 
34
35
  transfer_mappings
36
+ execute_hooks
35
37
  action("Mappings pulled", icon: :done)
36
38
  end
37
39
 
@@ -14,6 +14,7 @@ module Dotsync
14
14
  show_mappings if output_sections[:mappings]
15
15
  show_differences_legend if has_differences? && output_sections[:differences_legend]
16
16
  show_differences(diff_content: output_sections[:diff_content]) if output_sections[:differences]
17
+ show_hooks_preview if output_sections[:differences]
17
18
 
18
19
  return unless options[:apply]
19
20
 
@@ -23,6 +24,7 @@ module Dotsync
23
24
  end
24
25
 
25
26
  transfer_mappings
27
+ execute_hooks
26
28
  action("Mappings pushed", icon: :done)
27
29
  end
28
30
  end
@@ -96,6 +96,12 @@ module Dotsync
96
96
  base["ignore"] = mapping["ignore"] if mapping.key?("ignore")
97
97
  base["only"] = mapping["only"] if mapping.key?("only")
98
98
 
99
+ # Resolve hooks for direction
100
+ if mapping.key?("hooks")
101
+ resolved = resolve_hooks_for_direction(mapping["hooks"], direction)
102
+ base["hooks"] = resolved if resolved.any?
103
+ end
104
+
99
105
  base
100
106
  end
101
107
 
@@ -142,9 +148,41 @@ module Dotsync
142
148
  base["ignore"] = mapping["ignore"] if mapping.key?("ignore")
143
149
  base["only"] = only if only
144
150
 
151
+ # Resolve hooks for direction
152
+ if mapping.key?("hooks")
153
+ resolved = resolve_hooks_for_direction(mapping["hooks"], direction)
154
+ base["hooks"] = resolved if resolved.any?
155
+ end
156
+
145
157
  base
146
158
  end
147
159
 
160
+ def resolve_hooks_for_direction(raw_hooks, direction)
161
+ return [] unless raw_hooks.is_a?(Hash)
162
+
163
+ hooks = Array(raw_hooks["post_sync"])
164
+ case direction
165
+ when :push
166
+ hooks += Array(raw_hooks["post_push"])
167
+ when :pull
168
+ hooks += Array(raw_hooks["post_pull"])
169
+ end
170
+ hooks
171
+ end
172
+
173
+ def validate_hooks!(hooks, context)
174
+ return unless hooks.is_a?(Hash)
175
+
176
+ valid_keys = %w[post_sync post_push post_pull]
177
+ invalid_keys = hooks.keys - valid_keys
178
+ return if invalid_keys.empty?
179
+
180
+ raise Dotsync::ConfigError,
181
+ "Configuration error in #{context}: " \
182
+ "Invalid hook key(s): #{invalid_keys.join(", ")}. " \
183
+ "Valid keys are: #{valid_keys.join(", ")}"
184
+ end
185
+
148
186
  def build_path(base, path)
149
187
  path ? File.join(base, path) : base
150
188
  end
@@ -171,6 +209,8 @@ module Dotsync
171
209
  "Configuration error in sync.mappings ##{index + 1}: " \
172
210
  "Each mapping must have 'local' and 'remote' keys."
173
211
  end
212
+
213
+ validate_hooks!(mapping["hooks"], "sync.mappings ##{index + 1}") if mapping.key?("hooks")
174
214
  end
175
215
  end
176
216
 
@@ -184,6 +224,8 @@ module Dotsync
184
224
  "Configuration error in sync.#{shorthand_type} ##{index + 1}: " \
185
225
  "Each mapping must be a table."
186
226
  end
227
+
228
+ validate_hooks!(mapping["hooks"], "sync.#{shorthand_type} ##{index + 1}") if mapping.is_a?(Hash) && mapping.key?("hooks")
187
229
  end
188
230
  end
189
231
  end
@@ -22,7 +22,15 @@ module Dotsync
22
22
 
23
23
  def section_mappings
24
24
  return [] unless section && section["mappings"]
25
- Array(section["mappings"]).map { |mapping| Dotsync::Mapping.new(mapping) }
25
+ Array(section["mappings"]).map do |mapping|
26
+ attrs = mapping.dup
27
+ if attrs.key?("hooks") && attrs["hooks"].is_a?(Hash)
28
+ resolved = Array(attrs["hooks"]["post_pull"])
29
+ attrs["hooks"] = resolved.any? ? resolved : nil
30
+ attrs.delete("hooks") unless attrs["hooks"]
31
+ end
32
+ Dotsync::Mapping.new(attrs)
33
+ end
26
34
  end
27
35
 
28
36
  def validate!
@@ -46,6 +54,16 @@ module Dotsync
46
54
  unless mapping.is_a?(Hash) && mapping.key?("src") && mapping.key?("dest")
47
55
  raise "Configuration error in pull mapping ##{index + 1}: Each mapping must have 'src' and 'dest' keys."
48
56
  end
57
+
58
+ if mapping.is_a?(Hash) && mapping.key?("hooks") && mapping["hooks"].is_a?(Hash)
59
+ invalid_keys = mapping["hooks"].keys - ["post_pull"]
60
+ if invalid_keys.any?
61
+ raise Dotsync::ConfigError,
62
+ "Configuration error in pull mapping ##{index + 1}: " \
63
+ "Only 'post_pull' hooks are allowed in [pull] mappings. " \
64
+ "Invalid key(s): #{invalid_keys.join(", ")}"
65
+ end
66
+ end
49
67
  end
50
68
  end
51
69
  end
@@ -17,7 +17,15 @@ module Dotsync
17
17
 
18
18
  def section_mappings
19
19
  return [] unless section && section["mappings"]
20
- Array(section["mappings"]).map { |mapping| Dotsync::Mapping.new(mapping) }
20
+ Array(section["mappings"]).map do |mapping|
21
+ attrs = mapping.dup
22
+ if attrs.key?("hooks") && attrs["hooks"].is_a?(Hash)
23
+ resolved = Array(attrs["hooks"]["post_push"])
24
+ attrs["hooks"] = resolved.any? ? resolved : nil
25
+ attrs.delete("hooks") unless attrs["hooks"]
26
+ end
27
+ Dotsync::Mapping.new(attrs)
28
+ end
21
29
  end
22
30
 
23
31
  def validate!
@@ -41,6 +49,16 @@ module Dotsync
41
49
  unless mapping.is_a?(Hash) && mapping.key?("src") && mapping.key?("dest")
42
50
  raise "Configuration error in push mapping ##{index + 1}: Each mapping must have 'src' and 'dest' keys."
43
51
  end
52
+
53
+ if mapping.is_a?(Hash) && mapping.key?("hooks") && mapping["hooks"].is_a?(Hash)
54
+ invalid_keys = mapping["hooks"].keys - ["post_push"]
55
+ if invalid_keys.any?
56
+ raise Dotsync::ConfigError,
57
+ "Configuration error in push mapping ##{index + 1}: " \
58
+ "Only 'post_push' hooks are allowed in [push] mappings. " \
59
+ "Invalid key(s): #{invalid_keys.join(", ")}"
60
+ end
61
+ end
44
62
  end
45
63
  end
46
64
  end
@@ -8,4 +8,5 @@ module Dotsync
8
8
  class DiskFullError < FileTransferError; end
9
9
  class SymlinkError < FileTransferError; end
10
10
  class TypeConflictError < FileTransferError; end
11
+ class HookError < Error; end
11
12
  end
data/lib/dotsync/icons.rb CHANGED
@@ -18,6 +18,7 @@ module Dotsync
18
18
  DEFAULT_ONLY = " "
19
19
  DEFAULT_IGNORE = "󰈉 "
20
20
  DEFAULT_INVALID = "󱏏 "
21
+ DEFAULT_HOOK = "󰜎 "
21
22
 
22
23
  # Default Mappings Differences icons
23
24
  DEFAULT_DIFF_CREATED = " "
@@ -50,6 +51,7 @@ module Dotsync
50
51
  only: config.dig("icons", "only") || DEFAULT_ONLY,
51
52
  ignore: config.dig("icons", "ignore") || DEFAULT_IGNORE,
52
53
  invalid: config.dig("icons", "invalid") || DEFAULT_INVALID,
54
+ hook: config.dig("icons", "hook") || DEFAULT_HOOK,
53
55
  # Differences Legend
54
56
  diff_created: config.dig("icons", "diff_created") || DEFAULT_DIFF_CREATED,
55
57
  diff_updated: config.dig("icons", "diff_updated") || DEFAULT_DIFF_UPDATED,
@@ -75,6 +77,10 @@ module Dotsync
75
77
  @custom_icons[:invalid] || DEFAULT_INVALID
76
78
  end
77
79
 
80
+ def self.hook
81
+ @custom_icons[:hook] || DEFAULT_HOOK
82
+ end
83
+
78
84
  # Differences Legend methods
79
85
 
80
86
  def self.diff_created
@@ -100,6 +106,7 @@ module Dotsync
100
106
  diff: DIFF,
101
107
  force: -> { force },
102
108
  ignore: -> { ignore },
109
+ hook: DEFAULT_HOOK,
103
110
  pull: PULL,
104
111
  push: PUSH,
105
112
  watch: WATCH,
@@ -13,6 +13,7 @@ require_relative "../utils/directory_differ"
13
13
  require_relative "../utils/config_cache"
14
14
  require_relative "../utils/content_diff"
15
15
  require_relative "../utils/parallel"
16
+ require_relative "../utils/hook_runner"
16
17
 
17
18
  # Models
18
19
  require_relative "../models/mapping"
@@ -13,6 +13,7 @@ require_relative "../utils/directory_differ"
13
13
  require_relative "../utils/config_cache"
14
14
  require_relative "../utils/content_diff"
15
15
  require_relative "../utils/parallel"
16
+ require_relative "../utils/hook_runner"
16
17
 
17
18
  # Models
18
19
  require_relative "../models/mapping"
@@ -29,6 +29,7 @@ module Dotsync
29
29
  @original_ignores = Array(attributes["ignore"])
30
30
  @original_only = Array(attributes["only"])
31
31
  @force = attributes["force"] || false
32
+ @hooks = Array(attributes["hooks"])
32
33
 
33
34
  @sanitized_src, @sanitized_dest, @sanitized_ignores, @sanitized_only = process_paths(
34
35
  @original_src,
@@ -58,6 +59,12 @@ module Dotsync
58
59
  @force
59
60
  end
60
61
 
62
+ attr_reader :hooks
63
+
64
+ def has_hooks?
65
+ @hooks.any?
66
+ end
67
+
61
68
  def directories?
62
69
  File.directory?(src) && File.directory?(dest)
63
70
  end
@@ -103,6 +110,7 @@ module Dotsync
103
110
  msg << Icons.force if force?
104
111
  msg << Icons.only if has_inclusions?
105
112
  msg << Icons.ignore if has_ignores?
113
+ msg << Icons.hook if has_hooks?
106
114
  msg << Icons.invalid unless valid?
107
115
  msg.join
108
116
  end
@@ -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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "shellwords"
5
+
6
+ module Dotsync
7
+ class HookRunner
8
+ def initialize(mapping:, changed_files:, logger:)
9
+ @mapping = mapping
10
+ @changed_files = changed_files
11
+ @logger = logger
12
+ end
13
+
14
+ def execute
15
+ @mapping.hooks.map do |command|
16
+ expanded = expand_template(command)
17
+ run_command(expanded)
18
+ end
19
+ end
20
+
21
+ def preview
22
+ @mapping.hooks.map { |command| expand_template(command) }
23
+ end
24
+
25
+ private
26
+ def expand_template(command)
27
+ files_str = @changed_files.map { |f| Shellwords.escape(f) }.join(" ")
28
+
29
+ command
30
+ .gsub("{files}", files_str)
31
+ .gsub("{src}", @mapping.src)
32
+ .gsub("{dest}", @mapping.dest)
33
+ end
34
+
35
+ def run_command(command)
36
+ stdout, stderr, status = Open3.capture3(command)
37
+
38
+ if status.success?
39
+ @logger.info("Hook succeeded: #{command}", icon: :hook)
40
+ else
41
+ @logger.error("Hook failed: #{command}")
42
+ @logger.error(" #{stderr.strip}") unless stderr.strip.empty?
43
+ end
44
+
45
+ { command: command, stdout: stdout, stderr: stderr, success: status.success? }
46
+ end
47
+ end
48
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dotsync
4
- VERSION = "0.2.2"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/dotsync.rb CHANGED
@@ -31,7 +31,9 @@ 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"
36
+ require_relative "dotsync/utils/hook_runner"
35
37
 
36
38
  # Models
37
39
  require_relative "dotsync/models/mapping"
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.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sáenz
@@ -334,9 +334,11 @@ 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
341
+ - lib/dotsync/utils/hook_runner.rb
340
342
  - lib/dotsync/utils/logger.rb
341
343
  - lib/dotsync/utils/parallel.rb
342
344
  - lib/dotsync/utils/path_utils.rb