dotsync 0.1.26 → 0.2.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: 89c25a6529e3557dc1383f3facf677f0775053d7db53863b7cc61a5b44d13b11
4
- data.tar.gz: 1cfadbe1c539923fb6bf9c27eafde24beeab527feeaadf2b88d997d762f972b4
3
+ metadata.gz: 492eda1f730e490322f6ff7fe85fd3fb91a1bd337f191f13d9574d63a5a9a326
4
+ data.tar.gz: f797e4c6a06258d312c3264be88cda022cb02f8053ed31159de54ce585ef9e3a
5
5
  SHA512:
6
- metadata.gz: 2b7012240c48cd7b9d84cf8e20d54efa913fd08324720b08d270db0c39e46f87a1f63f124e6d562d06ab2bef7f0fd017bb686e666b40866fc89eceae831de1bf
7
- data.tar.gz: 87f03124b8dc8cad8c3588d3391858414ac0a577416c7a227465702a081c8de9fa36648e5eb5c642ba9b6beae81c19043259c25b536a634cd3bfcd3b83a7cfc9
6
+ metadata.gz: 0d9bd4948c5d7de46e30443ca19ae4fabfc8d0caf3410b2a5e8bb519c8dc27384e032c06db32bd2a9bad041f6642707e8223f4affad58aa540cde4e277663b77
7
+ data.tar.gz: f20438f023966222c67f1a5397dfa8110d0d1cf4985fe67a82f0f8cbe615a9b25bdb88db176db33f1a0bf5109dccb1c5cce4889d74ab259dbd8bdd6e1716c2cf
data/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## [0.2.0] - 2025-02-06
2
+
3
+ **New Features:**
4
+ - Add `--diff-content` CLI option to display git-like unified diff output for modified files
5
+ - Shows actual content changes without needing external tools like `nvim -d`
6
+ - Color-coded output: blue for additions, red for deletions, cyan for hunk headers
7
+ - Automatically skips binary files
8
+ - Works with both `push` and `pull` commands
9
+
10
+ **Performance Optimizations:**
11
+ - Add parallel processing for mapping operations
12
+ - New `Dotsync::Parallel` utility module with thread-pool based execution
13
+ - Diff computation runs in parallel across multiple mappings
14
+ - File transfers execute concurrently for independent mappings
15
+ - Thread-safe error collection and reporting
16
+ - Add directory pruning optimization
17
+ - New `should_prune_directory?` method for early-exit during traversal
18
+ - Skips entire directory subtrees that are ignored or outside inclusion lists
19
+ - Reduces filesystem operations for large excluded directories
20
+
1
21
  ## [0.1.26] - 2025-01-11
2
22
 
3
23
  **Breaking Changes:**
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- dotsync (0.1.26)
4
+ dotsync (0.2.0)
5
5
  fileutils (~> 1.7.3)
6
6
  find (~> 0.2.0)
7
7
  listen (~> 3.9.0)
data/exe/dotsync CHANGED
@@ -51,6 +51,7 @@ opt_parser = OptionParser.new do |opts|
51
51
  --only-config Show only the config section
52
52
  --only-mappings Show only the mappings section
53
53
  -v, --verbose Force showing all available information
54
+ --diff-content Show git-like content diff for modified files
54
55
  --trace Show full error backtraces (for debugging)
55
56
  --version Show version number
56
57
  -h, --help Show this help message
@@ -112,6 +113,10 @@ opt_parser = OptionParser.new do |opts|
112
113
  options[:verbose] = true
113
114
  end
114
115
 
116
+ opts.on("--diff-content", "Show git-like content diff for modified files") do
117
+ options[:diff_content] = true
118
+ end
119
+
115
120
  opts.on("--trace", "Show full error backtraces (for debugging)") do
116
121
  options[:trace] = true
117
122
  end
@@ -62,7 +62,7 @@ module Dotsync
62
62
  logger.log("")
63
63
  end
64
64
 
65
- def show_differences
65
+ def show_differences(diff_content: false)
66
66
  info("Differences:", icon: :diff)
