patch_util 0.1.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.
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module PatchUtil
7
+ module Split
8
+ class PlanStore
9
+ FORMAT_VERSION = 1
10
+
11
+ attr_reader :path
12
+
13
+ def initialize(path:)
14
+ @path = File.expand_path(path)
15
+ end
16
+
17
+ def load
18
+ return PlanSet.new(version: FORMAT_VERSION, entries: []) unless File.exist?(path)
19
+
20
+ payload = JSON.parse(File.read(path))
21
+ entries = []
22
+ payload.fetch('entries', []).each do |entry|
23
+ entries << deserialize_entry(entry)
24
+ end
25
+ PlanSet.new(version: payload.fetch('version', FORMAT_VERSION), entries: entries)
26
+ end
27
+
28
+ def save(plan_set)
29
+ FileUtils.mkdir_p(File.dirname(path))
30
+ File.write(path, JSON.pretty_generate(serialize_plan_set(plan_set)) + "\n")
31
+ end
32
+
33
+ def upsert(plan_set, entry)
34
+ entries = []
35
+ replaced = false
36
+
37
+ plan_set.entries.each do |existing|
38
+ if existing.source_kind == entry.source_kind && existing.source_fingerprint == entry.source_fingerprint
39
+ entries << entry
40
+ replaced = true
41
+ else
42
+ entries << existing
43
+ end
44
+ end
45
+
46
+ entries << entry unless replaced
47
+ PlanSet.new(version: FORMAT_VERSION, entries: entries)
48
+ end
49
+
50
+ private
51
+
52
+ def serialize_plan_set(plan_set)
53
+ entries = []
54
+ plan_set.entries.each do |entry|
55
+ entries << {
56
+ 'source_kind' => entry.source_kind,
57
+ 'source_label' => entry.source_label,
58
+ 'source_fingerprint' => entry.source_fingerprint,
59
+ 'created_at' => entry.created_at,
60
+ 'chunks' => serialize_chunks(entry.chunks)
61
+ }
62
+ end
63
+
64
+ {
65
+ 'version' => plan_set.version,
66
+ 'entries' => entries
67
+ }
68
+ end
69
+
70
+ def serialize_chunks(chunks)
71
+ items = []
72
+ chunks.each do |chunk|
73
+ items << {
74
+ 'name' => chunk.name,
75
+ 'selector_text' => chunk.selector_text,
76
+ 'row_ids' => chunk.row_ids,
77
+ 'change_labels' => chunk.change_labels,
78
+ 'leftovers' => chunk.leftovers?
79
+ }
80
+ end
81
+ items
82
+ end
83
+
84
+ def deserialize_entry(entry)
85
+ chunks = []
86
+ entry.fetch('chunks', []).each do |chunk|
87
+ chunks << Chunk.new(
88
+ name: chunk.fetch('name'),
89
+ selector_text: chunk['selector_text'],
90
+ row_ids: chunk.fetch('row_ids'),
91
+ change_labels: chunk.fetch('change_labels'),
92
+ leftovers: chunk.fetch('leftovers', false)
93
+ )
94
+ end
95
+
96
+ PlanEntry.new(
97
+ source_kind: entry.fetch('source_kind'),
98
+ source_label: entry.fetch('source_label'),
99
+ source_fingerprint: entry.fetch('source_fingerprint'),
100
+ chunks: chunks,
101
+ created_at: entry.fetch('created_at')
102
+ )
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module PatchUtil
6
+ module Split
7
+ class Planner
8
+ def initialize(verifier: Verifier.new)
9
+ @verifier = verifier
10
+ end
11
+
12
+ def build(source:, diff:, chunk_requests:)
13
+ chunks = @verifier.build_chunks(diff: diff, chunk_requests: chunk_requests)
14
+ PlanEntry.new(
15
+ source_kind: source.kind,
16
+ source_label: source.label,
17
+ source_fingerprint: source.fingerprint,
18
+ chunks: chunks,
19
+ created_at: Time.now.utc.iso8601
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Split
5
+ ProjectedFile = Data.define(:old_path, :new_path, :diff_git_line, :metadata_lines, :hunks, :emit_text_headers)
6
+ ProjectedHunk = Data.define(:old_start, :old_count, :new_start, :new_count, :lines, :kind, :patch_lines)
7
+
8
+ class Projector
9
+ def initialize(diff:, plan_entry:)
10
+ @diff = diff
11
+ @plan_entry = plan_entry
12
+ @chunk_index_by_row_id = {}
13
+
14
+ @plan_entry.chunks.each_with_index do |chunk, chunk_index|
15
+ chunk.row_ids.each do |row_id|
16
+ @chunk_index_by_row_id[row_id] = chunk_index
17
+ end
18
+ end
19
+ end
20
+
21
+ def project_chunk(chunk_index)
22
+ projected_files = []
23
+
24
+ @diff.file_diffs.each do |file_diff|
25
+ before_offset = 0
26
+ after_offset = 0
27
+ projected_hunks = []
28
+ path_operation_applied_before = path_operation_applied_before?(file_diff, chunk_index)
29
+ path_operation_applied_after = path_operation_applied_after?(file_diff, chunk_index)
30
+
31
+ file_diff.hunks.each do |hunk|
32
+ case hunk.kind
33
+ when :file_operation
34
+ projected_hunk = project_operation_hunk(hunk, chunk_index)
35
+ projected_hunks << projected_hunk if projected_hunk
36
+ when :binary
37
+ projected_hunk = project_binary_hunk(hunk, chunk_index)
38
+ projected_hunks << projected_hunk if projected_hunk
39
+ else
40
+ changed, projected_hunk = project_text_hunk(hunk, chunk_index, before_offset, after_offset)
41
+ projected_hunks << projected_hunk if changed
42
+
43
+ before_offset += applied_delta(hunk, max_chunk_index: chunk_index - 1)
44
+ after_offset += applied_delta(hunk, max_chunk_index: chunk_index)
45
+ end
46
+ end
47
+
48
+ next if projected_hunks.empty?
49
+
50
+ metadata_lines = projected_hunks.select { |hunk| hunk.kind == :file_operation }.flat_map(&:patch_lines)
51
+ metadata_lines = implicit_metadata_lines(file_diff, metadata_lines) if metadata_lines.empty?
52
+ before_paths = current_paths(file_diff, path_operation_applied: path_operation_applied_before)
53
+ after_paths = current_paths(file_diff, path_operation_applied: path_operation_applied_after)
54
+ binary_only = projected_hunks.all? { |hunk| hunk.kind == :binary }
55
+
56
+ projected_files << ProjectedFile.new(
57
+ old_path: before_paths[:old_path],
58
+ new_path: after_paths[:new_path],
59
+ diff_git_line: build_diff_git_line(before_paths[:old_path], after_paths[:new_path]),
60
+ metadata_lines: metadata_lines,
61
+ hunks: projected_hunks.reject { |hunk| hunk.kind == :file_operation },
62
+ emit_text_headers: !binary_only || emits_text_headers_for_binary?(file_diff)
63
+ )
64
+ end
65
+
66
+ projected_files
67
+ end
68
+
69
+ private
70
+
71
+ def project_operation_hunk(hunk, chunk_index)
72
+ return nil unless rows_assigned_to_chunk?(hunk, chunk_index)
73
+
74
+ ProjectedHunk.new(
75
+ old_start: 0,
76
+ old_count: 0,
77
+ new_start: 0,
78
+ new_count: 0,
79
+ lines: [],
80
+ kind: :file_operation,
81
+ patch_lines: hunk.patch_lines
82
+ )
83
+ end
84
+
85
+ def project_binary_hunk(hunk, chunk_index)
86
+ return nil unless rows_assigned_to_chunk?(hunk, chunk_index)
87
+
88
+ ProjectedHunk.new(
89
+ old_start: 0,
90
+ old_count: 0,
91
+ new_start: 0,
92
+ new_count: 0,
93
+ lines: [],
94
+ kind: :binary,
95
+ patch_lines: hunk.patch_lines
96
+ )
97
+ end
98
+
99
+ def project_text_hunk(hunk, chunk_index, before_offset, after_offset)
100
+ lines = []
101
+ changed = false
102
+ old_count = 0
103
+ new_count = 0
104
+
105
+ hunk.rows.each do |row|
106
+ visible_before = visible_in_before?(row, chunk_index)
107
+ visible_after = visible_in_after?(row, chunk_index)
108
+ changed ||= visible_before != visible_after
109
+
110
+ old_count += 1 if visible_before
111
+ new_count += 1 if visible_after
112
+ next unless visible_before || visible_after
113
+
114
+ prefix = if visible_before && visible_after
115
+ ' '
116
+ elsif visible_before
117
+ '-'
118
+ else
119
+ '+'
120
+ end
121
+ lines << "#{prefix}#{row.text}"
122
+ end
123
+
124
+ [
125
+ changed,
126
+ ProjectedHunk.new(
127
+ old_start: hunk.old_start + before_offset,
128
+ old_count: old_count,
129
+ new_start: hunk.new_start + after_offset,
130
+ new_count: new_count,
131
+ lines: lines,
132
+ kind: :text,
133
+ patch_lines: []
134
+ )
135
+ ]
136
+ end
137
+
138
+ def rows_assigned_to_chunk?(hunk, chunk_index)
139
+ hunk.change_rows.all? { |row| @chunk_index_by_row_id.fetch(row.id) == chunk_index }
140
+ end
141
+
142
+ def applied_delta(hunk, max_chunk_index:)
143
+ return 0 if max_chunk_index.negative?
144
+
145
+ delta = 0
146
+ hunk.change_rows.each do |row|
147
+ chunk_index = @chunk_index_by_row_id.fetch(row.id)
148
+ next if chunk_index > max_chunk_index
149
+
150
+ delta += 1 if row.kind == :addition
151
+ delta -= 1 if row.kind == :deletion
152
+ end
153
+ delta
154
+ end
155
+
156
+ def visible_in_before?(row, chunk_index)
157
+ case row.kind
158
+ when :context
159
+ true
160
+ when :deletion
161
+ @chunk_index_by_row_id.fetch(row.id) >= chunk_index
162
+ when :addition
163
+ @chunk_index_by_row_id.fetch(row.id) < chunk_index
164
+ else
165
+ false
166
+ end
167
+ end
168
+
169
+ def visible_in_after?(row, chunk_index)
170
+ case row.kind
171
+ when :context
172
+ true
173
+ when :deletion
174
+ @chunk_index_by_row_id.fetch(row.id) > chunk_index
175
+ when :addition
176
+ @chunk_index_by_row_id.fetch(row.id) <= chunk_index
177
+ else
178
+ false
179
+ end
180
+ end
181
+
182
+ def current_paths(file_diff, path_operation_applied:)
183
+ operation_hunk = file_diff.path_operation_hunk
184
+ return { old_path: file_diff.old_path, new_path: file_diff.new_path } unless operation_hunk
185
+
186
+ if path_operation_applied
187
+ { old_path: file_diff.new_path, new_path: file_diff.new_path }
188
+ else
189
+ { old_path: file_diff.old_path, new_path: file_diff.old_path }
190
+ end
191
+ end
192
+
193
+ def build_diff_git_line(old_path, new_path)
194
+ old_git_path = if old_path == '/dev/null'
195
+ to_git_old_path(new_path)
196
+ elsif new_path == '/dev/null'
197
+ to_git_old_path(old_path)
198
+ else
199
+ to_git_old_path(old_path)
200
+ end
201
+ new_git_path = if new_path == '/dev/null'
202
+ to_git_new_path(old_path)
203
+ elsif old_path == '/dev/null'
204
+ to_git_new_path(new_path)
205
+ else
206
+ to_git_new_path(new_path)
207
+ end
208
+
209
+ "diff --git #{old_git_path} #{new_git_path}"
210
+ end
211
+
212
+ def path_operation_applied_before?(file_diff, chunk_index)
213
+ return false if chunk_index.zero?
214
+
215
+ path_operation_applied_after?(file_diff, chunk_index - 1)
216
+ end
217
+
218
+ def path_operation_applied_after?(file_diff, chunk_index)
219
+ operation_hunk = file_diff.path_operation_hunk
220
+ return false unless operation_hunk
221
+
222
+ operation_hunk.change_rows.all? do |row|
223
+ @chunk_index_by_row_id.fetch(row.id) <= chunk_index
224
+ end
225
+ end
226
+
227
+ def emits_text_headers_for_binary?(file_diff)
228
+ file_diff.modification? || file_diff.path_operation_hunk
229
+ end
230
+
231
+ def implicit_metadata_lines(file_diff, metadata_lines)
232
+ return metadata_lines unless file_diff.addition? || file_diff.deletion?
233
+
234
+ file_diff.metadata_lines.select do |line|
235
+ line.start_with?('new file mode ') || line.start_with?('deleted file mode ')
236
+ end
237
+ end
238
+
239
+ def to_git_old_path(path)
240
+ return '/dev/null' if path == '/dev/null'
241
+
242
+ "a/#{path.sub(%r{\A[ab]/}, '')}"
243
+ end
244
+
245
+ def to_git_new_path(path)
246
+ return '/dev/null' if path == '/dev/null'
247
+
248
+ "b/#{path.sub(%r{\A[ab]/}, '')}"
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Split
5
+ class Verifier
6
+ def initialize(selector_parser: PatchUtil::Selection::Parser.new)
7
+ @selector_parser = selector_parser
8
+ end
9
+
10
+ def build_chunks(diff:, chunk_requests:)
11
+ chunks = []
12
+ assigned_row_ids = {}
13
+ leftovers_request = nil
14
+
15
+ chunk_requests.each do |request|
16
+ if request.leftovers?
17
+ raise ValidationError, 'leftovers chunk can only be declared once' if leftovers_request
18
+
19
+ leftovers_request = request
20
+ next
21
+ end
22
+
23
+ selectors = @selector_parser.parse(request.selector_text)
24
+ row_ids, change_labels = resolve_selectors(diff, selectors)
25
+
26
+ row_ids.each do |row_id|
27
+ existing = assigned_row_ids[row_id]
28
+ next unless existing
29
+
30
+ label = diff.row_by_id(row_id)&.change_label || row_id
31
+ raise ValidationError, "#{label} is assigned to both #{existing} and #{request.name}"
32
+ end
33
+
34
+ row_ids.each { |row_id| assigned_row_ids[row_id] = request.name }
35
+
36
+ chunks << Chunk.new(
37
+ name: request.name,
38
+ selector_text: request.selector_text,
39
+ row_ids: row_ids,
40
+ change_labels: change_labels,
41
+ leftovers: false
42
+ )
43
+ end
44
+
45
+ leftovers = []
46
+ diff.change_rows.each do |row|
47
+ leftovers << row unless assigned_row_ids.key?(row.id)
48
+ end
49
+
50
+ if leftovers.any?
51
+ unless leftovers_request
52
+ raise ValidationError,
53
+ "#{leftovers.length} lines will be removed; re-plan with a leftovers commit if you do not intend removal"
54
+ end
55
+
56
+ row_ids = leftovers.map(&:id)
57
+ change_labels = leftovers.map(&:change_label)
58
+ chunks << Chunk.new(
59
+ name: leftovers_request.name,
60
+ selector_text: nil,
61
+ row_ids: row_ids,
62
+ change_labels: change_labels,
63
+ leftovers: true
64
+ )
65
+ elsif leftovers_request
66
+ chunks << Chunk.new(
67
+ name: leftovers_request.name,
68
+ selector_text: nil,
69
+ row_ids: [],
70
+ change_labels: [],
71
+ leftovers: true
72
+ )
73
+ end
74
+
75
+ chunks
76
+ end
77
+
78
+ private
79
+
80
+ def resolve_selectors(diff, selectors)
81
+ whole_hunks = {}
82
+ partial_hunks = {}
83
+
84
+ selectors.each do |selector|
85
+ if selector.whole_hunk?
86
+ whole_hunks[selector.hunk_label] = true
87
+ else
88
+ partial_hunks[selector.hunk_label] = true
89
+ end
90
+ end
91
+
92
+ whole_hunks.each_key do |hunk_label|
93
+ next unless partial_hunks[hunk_label]
94
+
95
+ raise ValidationError, "cannot select both whole hunk #{hunk_label} and partial changed lines from it"
96
+ end
97
+
98
+ selected_row_ids = []
99
+ selected_change_labels = []
100
+ seen_row_ids = {}
101
+
102
+ selectors.each do |selector|
103
+ rows = rows_for_selector(diff, selector)
104
+ rows.each do |row|
105
+ raise ValidationError, "#{row.change_label} is selected more than once" if seen_row_ids[row.id]
106
+
107
+ seen_row_ids[row.id] = true
108
+ selected_row_ids << row.id
109
+ selected_change_labels << row.change_label
110
+ end
111
+ end
112
+
113
+ [selected_row_ids, selected_change_labels]
114
+ end
115
+
116
+ def rows_for_selector(diff, selector)
117
+ hunk = diff.hunk_by_label(selector.hunk_label)
118
+ raise ValidationError, "unknown hunk label: #{selector.hunk_label}" unless hunk
119
+
120
+ return hunk.change_rows if selector.whole_hunk?
121
+
122
+ rows = []
123
+ selector.ordinals.each do |ordinal|
124
+ row = hunk.change_rows.find { |candidate| candidate.change_ordinal == ordinal }
125
+ raise ValidationError, "unknown changed line #{selector.hunk_label}#{ordinal}" unless row
126
+
127
+ rows << row
128
+ end
129
+ rows
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Split
5
+ autoload :Plan, 'patch_util/split/plan'
6
+ autoload :ChunkRequest, 'patch_util/split/plan'
7
+ autoload :Chunk, 'patch_util/split/plan'
8
+ autoload :PlanEntry, 'patch_util/split/plan'
9
+ autoload :PlanSet, 'patch_util/split/plan'
10
+ autoload :PlanStore, 'patch_util/split/plan_store'
11
+ autoload :Verifier, 'patch_util/split/verifier'
12
+ autoload :Planner, 'patch_util/split/planner'
13
+ autoload :ProjectedFile, 'patch_util/split/projector'
14
+ autoload :ProjectedHunk, 'patch_util/split/projector'
15
+ autoload :Projector, 'patch_util/split/projector'
16
+ autoload :Emitter, 'patch_util/split/emitter'
17
+ autoload :Applier, 'patch_util/split/applier'
18
+ autoload :Inspector, 'patch_util/split/inspector'
19
+ autoload :CLI, 'patch_util/split/cli'
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ VERSION = '0.1.0'
5
+ end
data/lib/patch_util.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ autoload :VERSION, 'patch_util/version'
5
+ class Error < StandardError; end
6
+ class ParseError < Error; end
7
+ class ValidationError < Error; end
8
+ class UnsupportedFeatureError < Error; end
9
+
10
+ autoload :Git, 'patch_util/git'
11
+ autoload :Source, 'patch_util/source'
12
+ autoload :Diff, 'patch_util/diff'
13
+ autoload :FileDiff, 'patch_util/diff'
14
+ autoload :Hunk, 'patch_util/diff'
15
+ autoload :Row, 'patch_util/diff'
16
+ autoload :ChangeLine, 'patch_util/diff'
17
+ autoload :Parser, 'patch_util/parser'
18
+ autoload :Selection, 'patch_util/selection'
19
+ autoload :Split, 'patch_util/split'
20
+ autoload :CLI, 'patch_util/cli'
21
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: patch_util
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - hmdne
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.2'
26
+ description: Patch planning and materialization helpers for splitting one unified
27
+ diff into multiple reviewable patches.
28
+ email:
29
+ - 54514036+hmdne@users.noreply.github.com
30
+ executables:
31
+ - patch_util
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".rspec"
36
+ - CHANGELOG.md
37
+ - LICENSE.txt
38
+ - README.md
39
+ - Rakefile
40
+ - SKILL.md
41
+ - exe/patch_util
42
+ - lib/patch_util.rb
43
+ - lib/patch_util/cli.rb
44
+ - lib/patch_util/diff.rb
45
+ - lib/patch_util/git.rb
46
+ - lib/patch_util/git/cli.rb
47
+ - lib/patch_util/git/rewrite_cli.rb
48
+ - lib/patch_util/git/rewrite_session_manager.rb
49
+ - lib/patch_util/git/rewrite_state_store.rb
50
+ - lib/patch_util/git/rewriter.rb
51
+ - lib/patch_util/parser.rb
52
+ - lib/patch_util/selection.rb
53
+ - lib/patch_util/source.rb
54
+ - lib/patch_util/split.rb
55
+ - lib/patch_util/split/applier.rb
56
+ - lib/patch_util/split/cli.rb
57
+ - lib/patch_util/split/emitter.rb
58
+ - lib/patch_util/split/inspector.rb
59
+ - lib/patch_util/split/plan.rb
60
+ - lib/patch_util/split/plan_store.rb
61
+ - lib/patch_util/split/planner.rb
62
+ - lib/patch_util/split/projector.rb
63
+ - lib/patch_util/split/verifier.rb
64
+ - lib/patch_util/version.rb
65
+ homepage: https://github.com/rbutils/patch_util
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ allowed_push_host: https://rubygems.org
70
+ source_code_uri: https://github.com/rbutils/patch_util
71
+ changelog_uri: https://github.com/rbutils/patch_util/blob/master/CHANGELOG.md
72
+ documentation_uri: https://github.com/rbutils/patch_util#readme
73
+ bug_tracker_uri: https://github.com/rbutils/patch_util/issues
74
+ rubygems_mfa_required: 'true'
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.2.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.6
90
+ specification_version: 4
91
+ summary: Split unified diffs into smaller ordered patches
92
+ test_files: []