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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Selection
5
+ WholeHunk = Data.define(:hunk_label) do
6
+ def whole_hunk?
7
+ true
8
+ end
9
+ end
10
+
11
+ ChangedLineRange = Data.define(:hunk_label, :start_ordinal, :end_ordinal) do
12
+ def whole_hunk?
13
+ false
14
+ end
15
+
16
+ def ordinals
17
+ (start_ordinal..end_ordinal).to_a
18
+ end
19
+ end
20
+
21
+ class Parser
22
+ def parse(selector_text)
23
+ tokens = selector_text.to_s.split(',').map(&:strip).reject(&:empty?)
24
+ selectors = []
25
+
26
+ tokens.each do |token|
27
+ selectors.concat(parse_token(token))
28
+ end
29
+
30
+ selectors
31
+ end
32
+
33
+ private
34
+
35
+ def parse_token(token)
36
+ if (match = /\A([a-z]+)(\d+)-([a-z]+)(\d+)\z/.match(token))
37
+ start_hunk = match[1]
38
+ finish_hunk = match[3]
39
+ raise ValidationError, "cross-hunk ranges are not supported: #{token}" unless start_hunk == finish_hunk
40
+
41
+ start_ordinal = Integer(match[2], 10)
42
+ end_ordinal = Integer(match[4], 10)
43
+ raise ValidationError, "descending selector range: #{token}" if start_ordinal > end_ordinal
44
+
45
+ return [ChangedLineRange.new(
46
+ hunk_label: start_hunk,
47
+ start_ordinal: start_ordinal,
48
+ end_ordinal: end_ordinal
49
+ )]
50
+ end
51
+
52
+ if (match = /\A([a-z]+)(\d+)\z/.match(token))
53
+ ordinal = Integer(match[2], 10)
54
+ return [ChangedLineRange.new(hunk_label: match[1], start_ordinal: ordinal, end_ordinal: ordinal)]
55
+ end
56
+
57
+ if (match = /\A([a-z]+)-([a-z]+)\z/.match(token))
58
+ start_label = match[1]
59
+ end_label = match[2]
60
+ start_index = hunk_label_index(start_label)
61
+ end_index = hunk_label_index(end_label)
62
+ raise ValidationError, "descending hunk range: #{token}" if start_index > end_index
63
+
64
+ return (start_index..end_index).map do |index|
65
+ WholeHunk.new(hunk_label: hunk_label_for(index))
66
+ end
67
+ end
68
+
69
+ return [WholeHunk.new(hunk_label: token)] if /\A[a-z]+\z/.match?(token)
70
+
71
+ raise ValidationError, "invalid selector token: #{token}"
72
+ end
73
+
74
+ def hunk_label_index(label)
75
+ value = 0
76
+
77
+ label.each_byte do |byte|
78
+ value = (value * 26) + (byte - 96)
79
+ end
80
+
81
+ value - 1
82
+ end
83
+
84
+ def hunk_label_for(index)
85
+ current = index
86
+ label = +''
87
+
88
+ loop do
89
+ label.prepend((97 + (current % 26)).chr)
90
+ current = (current / 26) - 1
91
+ break if current.negative?
92
+ end
93
+
94
+ label
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module PatchUtil
6
+ class Source
7
+ attr_reader :diff_text, :fingerprint, :kind, :label, :path, :repo_path, :commit_sha, :parent_shas
8
+
9
+ def self.from_diff_text(diff_text, label: 'stdin')
10
+ bytes = diff_text.dup.force_encoding(Encoding::BINARY)
11
+ new(
12
+ kind: 'raw_diff',
13
+ label: label,
14
+ path: nil,
15
+ diff_text: bytes.dup.force_encoding(Encoding::UTF_8),
16
+ fingerprint: Digest::SHA256.hexdigest(bytes)
17
+ )
18
+ end
19
+
20
+ def self.from_patch_file(path)
21
+ expanded = File.expand_path(path)
22
+ bytes = File.binread(expanded)
23
+ new(
24
+ kind: 'patch_file',
25
+ label: path,
26
+ path: expanded,
27
+ diff_text: bytes.dup.force_encoding(Encoding::UTF_8),
28
+ fingerprint: Digest::SHA256.hexdigest(bytes)
29
+ )
30
+ end
31
+
32
+ def self.from_git_commit(repo_path:, revision: 'HEAD', git_cli: PatchUtil::Git::Cli.new)
33
+ root = git_cli.repo_root(repo_path)
34
+ sha = git_cli.rev_parse(root, revision)
35
+ parent_shas = git_cli.parent_shas(root, sha)
36
+ if parent_shas.length > 1
37
+ raise ValidationError,
38
+ "merge commits are not supported yet for inspect/plan/apply: #{sha} has #{parent_shas.length} parents"
39
+ end
40
+
41
+ diff_text = git_cli.show_commit_patch(root, sha)
42
+ new(
43
+ kind: 'git_commit',
44
+ label: "#{root}@#{sha}",
45
+ path: nil,
46
+ diff_text: diff_text,
47
+ fingerprint: sha,
48
+ repo_path: root,
49
+ commit_sha: sha,
50
+ parent_shas: parent_shas
51
+ )
52
+ end
53
+
54
+ def initialize(kind:, label:, path:, diff_text:, fingerprint:, repo_path: nil, commit_sha: nil, parent_shas: [])
55
+ @kind = kind
56
+ @label = label
57
+ @path = path
58
+ @diff_text = diff_text
59
+ @fingerprint = fingerprint
60
+ @repo_path = repo_path
61
+ @commit_sha = commit_sha
62
+ @parent_shas = parent_shas
63
+ end
64
+
65
+ def git?
66
+ kind.start_with?('git_')
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module PatchUtil
6
+ module Split
7
+ class Applier
8
+ def initialize(emitter: Emitter.new)
9
+ @emitter = emitter
10
+ end
11
+
12
+ def apply(diff:, plan_entry:, output_dir:)
13
+ projector = Projector.new(diff: diff, plan_entry: plan_entry)
14
+ target_dir = File.expand_path(output_dir)
15
+ FileUtils.mkdir_p(target_dir)
16
+
17
+ emitted = []
18
+ plan_entry.chunks.each_with_index do |chunk, chunk_index|
19
+ patch_text = @emitter.emit(projector.project_chunk(chunk_index))
20
+ raise ValidationError, "chunk #{chunk.name} did not produce a patch" if patch_text.empty?
21
+
22
+ path = File.join(target_dir, format('%04d-%s.patch', chunk_index + 1, slug(chunk.name)))
23
+ File.write(path, patch_text)
24
+ emitted << { name: chunk.name, path: path, patch_text: patch_text }
25
+ end
26
+
27
+ emitted
28
+ end
29
+
30
+ private
31
+
32
+ def slug(name)
33
+ normalized = name.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-|-\z/, '')
34
+ normalized.empty? ? 'chunk' : normalized
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'thor'
5
+
6
+ module PatchUtil
7
+ module Split
8
+ class CLI < Thor
9
+ desc 'inspect', 'Display annotated diff lines and any saved plan overlay'
10
+ option :patch, type: :string, aliases: '-p', banner: 'PATH'
11
+ option :commit, type: :string, aliases: '-c', banner: 'REV'
12
+ option :repo, type: :string, aliases: '-r', banner: 'PATH'
13
+ option :plan, type: :string, aliases: '-P', banner: 'PATH'
14
+ option :compact, type: :boolean, default: false, banner: 'BOOL'
15
+ option :expand, type: :string, banner: 'HUNKS'
16
+ def inspect
17
+ source = load_source
18
+ diff = PatchUtil::Parser.new.parse(source)
19
+ plan_entry = load_plan_entry(source)
20
+ expand_hunks = parse_expanded_hunks(diff)
21
+ puts Inspector.new.render(diff: diff,
22
+ plan_entry: plan_entry,
23
+ compact: options[:compact],
24
+ expand_hunks: expand_hunks)
25
+ end
26
+
27
+ desc 'plan [NAME SELECTORS]... [LEFTOVERS_NAME]', 'Persist a split plan'
28
+ option :patch, type: :string, aliases: '-p', banner: 'PATH'
29
+ option :commit, type: :string, aliases: '-c', banner: 'REV'
30
+ option :repo, type: :string, aliases: '-r', banner: 'PATH'
31
+ option :plan, type: :string, aliases: '-P', banner: 'PATH'
32
+ def plan(*args)
33
+ source = load_source
34
+ diff = PatchUtil::Parser.new.parse(source)
35
+ chunk_requests = build_chunk_requests(args)
36
+ plan_entry = Planner.new.build(source: source, diff: diff, chunk_requests: chunk_requests)
37
+
38
+ store = PlanStore.new(path: resolve_plan_path(source))
39
+ plan_set = store.upsert(store.load, plan_entry)
40
+ store.save(plan_set)
41
+
42
+ puts "saved #{plan_entry.chunks.length} chunks to #{store.path}"
43
+ end
44
+
45
+ desc 'apply', 'Emit one patch file per saved chunk'
46
+ option :patch, type: :string, aliases: '-p', banner: 'PATH'
47
+ option :commit, type: :string, aliases: '-c', banner: 'REV'
48
+ option :repo, type: :string, aliases: '-r', banner: 'PATH'
49
+ option :plan, type: :string, aliases: '-P', banner: 'PATH'
50
+ option :output_dir, type: :string, aliases: '-o', banner: 'DIR'
51
+ option :rewrite, type: :boolean, default: false, banner: 'BOOL'
52
+ def apply
53
+ source = load_source
54
+ diff = PatchUtil::Parser.new.parse(source)
55
+ plan_entry = load_plan_entry(source)
56
+ raise ValidationError, "no saved plan matches #{source.label}" unless plan_entry
57
+
58
+ if options[:rewrite]
59
+ raise ValidationError, '--rewrite is only supported for git commit sources' unless source.git?
60
+
61
+ result = PatchUtil::Git::Rewriter.new.rewrite(source: source, diff: diff, plan_entry: plan_entry)
62
+ puts "rewrote #{result.branch}: #{result.old_head} -> #{result.new_head}"
63
+ puts "backup ref: #{result.backup_ref}"
64
+ result.commits.each { |name| puts "created #{name}" }
65
+ else
66
+ raise ValidationError, '--output-dir is required unless --rewrite is used' unless options[:output_dir]
67
+
68
+ emitted = Applier.new.apply(diff: diff, plan_entry: plan_entry, output_dir: options[:output_dir])
69
+ emitted.each do |item|
70
+ puts "#{item[:name]} -> #{item[:path]}"
71
+ end
72
+ end
73
+ end
74
+
75
+ no_commands do
76
+ def load_source
77
+ validate_source_options!
78
+
79
+ if options[:patch]
80
+ PatchUtil::Source.from_patch_file(options[:patch])
81
+ else
82
+ PatchUtil::Source.from_git_commit(repo_path: options[:repo] || Dir.pwd,
83
+ revision: options[:commit] || 'HEAD')
84
+ end
85
+ end
86
+
87
+ def build_chunk_requests(args)
88
+ raise ValidationError, 'plan requires at least one chunk name and selector' if args.empty?
89
+
90
+ requests = []
91
+ pairable_count = args.length.even? ? args.length : args.length - 1
92
+ index = 0
93
+
94
+ while index < pairable_count
95
+ name = args[index]
96
+ selector_text = args[index + 1]
97
+ raise ValidationError, "missing selector for chunk #{name}" if selector_text.nil?
98
+
99
+ requests << ChunkRequest.new(name: name, selector_text: selector_text, leftovers: false)
100
+ index += 2
101
+ end
102
+
103
+ requests << ChunkRequest.new(name: args[-1], selector_text: nil, leftovers: true) if args.length.odd?
104
+
105
+ requests
106
+ end
107
+
108
+ def parse_expanded_hunks(diff)
109
+ selector_text = options[:expand]
110
+ return [] unless selector_text
111
+
112
+ raise ValidationError, '--expand requires --compact' unless options[:compact]
113
+
114
+ selectors = PatchUtil::Selection::Parser.new.parse(selector_text)
115
+ raise ValidationError, '--expand requires at least one hunk label' if selectors.empty?
116
+
117
+ labels = []
118
+ selectors.each do |selector|
119
+ unless selector.whole_hunk?
120
+ raise ValidationError,
121
+ '--expand only accepts whole-hunk labels or hunk ranges (for example: a,b,c or a-c)'
122
+ end
123
+
124
+ unless diff.hunk_by_label(selector.hunk_label)
125
+ raise ValidationError,
126
+ "unknown hunk label for --expand: #{selector.hunk_label}"
127
+ end
128
+
129
+ labels << selector.hunk_label
130
+ end
131
+
132
+ labels.uniq
133
+ end
134
+
135
+ def load_plan_entry(source)
136
+ store = PlanStore.new(path: resolve_plan_path(source))
137
+ store.load.find_entry(source)
138
+ end
139
+
140
+ def resolve_plan_path(source)
141
+ return File.expand_path(options[:plan]) if options[:plan]
142
+
143
+ if source.git?
144
+ git_dir = PatchUtil::Git::Cli.new.git_dir(source.repo_path)
145
+ return File.join(git_dir, 'patch_util', 'plans.json')
146
+ end
147
+
148
+ raise ValidationError, "--plan is required for #{source.kind} sources outside a git repository"
149
+ end
150
+
151
+ def validate_source_options!
152
+ if options[:patch] && (options[:commit] || options[:repo])
153
+ raise ValidationError, 'use either --patch or --commit/--repo source options, not both'
154
+ end
155
+
156
+ raise ValidationError, '--repo requires --commit when used explicitly' if options[:repo] && !options[:commit]
157
+
158
+ return if options[:patch]
159
+ return if options[:commit]
160
+ return if PatchUtil::Git::Cli.new.inside_repo?(options[:repo] || Dir.pwd)
161
+
162
+ raise ValidationError, 'provide --patch, or run inside a git repository, or pass --commit with --repo'
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Split
5
+ class Emitter
6
+ def emit(projected_files)
7
+ lines = []
8
+
9
+ projected_files.each do |file_patch|
10
+ lines << file_patch.diff_git_line if file_patch.diff_git_line
11
+ lines.concat(file_patch.metadata_lines)
12
+ if file_patch.emit_text_headers
13
+ lines << "--- #{format_old_path(file_patch.old_path)}"
14
+ lines << "+++ #{format_new_path(file_patch.new_path)}"
15
+ end
16
+
17
+ file_patch.hunks.each do |hunk|
18
+ if hunk.kind == :text
19
+ lines << "@@ -#{format_range(hunk.old_start,
20
+ hunk.old_count)} +#{format_range(hunk.new_start, hunk.new_count)} @@"
21
+ lines.concat(hunk.lines)
22
+ else
23
+ lines.concat(hunk.patch_lines)
24
+ end
25
+ end
26
+ end
27
+
28
+ return '' if lines.empty?
29
+
30
+ lines.join("\n") + "\n"
31
+ end
32
+
33
+ private
34
+
35
+ def format_range(start, count)
36
+ "#{start},#{count}"
37
+ end
38
+
39
+ def format_old_path(path)
40
+ return '/dev/null' if path == '/dev/null'
41
+
42
+ "a/#{path.sub(%r{\A[ab]/}, '')}"
43
+ end
44
+
45
+ def format_new_path(path)
46
+ return '/dev/null' if path == '/dev/null'
47
+
48
+ "b/#{path.sub(%r{\A[ab]/}, '')}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Split
5
+ class Inspector
6
+ COLUMN_WIDTH = 28
7
+
8
+ def render(diff:, plan_entry: nil, compact: false, expand_hunks: [])
9
+ assignments = assignment_map(plan_entry)
10
+ return render_compact(diff, assignments, expand_hunks) if compact
11
+
12
+ render_full(diff, assignments)
13
+ end
14
+
15
+ private
16
+
17
+ def render_full(diff, assignments)
18
+ lines = []
19
+
20
+ diff.file_diffs.each do |file_diff|
21
+ lines << "--- #{file_diff.old_path}"
22
+ lines << "+++ #{file_diff.new_path}"
23
+
24
+ file_diff.hunks.each do |hunk|
25
+ lines << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@"
26
+ hunk.rows.each do |row|
27
+ if row.change?
28
+ label = row.change_label
29
+ chunk_name = assignments[row.id]
30
+ marker = chunk_name ? "#{label} [#{chunk_name}]" : label
31
+ lines << format("%-#{COLUMN_WIDTH}s %s%s", marker, row.display_prefix, row.text)
32
+ else
33
+ lines << format("%-#{COLUMN_WIDTH}s %s%s", '', row.display_prefix, row.text)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ lines.join("\n") + "\n"
40
+ end
41
+
42
+ def render_compact(diff, assignments, expand_hunks)
43
+ lines = []
44
+ expanded_labels = expand_hunks.to_set
45
+
46
+ lines << '== Compact Inspect =='
47
+ lines << 'legend: text=unified hunk, operation=rename/copy/mode metadata, binary=git binary payload'
48
+ lines << 'label spans show selectable changed-line ranges with optional [chunk name] overlay'
49
+ lines << "expanded hunks: #{expanded_labels.to_a.join(', ')}" unless expanded_labels.empty?
50
+ lines << ''
51
+ lines << '== File Index =='
52
+
53
+ diff.file_diffs.each do |file_diff|
54
+ lines << compact_file_index_line(file_diff, assignments)
55
+ end
56
+
57
+ lines << ''
58
+ lines << '== Details =='
59
+
60
+ diff.file_diffs.each do |file_diff|
61
+ lines << "--- #{file_diff.old_path}"
62
+ lines << "+++ #{file_diff.new_path}"
63
+
64
+ file_diff.hunks.each do |hunk|
65
+ if expanded_labels.include?(hunk.label)
66
+ lines.concat(full_hunk_lines(hunk, assignments))
67
+ else
68
+ lines << compact_hunk_line(hunk, assignments)
69
+ end
70
+ end
71
+ end
72
+
73
+ lines.join("\n") + "\n"
74
+ end
75
+
76
+ def compact_file_index_line(file_diff, assignments)
77
+ path = compact_file_path(file_diff)
78
+ hunks = compact_index_hunks(file_diff).map { |hunk| compact_file_index_hunk(hunk, assignments) }
79
+ change_count = 0
80
+ file_diff.hunks.each do |hunk|
81
+ change_count += hunk.change_rows.length
82
+ end
83
+
84
+ "#{path} (#{count_label(file_diff.hunks.length,
85
+ 'hunk')}, #{count_label(change_count, 'change')}): #{hunks.join('; ')}"
86
+ end
87
+
88
+ def compact_file_path(file_diff)
89
+ return file_diff.new_path if file_diff.addition?
90
+ return file_diff.old_path if file_diff.deletion?
91
+
92
+ file_diff.new_path
93
+ end
94
+
95
+ def compact_index_hunks(file_diff)
96
+ indexed = []
97
+ file_diff.hunks.each_with_index do |hunk, index|
98
+ indexed << [hunk, index]
99
+ end
100
+
101
+ indexed.sort_by { |hunk, index| [-hunk.change_rows.length, index] }.map(&:first)
102
+ end
103
+
104
+ def compact_file_index_hunk(hunk, assignments)
105
+ summary = compact_change_segments(hunk.change_rows, assignments)
106
+ "#{hunk.label}(#{compact_kind(hunk)}, #{count_label(hunk.change_rows.length, 'change')}: #{summary})"
107
+ end
108
+
109
+ def compact_hunk_line(hunk, assignments)
110
+ if hunk.text?
111
+ "#{hunk.label} text @@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@: " \
112
+ "#{compact_change_segments(hunk.change_rows, assignments)}"
113
+ else
114
+ row = hunk.change_rows.first
115
+ "#{hunk.label} #{compact_kind(hunk)}: #{compact_change_segments([row],
116
+ assignments)} #{row.display_prefix}#{row.text}"
117
+ end
118
+ end
119
+
120
+ def full_hunk_lines(hunk, assignments)
121
+ lines = []
122
+ lines << "#{compact_hunk_line(hunk, assignments)} [expanded]"
123
+
124
+ lines << "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@" if hunk.text?
125
+
126
+ hunk.rows.each do |row|
127
+ if row.change?
128
+ label = row.change_label
129
+ chunk_name = assignments[row.id]
130
+ marker = chunk_name ? "#{label} [#{chunk_name}]" : label
131
+ lines << format("%-#{COLUMN_WIDTH}s %s%s", marker, row.display_prefix, row.text)
132
+ else
133
+ lines << format("%-#{COLUMN_WIDTH}s %s%s", '', row.display_prefix, row.text)
134
+ end
135
+ end
136
+
137
+ lines
138
+ end
139
+
140
+ def compact_change_segments(rows, assignments)
141
+ segments = []
142
+ range_start = nil
143
+ range_end = nil
144
+ current_chunk_name = nil
145
+
146
+ rows.each do |row|
147
+ chunk_name = assignments[row.id]
148
+
149
+ if range_start && contiguous_segment?(range_end, row) && current_chunk_name == chunk_name
150
+ range_end = row
151
+ next
152
+ end
153
+
154
+ segments << compact_segment_label(range_start, range_end, current_chunk_name) if range_start
155
+ range_start = row
156
+ range_end = row
157
+ current_chunk_name = chunk_name
158
+ end
159
+
160
+ segments << compact_segment_label(range_start, range_end, current_chunk_name) if range_start
161
+ segments.join(', ')
162
+ end
163
+
164
+ def compact_segment_label(range_start, range_end, chunk_name)
165
+ marker = if range_start.change_label == range_end.change_label
166
+ range_start.change_label
167
+ else
168
+ "#{range_start.change_label}-#{range_end.change_label}"
169
+ end
170
+ chunk_name ? "#{marker} [#{chunk_name}]" : marker
171
+ end
172
+
173
+ def contiguous_segment?(left, right)
174
+ left.change_ordinal + 1 == right.change_ordinal
175
+ end
176
+
177
+ def compact_kind(hunk)
178
+ return 'text' if hunk.text?
179
+ return 'operation' if hunk.operation?
180
+ return 'binary' if hunk.binary?
181
+
182
+ raise PatchUtil::UnsupportedFeatureError, "unknown hunk kind: #{hunk.kind.inspect}"
183
+ end
184
+
185
+ def count_label(count, singular)
186
+ noun = count == 1 ? singular : "#{singular}s"
187
+ "#{count} #{noun}"
188
+ end
189
+
190
+ def assignment_map(plan_entry)
191
+ return {} unless plan_entry
192
+
193
+ map = {}
194
+ plan_entry.chunks.each do |chunk|
195
+ chunk.row_ids.each do |row_id|
196
+ map[row_id] = chunk.name
197
+ end
198
+ end
199
+ map
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Split
5
+ ChunkRequest = Data.define(:name, :selector_text, :leftovers) do
6
+ def leftovers?
7
+ leftovers
8
+ end
9
+ end
10
+
11
+ Chunk = Data.define(:name, :selector_text, :row_ids, :change_labels, :leftovers) do
12
+ def leftovers?
13
+ leftovers
14
+ end
15
+ end
16
+
17
+ PlanEntry = Data.define(:source_kind, :source_label, :source_fingerprint, :chunks, :created_at) do
18
+ def matches_source?(source)
19
+ source_kind == source.kind && source_fingerprint == source.fingerprint
20
+ end
21
+
22
+ def chunk_for_row_id(row_id)
23
+ chunks.find { |chunk| chunk.row_ids.include?(row_id) }
24
+ end
25
+ end
26
+
27
+ PlanSet = Data.define(:version, :entries) do
28
+ def find_entry(source)
29
+ entries.find { |entry| entry.matches_source?(source) }
30
+ end
31
+ end
32
+ end
33
+ end