hashdiff 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99e198ac76f687f33cb2537c32bd81ccdd45798502cba565a624d1107a7e85ca
4
- data.tar.gz: 7eba9f454570311e7b784d3da51db6827331f6e9c73d98d113b407d57bc60626
3
+ metadata.gz: e1cbec06648aef6ff8f519bab7e12a667121f4dec64a603f172198d818f245bb
4
+ data.tar.gz: fe86c5abd6c6c61b8820a3c90e3f09aa56233087831932f75da507e05212858d
5
5
  SHA512:
6
- metadata.gz: e8bf11d2563db1d99348a17bd6074e51cfd35dc95968bf43eca5f115b7af7547c5a08457b031b880442e4d8111343d3e096c8d5075c83a63c858fcd8627d4140
7
- data.tar.gz: 6b318cd78d7f8dac01d1fea139fbc81d4724dc2c9a7f4cb0b21d5c54b38da5384908e6ba91b40503d7c501d596d4f91ecaf348122679f62fb91f0a2dc6e8e30a
6
+ metadata.gz: e09cd08b570c6fb894bb4e904369f64061e78125a8b83462b22d5b37fb9b549afcd501a1bfc839e8aaf02ce552b420b121d08c12109ed74b47e2c8dc8b1b7957
7
+ data.tar.gz: ede8595360f0da6f409c4955d979f82fe6f72df3434485cb036d0277b6173fcb64371a6d810f89c2c0eeb55e850a16bfbc27c195d8191337d800c25eff5967d6
data/RELEASE.md ADDED
@@ -0,0 +1,76 @@
1
+ # Releasing Hashdiff
2
+
3
+ Prepare releases directly on `master`: update the changelog and version, make a
4
+ release commit, and push it to `origin/master`. Previous releases followed this
5
+ process (for example, `8846a2c` for 1.2.1). A release PR is not required.
6
+
7
+ ## Prepare the release
8
+
9
+ 1. Start with a clean working tree and bring `master` up to date:
10
+
11
+ ```sh
12
+ git checkout master
13
+ git pull --ff-only origin master
14
+ git fetch --tags origin
15
+ git status --short
16
+ ```
17
+
18
+ 2. Review changes since the previous release, replacing `v1.2.1` with the
19
+ previous release tag:
20
+
21
+ ```sh
22
+ git log --oneline v1.2.1..HEAD
23
+ git diff v1.2.1..HEAD -- lib spec
24
+ ```
25
+
26
+ 3. Update `Hashdiff::VERSION` in `lib/hashdiff/version.rb`. The gemspec reads
27
+ this constant; it does not have a separate version to update.
28
+
29
+ 4. Add a section at the top of `changelog.md` with the matching version and
30
+ release date (`YYYY-MM-DD`). Summarize changes users will notice and link to
31
+ the relevant pull requests and contributors.
32
+
33
+ ## Validate and push
34
+
35
+ The commands below use 1.2.2 as an example; substitute the version being released.
36
+
37
+ 1. Install development dependencies and run the same checks as CI (RSpec and
38
+ RuboCop):
39
+
40
+ ```sh
41
+ bundle install
42
+ bundle exec rake
43
+ ```
44
+
45
+ 2. Stage and review the release files, then build the gem. The gemspec uses
46
+ `git ls-files`, so stage any new files that should be packaged before building.
47
+ Generated `.gem` files are ignored and should not be committed.
48
+
49
+ ```sh
50
+ git add changelog.md lib/hashdiff/version.rb RELEASE.md
51
+ git diff --cached --check
52
+ git diff --cached
53
+ gem build hashdiff.gemspec
54
+ ```
55
+
56
+ 3. Commit and push directly to `master`, then check the GitHub Actions result:
57
+
58
+ ```sh
59
+ git commit -m "Release 1.2.2"
60
+ git push origin master
61
+ ```
62
+
63
+ ## Publish the gem
64
+
65
+ Pushing to `master` runs CI; it does not publish to RubyGems. When publishing the
66
+ gem, wait for CI to pass and use a maintainer account with RubyGems push access.
67
+ From the release commit, tag the release and publish the gem built above:
68
+
69
+ ```sh
70
+ git tag v1.2.2
71
+ git push origin v1.2.2
72
+ gem push hashdiff-1.2.2.gem
73
+ ```
74
+
75
+ Confirm that RubyGems lists the new version. If a version has already been
76
+ published, prepare a new version rather than reusing it or moving its release tag.
data/changelog.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Change Log
2
2
 