67
67
  differs.flat_map(&:additions).sort.each do |path|
68
68
  logger.log("#{Icons.diff_created}#{path}", color: Colors.diff_additions)
@@ -75,31 +75,45 @@ module Dotsync
75
75
  end
76
76
  logger.log(" No differences") unless has_differences?
77
77
  logger.log("")
78
+
79
+ show_content_diffs if diff_content && has_modifications?
80
+ end
81
+
82
+ def show_content_diffs
83
+ info("Content Differences:", icon: :diff)
84
+ modification_pairs.each do |pair|
85
+ Dotsync::ContentDiff.new(pair[:src], pair[:dest], logger).display
86
+ end
78
87
  end
79
88
 
80
89
  def transfer_mappings
81
- valid_mappings.each do |mapping|
90
+ errors = []
91
+ mutex = Mutex.new
92
+
93
+ Dotsync::Parallel.each(valid_mappings) do |mapping|
82
94
  Dotsync::FileTransfer.new(mapping).transfer
83
95
  rescue Dotsync::PermissionError => e
84
- logger.error("Permission denied: #{e.message}")
85
- logger.info("Try: chmod +w <path> or check file permissions")
96
+ mutex.synchronize { errors << ["Permission denied: #{e.message}", "Try: chmod +w <path> or check file permissions"] }
86
97
  rescue Dotsync::DiskFullError => e
87
- logger.error("Disk full: #{e.message}")
88
- logger.info("Free up disk space and try again")
98
+ mutex.synchronize { errors << ["Disk full: #{e.message}", "Free up disk space and try again"] }
89
99
  rescue Dotsync::SymlinkError => e
90
- logger.error("Symlink error: #{e.message}")
91
- logger.info("Check that symlink target exists and is accessible")
100
+ mutex.synchronize { errors << ["Symlink error: #{e.message}", "Check that symlink target exists and is accessible"] }
92
101
  rescue Dotsync::TypeConflictError => e
93
- logger.error("Type conflict: #{e.message}")
94
- logger.info("Cannot overwrite directory with file or vice versa")
102
+ mutex.synchronize { errors << ["Type conflict: #{e.message}", "Cannot overwrite directory with file or vice versa"] }
95
103
  rescue Dotsync::FileTransferError => e
96
- logger.error("File transfer failed: #{e.message}")
104
+ mutex.synchronize { errors << ["File transfer failed: #{e.message}", nil] }
105
+ end
106
+
107
+ # Report all errors after parallel execution
108
+ errors.each do |error_msg, info_msg|
109
+ logger.error(error_msg)
110
+ logger.info(info_msg) if info_msg
97
111
  end
98
112
  end
99
113
 
100
114
  private
101
115
  def differs
102
- @differs ||= valid_mappings.map do |mapping|
116
+ @differs ||= Dotsync::Parallel.map(valid_mappings) do |mapping|
103
117
  Dotsync::DirectoryDiffer.new(mapping).diff
104
118
  end
105
119
  end
@@ -108,6 +122,14 @@ module Dotsync
108
122
  differs.any? { |differ| differ.any? }
109
123
  end
110
124
 
125
+ def has_modifications?
126
+ differs.any? { |differ| differ.modifications.any? }
127
+ end
128
+
129
+ def modification_pairs
130
+ differs.flat_map(&:modification_pairs)
131
+ end
132
+
111
133
  def confirm_action
112
134
  total_changes = differs.sum { |diff| diff.additions.size + diff.modifications.size + diff.removals.size }
113
135
  logger.log("")
@@ -12,7 +12,8 @@ module Dotsync
12
12
  mappings_legend: !(quiet || options[:no_legend] || options[:no_mappings] || options[:only_diff]),
13
13
  mappings: !(quiet || options[:no_mappings] || options[:only_diff]),
14
14
  differences_legend: !(quiet || options[:no_legend] || options[:no_diff_legend] || options[:no_diff] || options[:only_config] || options[:only_mappings]),
15
- differences: !(quiet || options[:no_diff] || options[:only_mappings] || options[:only_config])
15
+ differences: !(quiet || options[:no_diff] || options[:only_mappings] || options[:only_config]),
16
+ diff_content: options[:diff_content] || false
16
17
  }
17
18
 
18
19
  if verbose
@@ -15,7 +15,7 @@ module Dotsync
15
15
  show_mappings_legend if output_sections[:mappings_legend]
16
16
  show_mappings if output_sections[:mappings]
17
17
  show_differences_legend if has_differences? && output_sections[:differences_legend]
18
- show_differences if output_sections[:differences]
18
+ show_differences(diff_content: output_sections[:diff_content]) if output_sections[:differences]
19
19
 
20
20
  return unless options[:apply]
21
21
 
@@ -13,7 +13,7 @@ module Dotsync
13
13
  show_mappings_legend if output_sections[:mappings_legend]
14
14
  show_mappings if output_sections[:mappings]
15
15
  show_differences_legend if has_differences? && output_sections[:differences_legend]
16
- show_differences if output_sections[:differences]
16
+ show_differences(diff_content: output_sections[:diff_content]) if output_sections[:differences]
17
17
 
18
18
  return unless options[:apply]
19
19
 
@@ -11,6 +11,8 @@ require "terminal-table"
11
11
  require_relative "../utils/file_transfer"
12
12
  require_relative "../utils/directory_differ"
13
13
  require_relative "../utils/config_cache"
14
+ require_relative "../utils/content_diff"
15
+ require_relative "../utils/parallel"
14
16
 
15
17
  # Models
16
18
  require_relative "../models/mapping"
@@ -11,6 +11,8 @@ require "terminal-table"
11
11
  require_relative "../utils/file_transfer"
12
12
  require_relative "../utils/directory_differ"
13
13
  require_relative "../utils/config_cache"
14
+ require_relative "../utils/content_diff"
15
+ require_relative "../utils/parallel"
14
16
 
15
17
  # Models
16
18
  require_relative "../models/mapping"
@@ -3,12 +3,13 @@
3
3
  module Dotsync
4
4
  # Represents the differences between two directories
5
5
  class Diff
6
- attr_reader :additions, :modifications, :removals
6
+ attr_reader :additions, :modifications, :removals, :modification_pairs
7
7
 
8
- def initialize(additions: [], modifications: [], removals: [])
8
+ def initialize(additions: [], modifications: [], removals: [], modification_pairs: [])
9
9
  @additions = additions
10
10
  @modifications = modifications
11
11
  @removals = removals
12
+ @modification_pairs = modification_pairs
12
13
  end
13
14
 
14
15
  def any?
@@ -140,6 +140,16 @@ module Dotsync
140
140
  ignore?(path) || !include?(path)
141
141
  end
142
142
 
143
+ # Returns true if a directory can be entirely skipped during destination walks.
144
+ # A directory should be pruned if:
145
+ # 1. It's ignored, OR
146
+ # 2. It has inclusions AND the path is neither included nor a parent of any inclusion
147
+ def should_prune_directory?(path)
148
+ return true if ignore?(path)
149
+ return false unless has_inclusions?
150
+ !bidirectional_include?(path)
151
+ end
152
+
143
153
  private
144
154
  def has_ignores?