3
+ ## v1.2.2 2026-11-08
4
+
5
+ * Fix array diffs with `use_lcs: false` so that `patch!` can replay them correctly [#102](https://github.com/liufengyun/hashdiff/pull/102) ([@oboxodo](https://github.com/oboxodo))
6
+ * Reduce LCS memory use and improve performance for equal arrays and arrays with common leading or trailing items [#103](https://github.com/liufengyun/hashdiff/pull/103) ([@oboxodo](https://github.com/oboxodo))
7
+
3
8
  ## v1.2.1 2025-09-06
4
9
 
5
10
  * Use HTTPS for the source in the Gemfile [#101](https://github.com/liufengyun/hashdiff/issues/101) ([@krzysiek1507](https://github.com/krzysiek1507))
data/lib/hashdiff/lcs.rb CHANGED
@@ -6,61 +6,125 @@ module Hashdiff
6
6
  # caculate array difference using LCS algorithm
7
7
  # http://en.wikipedia.org/wiki/Longest_common_subsequence_problem
8
8
  def self.lcs(arraya, arrayb, options = {})
9
- return [] if arraya.empty? || arrayb.empty?
9
+ Lcs.call(arraya, arrayb, options)
10
+ end
11
+
12
+ # @private
13
+ #
14
+ # Finds the pairs [index in arraya, index in arrayb] of similar items that
15
+ # make up the longest common subsequence of the two arrays.
16
+ class Lcs
17
+ def self.call(arraya, arrayb, options = {})
18
+ return [] if arraya.empty? || arrayb.empty?
19
+
20
+ new(arraya, arrayb, options).call
21
+ end
10
22
 
11
- opts = { similarity: 0.8 }.merge!(options)
23
+ # The table costs O(n * m), so it only covers the items between the common
24
+ # prefix and suffix. The result is the same as with a full table.
25
+ def call
26
+ pair_common_suffix
27
+ start = common_prefix_length
28
+ backtrack(build_table(start), start)
29
+ backtrack_through_prefix
30
+ pairs.reverse
31
+ end
12
32
 
13
- opts[:prefix] = prefix_append_array_index(opts[:prefix], '*', opts)
33
+ private
14
34
 
15
- a_start = b_start = 0
16
- a_finish = arraya.size - 1
17
- b_finish = arrayb.size - 1
18
- vector = []
35
+ attr_reader :arraya, :arrayb, :options, :pairs
36
+ # the items the backtrack is at, walking from the last ones to the first
37
+ attr_accessor :a_index, :b_index
19
38
 
20
- lcs = []
21
- (b_start..b_finish).each do |bi|
22
- lcs[bi] = []
23
- (a_start..a_finish).each do |ai|
24
- if similar?(arraya[ai], arrayb[bi], opts)
25
- topleft = (ai > 0) && (bi > 0) ? lcs[bi - 1][ai - 1][1] : 0
26
- lcs[bi][ai] = [:topleft, topleft + 1]
27
- elsif (top = bi > 0 ? lcs[bi - 1][ai][1] : 0)
28
- left = ai > 0 ? lcs[bi][ai - 1][1] : 0
29
- count = top > left ? top : left
39
+ def initialize(arraya, arrayb, options)
40
+ @arraya = arraya
41
+ @arrayb = arrayb
42
+ @options = { similarity: 0.8 }.merge!(options)
43
+ @options[:prefix] = Hashdiff.prefix_append_array_index(@options[:prefix], '*', @options)
44
+ @pairs = []
45
+ self.a_index = arraya.size - 1
46
+ self.b_index = arrayb.size - 1
47
+ end
30
48
 
31
- direction = if top > left
32
- :top
33
- elsif top < left
34
- :left
35
- elsif bi.zero?
36
- :top
37
- elsif ai.zero?
38
- :left
39
- else
40
- :both
41
- end
49
+ # The backtrack starts at the last items and pairs them whenever they are
50
+ # similar, so it always pairs the common suffix item by item.
51
+ def pair_common_suffix
52
+ pair_and_step while a_index >= 0 && b_index >= 0 && similar_at?(a_index, b_index)
53
+ end
42
54
 
43
- lcs[bi][ai] = [direction, count]
55
+ def common_prefix_length
56
+ length = 0
57
+ length += 1 while length <= a_index && length <= b_index && similar_at?(length, length)
58
+ length
59
+ end
60
+
61
+ # Each cell holds the length of the LCS of the items up to it, and the
62
+ # direction the backtrack takes from it. When both directions give the
63
+ # same length it goes left.
64
+ #
65
+ # Past a common prefix, the lengths in a full table are the ones in this
66
+ # table plus the prefix length, so both backtracks take the same steps.
67
+ def build_table(start)
68
+ a_items = arraya[start...a_index + 1]
69
+ b_items = arrayb[start...b_index + 1]
70
+ table = []
71
+ b_items.each_with_index do |b_item, bi|
72
+ above = table[bi - 1] if bi > 0
73
+ row = table[bi] = []
74
+ a_items.each_with_index do |a_item, ai|
75
+ row[ai] = if Hashdiff.similar?(a_item, b_item, options)
76
+ [:topleft, (above && ai > 0 ? above[ai - 1][1] : 0) + 1]
77
+ else
78
+ top = above ? above[ai][1] : 0
79
+ left = ai > 0 ? row[ai - 1][1] : 0
80
+ top > left ? [:top, top] : [:left, left]
81
+ end
44
82
  end
45
83
  end
84
+ table
46
85
  end
47
86
 
48
- x = a_finish
49
- y = b_finish
50
- while (x >= 0) && (y >= 0) && (lcs[y][x][1] > 0)
51
- if lcs[y][x][0] == :both
52
- x -= 1
53
- elsif lcs[y][x][0] == :topleft
54
- vector.insert(0, [x, y])
55
- x -= 1
56
- y -= 1
57
- elsif lcs[y][x][0] == :top
58
- y -= 1
59
- elsif lcs[y][x][0] == :left
60
- x -= 1
87
+ # Cells of length 0 point left, so once the middle has no more pairs the
88
+ # backtrack leaves the table into the prefix, as a full table's would.
89
+ def backtrack(table, start)
90
+ while a_index >= start && b_index >= start
91
+ direction = table[b_index - start][a_index - start].first
92
+ if direction == :topleft
93
+ pair_and_step
94
+ elsif direction == :top
95
+ self.b_index = b_index - 1
96
+ else
97
+ self.a_index = a_index - 1
98
+ end
61
99
  end
62
100
  end
63
101
 
64
- vector
102
+ # Takes the steps a full table would take once an index is in the prefix:
103
+ # it pairs similar items and otherwise steps towards the diagonal, where
104
+ # items are known to be similar. So the last prefix item can pair with a
105
+ # later copy:
106
+ #
107
+ # lcs(%w[x w], %w[x x z]) # => [[0, 1]]
108
+ def backtrack_through_prefix
109
+ while a_index >= 0 && b_index >= 0
110
+ if a_index == b_index || similar_at?(a_index, b_index)
111
+ pair_and_step
112
+ elsif b_index > a_index
113
+ self.b_index = b_index - 1
114
+ else
115
+ self.a_index = a_index - 1
116
+ end
117
+ end
118
+ end
119
+
120
+ def similar_at?(index_a, index_b)
121
+ Hashdiff.similar?(arraya[index_a], arrayb[index_b], options)
122
+ end
123
+
124
+ def pair_and_step
125
+ pairs << [a_index, b_index]
126
+ self.a_index = a_index - 1
127
+ self.b_index = b_index - 1
128
+ end
65
129
  end
66
130
  end
@@ -16,9 +16,6 @@ module Hashdiff
16
16
 
17
17
  self.old_index = 0
18
18
  self.new_index = 0
19
- # by comparing the array lengths we can expect that a number of items
20
- # are either added or removed
21
- self.expected_additions = new_array.length - old_array.length
22
19
 
23
20
  loop do
24
21
  if extra_items_in_old_array?
@@ -40,7 +37,7 @@ module Hashdiff
40
37
  private
41
38
 
42
39
  attr_reader :old_array, :new_array, :options, :additions, :deletions, :differences
43
- attr_accessor :old_index, :new_index, :expected_additions
40
+ attr_accessor :old_index, :new_index
44
41
 
45
42
  def initialize(old_array, new_array, options)
46
43
  @old_array = old_array
@@ -60,6 +57,12 @@ module Hashdiff
60
57
  new_index < new_array.length && old_index >= old_array.length
61
58
  end
62
59
 
60
+ # by comparing the lengths of what is left of each array we can expect
61
+ # that a number of items are either added or removed
62
+ def expected_additions
63
+ (new_array.length - new_index) - (old_array.length - old_index)
64
+ end
65
+
63
66
  def iterated_through_both_arrays?
64
67
  old_index >= old_array.length && new_index >= new_array.length
65
68
  end
@@ -69,7 +72,7 @@ module Hashdiff
69
72
  return if difference.empty?
70
73
 
71
74
  index_after_additions = index_of_match_after_additions
72
- append_addititions_before_match(index_after_additions)
75
+ append_additions_before_match(index_after_additions)
73
76
 
74
77
  index_after_deletions = index_of_match_after_deletions
75
78
  append_deletions_before_match(index_after_deletions)
@@ -120,11 +123,10 @@ module Hashdiff
120
123
  nil
121
124
  end
122
125
 
123
- def append_addititions_before_match(match_index)
126
+ def append_additions_before_match(match_index)
124
127
  return unless match_index
125
128
 
126
129
  (new_index...match_index).each { |i| append_addition(new_array[i], i) }
127
- self.expected_additions = expected_additions - (match_index - new_index)
128
130
  self.new_index = match_index
129
131
  end
130
132
 
@@ -132,7 +134,6 @@ module Hashdiff
132
134
  return unless match_index
133
135
 
134
136
  (old_index...match_index).each { |i| append_deletion(old_array[i], i) }
135
- self.expected_additions = expected_additions + (match_index - new_index)
136
137
  self.old_index = match_index
137
138
  end
138
139
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashdiff
4
- VERSION = '1.2.1'.freeze
4
+ VERSION = '1.2.2'.freeze
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hashdiff
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.1
4
+ version: 1.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Liu Fengyun
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-09-06 00:00:00.000000000 Z
11
+ date: 2026-11-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bluecloth
@@ -97,6 +97,7 @@ files:
97
97
  - Gemfile
98
98
  - LICENSE
99
99
  - README.md
100
+ - RELEASE.md
100
101
  - Rakefile
101
102
  - changelog.md
102
103
  - hashdiff.gemspec