145
155
  @original_ignores.any?
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotsync
4
+ class ContentDiff
5
+ COLORS = {
6
+ header: 103, # Purple for file headers
7
+ hunk: 36, # Cyan for @@ lines
8
+ addition: 34, # Blue for added lines (consistent with diff_additions)
9
+ deletion: 88, # Red for removed lines (consistent with diff_removals)
10
+ context: 240 # Gray for context lines
11
+ }.freeze
12
+
13
+ def initialize(src_path, dest_path, logger)
14
+ @src_path = src_path
15
+ @dest_path = dest_path
16
+ @logger = logger
17
+ end
18
+
19
+ def display
20
+ return unless displayable?
21
+
22
+ diff_output = generate_diff
23
+ return if diff_output.empty?
24
+
25
+ display_header
26
+ colorize_and_display(diff_output)
27
+ end
28
+
29
+ private
30
+ def displayable?
31
+ text_file?(@src_path) && text_file?(@dest_path)
32
+ end
33
+
34
+ def text_file?(path)
35
+ return false unless File.file?(path)
36
+
37
+ # Check if file appears to be text by reading first few bytes
38
+ begin
39
+ sample = File.read(path, 8192) || ""
40
+ # Binary files typically contain null bytes
41
+ !sample.include?("\x00")
42
+ rescue StandardError
43
+ false
44
+ end
45
+ end
46
+
47
+ def generate_diff
48
+ # Use system diff with unified format
49
+ # diff returns exit code 1 when files differ, so we can't use backticks directly
50
+ output = `diff -u "#{@dest_path}" "#{@src_path}" 2>/dev/null`
51
+ output.lines.drop(2).join # Drop the first two header lines, we'll add our own
52
+ rescue StandardError
53
+ ""
54
+ end
55
+
56
+ def display_header
57
+ @logger.log("--- #{@dest_path}", color: COLORS[:header])
58
+ @logger.log("+++ #{@src_path}", color: COLORS[:header])
59
+ end
60
+
61
+ def colorize_and_display(diff_output)
62
+ diff_output.each_line do |line|
63
+ color = line_color(line)
64
+ @logger.log(line.chomp, color: color)
65
+ end
66
+ @logger.log("")
67
+ end
68
+
69
+ def line_color(line)
70
+ case line[0]
71
+ when "+"
72
+ COLORS[:addition]
73
+ when "-"
74
+ COLORS[:deletion]
75
+ when "@"
76
+ COLORS[:hunk]
77
+ else
78
+ COLORS[:context]
79
+ end
80
+ end
81
+ end
82
+ end
@@ -31,6 +31,7 @@ module Dotsync
31
31
  def diff_mapping_directories
32
32
  additions = []
33
33
  modifications = []
34
+ modification_pairs = []
34
35
  removals = []
35
36
 
36
37
  Find.find(mapping_src) do |src_path|
@@ -48,6 +49,7 @@ module Dotsync
48
49
  elsif File.file?(src_path) && File.file?(dest_path)
49
50
  if files_differ?(src_path, dest_path)
50
51
  modifications << rel_path
52
+ modification_pairs << { rel_path: rel_path, src: src_path, dest: dest_path }
51
53
  end
52
54
  end
53
55
  end
@@ -59,6 +61,12 @@ module Dotsync
59
61
 
60
62
  src_path = File.join(mapping_src, rel_path)
61
63
 
64
+ # Prune entire directory trees that are outside the inclusion list or ignored
65
+ if File.directory?(dest_path) && @mapping.should_prune_directory?(src_path)
66
+ Find.prune
67
+ next
68
+ end
69
+
62
70
  next if @mapping.skip?(src_path)
63
71
 
64
72
  if !File.exist?(src_path)
@@ -67,21 +75,29 @@ module Dotsync
67
75
  end
68
76
  end
69
77
 
78
+ filtered_modifications = filter_ignores(modifications)
79
+ modification_pairs = modification_pairs.select { |pair| filtered_modifications.include?(pair[:rel_path]) }
80
+
70
81
  additions = relative_to_absolute(filter_ignores(additions), mapping_original_dest)
71
- modifications = relative_to_absolute(filter_ignores(modifications), mapping_original_dest)
82
+ modifications = relative_to_absolute(filtered_modifications, mapping_original_dest)
72
83
  removals = relative_to_absolute(filter_ignores(removals), mapping_original_dest)
73
84
 
74
- Dotsync::Diff.new(additions: additions, modifications: modifications, removals: removals)
85
+ Dotsync::Diff.new(additions: additions, modifications: modifications, removals: removals, modification_pairs: modification_pairs)
75
86
  end
76
87
 
77
88
  def diff_mapping_files
78
- Dotsync::Diff.new.tap do |diff|
79
- if @mapping.file_present_in_src_only?
80
- diff.additions << @mapping.original_dest
81
- elsif @mapping.file_changed?
82
- diff.modifications << @mapping.original_dest
83
- end
89
+ additions = []
90
+ modifications = []
91
+ modification_pairs = []
92
+
93
+ if @mapping.file_present_in_src_only?
94
+ additions << @mapping.original_dest
95
+ elsif @mapping.file_changed?
96
+ modifications << @mapping.original_dest
97
+ modification_pairs << { rel_path: File.basename(@mapping.original_dest), src: @mapping.src, dest: @mapping.dest }
84
98
  end
99
+
100
+ Dotsync::Diff.new(additions: additions, modifications: modifications, modification_pairs: modification_pairs)
85
101
  end
86
102
 
87
103
  def filter_ignores(all_paths)
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dotsync
4
+ # Simple thread-based parallel execution for independent operations.
5
+ # Uses a configurable thread pool to process items concurrently.
6
+ module Parallel
7
+ # Default number of threads (matches typical CPU core count)
8
+ DEFAULT_THREADS = 4
9
+
10
+ # Executes a block for each item in the collection using parallel threads.
11
+ # Returns results in the same order as the input collection.
12
+ #
13
+ # @param items [Array] Collection of items to process
14
+ # @param threads [Integer] Number of parallel threads (default: 4)
15
+ # @yield [item] Block to execute for each item
16
+ # @return [Array] Results in same order as input
17
+ #
18
+ # @example
19
+ # results = Dotsync::Parallel.map(urls, threads: 8) do |url|
20
+ # fetch(url)
21
+ # end
22
+ def self.map(items, threads: DEFAULT_THREADS, &block)
23
+ return [] if items.empty?
24
+ return items.map(&block) if items.size == 1
25
+
26
+ # Limit threads to item count
27
+ thread_count = [threads, items.size].min
28
+
29
+ # Create indexed work items
30
+ work_queue = Queue.new
31
+ items.each_with_index { |item, idx| work_queue << [idx, item] }
32
+
33
+ # Results array (pre-sized for thread safety with index assignment)
34
+ results = Array.new(items.size)
35
+ mutex = Mutex.new
36
+ errors = []
37
+
38
+ # Spawn worker threads
39
+ workers = thread_count.times.map do
40
+ Thread.new do
41
+ loop do
42
+ idx, item = work_queue.pop(true) rescue break
43
+ begin
44
+ results[idx] = yield(item)
45
+ rescue => e
46
+ mutex.synchronize { errors << e }
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ # Wait for completion
53
+ workers.each(&:join)
54
+
55
+ # Re-raise first error if any occurred
56
+ raise errors.first unless errors.empty?
57
+
58
+ results
59
+ end
60
+
61
+ # Executes a block for each item in parallel, ignoring return values.
62
+ # Useful for side-effect operations like file transfers.
63
+ #
64
+ # @param items [Array] Collection of items to process
65
+ # @param threads [Integer] Number of parallel threads (default: 4)
66
+ # @yield [item] Block to execute for each item
67
+ def self.each(items, threads: DEFAULT_THREADS, &block)
68
+ map(items, threads: threads, &block)
69
+ nil
70
+ end
71
+ end
72
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dotsync
4
- VERSION = "0.1.26"
4
+ VERSION = "0.2.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/parallel"
34
35
 
35
36
  # Models
36
37
  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.1.26
4
+ version: 0.2.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/content_diff.rb
337
338
  - lib/dotsync/utils/directory_differ.rb
338
339
  - lib/dotsync/utils/file_transfer.rb
339
340
  - lib/dotsync/utils/logger.rb
341
+ - lib/dotsync/utils/parallel.rb
340
342
  - lib/dotsync/utils/path_utils.rb
341
343
  - lib/dotsync/utils/version_checker.rb
342
344
  - lib/dotsync/version.rb