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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'time'
5
+
6
+ module PatchUtil
7
+ module Git
8
+ class Rewriter
9
+ Result = RewriteSessionManager::Result
10
+ AbortResult = RewriteSessionManager::AbortResult
11
+ RestoreResult = RewriteSessionManager::RestoreResult
12
+ ResolveResult = RewriteSessionManager::ResolveResult
13
+ ResolveBlockResult = RewriteSessionManager::ResolveBlockResult
14
+ ConflictBlocksResult = RewriteSessionManager::ConflictBlocksResult
15
+ ExportBlockResult = RewriteSessionManager::ExportBlockResult
16
+ ApplyBlockEditResult = RewriteSessionManager::ApplyBlockEditResult
17
+ ExportBlockSessionResult = RewriteSessionManager::ExportBlockSessionResult
18
+ ApplyBlockSessionEditResult = RewriteSessionManager::ApplyBlockSessionEditResult
19
+ SessionSummaryResult = RewriteSessionManager::SessionSummaryResult
20
+ Status = RewriteSessionManager::Status
21
+
22
+ def initialize(git_cli: Cli.new, applier: PatchUtil::Split::Applier.new, clock: -> { Time.now.utc },
23
+ session_manager: nil)
24
+ @git_cli = git_cli
25
+ @applier = applier
26
+ @clock = clock
27
+ @session_manager = session_manager || RewriteSessionManager.new(git_cli: git_cli, clock: clock)
28
+ end
29
+
30
+ def rewrite(source:, diff:, plan_entry:)
31
+ repo_path = source.repo_path
32
+ raise PatchUtil::ValidationError, 'git rewrite apply requires a git commit source' unless source.git?
33
+
34
+ unless @git_cli.worktree_clean?(repo_path)
35
+ raise PatchUtil::ValidationError,
36
+ 'git rewrite apply requires a clean worktree'
37
+ end
38
+
39
+ branch = @git_cli.current_branch(repo_path)
40
+ raise PatchUtil::ValidationError, 'git rewrite apply requires a checked out branch' if branch.empty?
41
+
42
+ head = @git_cli.head_sha(repo_path)
43
+ target = source.commit_sha
44
+ unless @git_cli.ancestor?(repo_path, target, head)
45
+ raise PatchUtil::ValidationError, "target commit #{target} is not an ancestor of HEAD"
46
+ end
47
+
48
+ parent = @git_cli.rev_parse(repo_path, "#{target}^")
49
+ descendants = @git_cli.rev_list(repo_path, "#{target}..#{head}")
50
+ merge_descendant = descendants.find { |revision| @git_cli.merge_commit?(repo_path, revision) }
51
+ if merge_descendant
52
+ raise PatchUtil::ValidationError,
53
+ "git rewrite apply does not support descendant merge commits yet: #{merge_descendant} is a merge commit in #{target}..#{head}"
54
+ end
55
+ original_commit = @git_cli.show_commit_metadata(repo_path, target)
56
+ backup_ref = "refs/patch_util-backups/#{branch}/#{timestamp_token}"
57
+ @git_cli.update_ref(repo_path, backup_ref, head)
58
+ worktree = build_worktree_path(repo_path, branch, target)
59
+ emitted_output_dir = File.join(worktree, '.patch_util_emitted')
60
+ state_store = RewriteStateStore.new(git_dir: @git_cli.git_dir(repo_path))
61
+ pending_revisions = descendants.dup
62
+
63
+ begin
64
+ @git_cli.worktree_add(repo_path, worktree, parent)
65
+
66
+ emitted = @applier.apply(diff: diff, plan_entry: plan_entry,
67
+ output_dir: emitted_output_dir)
68
+ emitted.each do |item|
69
+ @git_cli.apply_patch_text(worktree, item[:patch_text])
70
+ FileUtils.rm_rf(emitted_output_dir)
71
+ @git_cli.commit_all(worktree, build_commit_message(item[:name], target, original_commit),
72
+ env: split_commit_env(original_commit))
73
+ end
74
+
75
+ until pending_revisions.empty?
76
+ revision = pending_revisions.first
77
+ @git_cli.cherry_pick(worktree, revision)
78
+ pending_revisions.shift
79
+ end
80
+
81
+ new_head = @git_cli.head_sha(worktree)
82
+ @git_cli.update_ref(repo_path, "refs/heads/#{branch}", new_head, head)
83
+ @git_cli.reset_hard(repo_path, new_head)
84
+ @git_cli.worktree_remove(repo_path, worktree)
85
+ state_store.clear_branch(branch)
86
+
87
+ Result.new(
88
+ branch: branch,
89
+ old_head: head,
90
+ new_head: new_head,
91
+ backup_ref: backup_ref,
92
+ commits: emitted.map { |item| item[:name] }
93
+ )
94
+ rescue PatchUtil::Error => e
95
+ state_store.record_failure(
96
+ RewriteStateStore::State.new(
97
+ branch: branch,
98
+ target_sha: target,
99
+ head_sha: head,
100
+ backup_ref: backup_ref,
101
+ worktree_path: worktree,
102
+ status: 'failed',
103
+ message: e.message,
104
+ created_at: @clock.call.iso8601,
105
+ pending_revisions: pending_revisions
106
+ )
107
+ )
108
+ raise PatchUtil::Error,
109
+ "#{e.message}\nbackup ref: #{backup_ref}\nretained worktree: #{worktree}"
110
+ end
111
+ end
112
+
113
+ def abort_rewrite(repo_path:)
114
+ @session_manager.abort_rewrite(repo_path: repo_path)
115
+ end
116
+
117
+ def continue_rewrite(repo_path:)
118
+ @session_manager.continue_rewrite(repo_path: repo_path)
119
+ end
120
+
121
+ def status(repo_path:)
122
+ @session_manager.status(repo_path: repo_path)
123
+ end
124
+
125
+ def restore_rewrite(repo_path:)
126
+ @session_manager.restore_rewrite(repo_path: repo_path)
127
+ end
128
+
129
+ def resolve_conflicts(repo_path:, side:, paths: nil, all_unresolved: false)
130
+ @session_manager.resolve_conflicts(
131
+ repo_path: repo_path,
132
+ side: side,
133
+ paths: paths,
134
+ all_unresolved: all_unresolved
135
+ )
136
+ end
137
+
138
+ def resolve_conflict_block(repo_path:, path:, block_id:, side:)
139
+ @session_manager.resolve_conflict_block(
140
+ repo_path: repo_path,
141
+ path: path,
142
+ block_id: block_id,
143
+ side: side
144
+ )
145
+ end
146
+
147
+ def conflict_blocks(repo_path:, paths: nil)
148
+ @session_manager.conflict_blocks(repo_path: repo_path, paths: paths)
149
+ end
150
+
151
+ def export_conflict_block(repo_path:, path:, block_id:, output_path:)
152
+ @session_manager.export_conflict_block(
153
+ repo_path: repo_path,
154
+ path: path,
155
+ block_id: block_id,
156
+ output_path: output_path
157
+ )
158
+ end
159
+
160
+ def apply_conflict_block_edit(repo_path:, path:, block_id:, input_path:)
161
+ @session_manager.apply_conflict_block_edit(
162
+ repo_path: repo_path,
163
+ path: path,
164
+ block_id: block_id,
165
+ input_path: input_path
166
+ )
167
+ end
168
+
169
+ def export_conflict_block_session(repo_path:, output_path:, paths: nil)
170
+ @session_manager.export_conflict_block_session(
171
+ repo_path: repo_path,
172
+ output_path: output_path,
173
+ paths: paths
174
+ )
175
+ end
176
+
177
+ def apply_conflict_block_session_edit(repo_path:, input_path:)
178
+ @session_manager.apply_conflict_block_session_edit(repo_path: repo_path, input_path: input_path)
179
+ end
180
+
181
+ def conflict_block_session_summary(repo_path:, paths: nil)
182
+ @session_manager.conflict_block_session_summary(repo_path: repo_path, paths: paths)
183
+ end
184
+
185
+ def next_action(status)
186
+ @session_manager.next_action(status)
187
+ end
188
+
189
+ private
190
+
191
+ def build_commit_message(chunk_name, target_sha, original_commit)
192
+ sections = []
193
+ sections << chunk_name
194
+ sections << original_commit.body unless original_commit.body.empty?
195
+ sections << build_split_metadata_block(target_sha, original_commit.subject)
196
+ sections.join("\n\n")
197
+ end
198
+
199
+ def build_split_metadata_block(target_sha, original_subject)
200
+ [
201
+ "Split-from: #{target_sha}",
202
+ "Original-subject: #{original_subject}"
203
+ ].join("\n")
204
+ end
205
+
206
+ def split_commit_env(original_commit)
207
+ {
208
+ 'GIT_AUTHOR_NAME' => original_commit.author_name,
209
+ 'GIT_AUTHOR_EMAIL' => original_commit.author_email,
210
+ 'GIT_AUTHOR_DATE' => original_commit.author_date,
211
+ 'GIT_COMMITTER_NAME' => original_commit.committer_name,
212
+ 'GIT_COMMITTER_EMAIL' => original_commit.committer_email,
213
+ 'GIT_COMMITTER_DATE' => original_commit.committer_date
214
+ }
215
+ end
216
+
217
+ def timestamp_token
218
+ @clock.call.strftime('%Y%m%d%H%M%S')
219
+ end
220
+
221
+ def build_worktree_path(repo_path, branch, target)
222
+ git_dir = @git_cli.git_dir(repo_path)
223
+ root = File.join(git_dir, 'patch_util', 'rewrite-worktrees')
224
+ FileUtils.mkdir_p(root)
225
+ File.join(root, "#{timestamp_token}-#{sanitize(branch)}-#{target[0, 12]}")
226
+ end
227
+
228
+ def sanitize(text)
229
+ text.gsub(/[^a-zA-Z0-9._-]+/, '-')
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ module Git
5
+ autoload :Cli, 'patch_util/git/cli'
6
+ autoload :RewriteStateStore, 'patch_util/git/rewrite_state_store'
7
+ autoload :RewriteSessionManager, 'patch_util/git/rewrite_session_manager'
8
+ autoload :Rewriter, 'patch_util/git/rewriter'
9
+ autoload :RewriteCLI, 'patch_util/git/rewrite_cli'
10
+ end
11
+ end
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatchUtil
4
+ class Parser
5
+ HUNK_HEADER = /\A@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: ?(.*))?\z/
6
+
7
+ def parse(source)
8
+ lines = source.diff_text.lines(chomp: true)
9
+ file_diffs = []
10
+ index = 0
11
+ hunk_index = 0
12
+
13
+ while index < lines.length
14
+ index += 1 while index < lines.length && lines[index].empty?
15
+ break if index >= lines.length
16
+
17
+ diff_git_line = nil
18
+ metadata_lines = []
19
+
20
+ if lines[index].start_with?('diff --git ')
21
+ diff_git_line = lines[index]
22
+ index += 1
23
+
24
+ while index < lines.length && metadata_line?(lines[index])
25
+ metadata_lines << lines[index]
26
+ index += 1
27
+ end
28
+ elsif !lines[index].start_with?('--- ')
29
+ raise ParseError, "unsupported diff prelude line: #{lines[index]}"
30
+ end
31
+
32
+ if index < lines.length && lines[index].start_with?('Binary files ')
33
+ raise UnsupportedFeatureError,
34
+ 'binary diff requires a GIT binary patch payload; plain Binary files differ output is not enough'
35
+ end
36
+
37
+ old_path, new_path, index = parse_paths(lines, index, metadata_lines, diff_git_line)
38
+ hunks = []
39
+
40
+ if index < lines.length && lines[index] == 'GIT binary patch'
41
+ path_lines = path_metadata_lines(metadata_lines)
42
+ if path_lines.any?
43
+ operation_hunk, hunk_index = build_operation_hunk(
44
+ old_path: old_path,
45
+ new_path: new_path,
46
+ patch_lines: path_lines,
47
+ hunk_index: hunk_index
48
+ )
49
+ hunks << operation_hunk
50
+ end
51
+
52
+ binary_hunk, index, hunk_index = build_binary_hunk(
53
+ lines: lines,
54
+ index: index,
55
+ old_path: old_path,
56
+ new_path: new_path,
57
+ metadata_lines: binary_metadata_lines(metadata_lines),
58
+ hunk_index: hunk_index
59
+ )
60
+ hunks << binary_hunk
61
+ else
62
+ operation_lines = nonbinary_operation_lines(metadata_lines)
63
+ if operation_lines.any?
64
+ operation_hunk, hunk_index = build_operation_hunk(
65
+ old_path: old_path,
66
+ new_path: new_path,
67
+ patch_lines: operation_lines,
68
+ hunk_index: hunk_index
69
+ )
70
+ hunks << operation_hunk
71
+ end
72
+
73
+ while index < lines.length && lines[index].start_with?('@@ ')
74
+ hunk, index = parse_text_hunk(lines, index, hunk_index)
75
+ hunks << hunk
76
+ hunk_index += 1
77
+ end
78
+ end
79
+
80
+ raise UnsupportedFeatureError, "file diff for #{new_path} has no supported hunks" if hunks.empty?
81
+
82
+ file_diffs << FileDiff.new(
83
+ old_path: old_path,
84
+ new_path: new_path,
85
+ hunks: hunks,
86
+ diff_git_line: diff_git_line,
87
+ metadata_lines: metadata_lines
88
+ )
89
+ end
90
+
91
+ Diff.new(source: source, file_diffs: file_diffs)
92
+ end
93
+
94
+ private
95
+
96
+ def parse_paths(lines, index, metadata_lines, diff_git_line)
97
+ if index < lines.length && lines[index].start_with?('--- ')
98
+ old_path = lines[index].delete_prefix('--- ')
99
+ index += 1
100
+ new_line = lines[index]
101
+ raise ParseError, "missing +++ header after #{old_path}" unless new_line&.start_with?('+++ ')
102
+
103
+ new_path = new_line.delete_prefix('+++ ')
104
+ index += 1
105
+ return [old_path, new_path, index]
106
+ end
107
+
108
+ rename_from = metadata_value(metadata_lines, 'rename from ')
109
+ rename_to = metadata_value(metadata_lines, 'rename to ')
110
+ copy_from = metadata_value(metadata_lines, 'copy from ')
111
+ copy_to = metadata_value(metadata_lines, 'copy to ')
112
+ old_path_from_diff, new_path_from_diff = diff_git_line ? paths_from_diff_git_line(diff_git_line) : [nil, nil]
113
+
114
+ return ["a/#{rename_from}", "b/#{rename_to}", index] if rename_from && rename_to
115
+ return ["a/#{copy_from}", "b/#{copy_to}", index] if copy_from && copy_to
116
+
117
+ return ['/dev/null', new_path_from_diff, index] if metadata_value(metadata_lines, 'new file mode ')
118
+
119
+ return [old_path_from_diff, '/dev/null', index] if metadata_value(metadata_lines, 'deleted file mode ')
120
+
121
+ return [*paths_from_diff_git_line(diff_git_line), index] if diff_git_line
122
+
123
+ raise ParseError, 'missing path headers and no diff --git or rename/copy metadata to infer paths'
124
+ end
125
+
126
+ def metadata_line?(line)
127
+ line.start_with?('index ') ||
128
+ line.start_with?('old mode ') ||
129
+ line.start_with?('new mode ') ||
130
+ line.start_with?('deleted file mode ') ||
131
+ line.start_with?('new file mode ') ||
132
+ line.start_with?('similarity index ') ||
133
+ line.start_with?('rename from ') ||
134
+ line.start_with?('rename to ') ||
135
+ line.start_with?('copy from ') ||
136
+ line.start_with?('copy to ')
137
+ end
138
+
139
+ def path_metadata_lines(metadata_lines)
140
+ metadata_lines.select do |line|
141
+ line.start_with?('similarity index ') ||
142
+ line.start_with?('rename from ') ||
143
+ line.start_with?('rename to ') ||
144
+ line.start_with?('copy from ') ||
145
+ line.start_with?('copy to ')
146
+ end
147
+ end
148
+
149
+ def mode_metadata_lines(metadata_lines)
150
+ metadata_lines.select do |line|
151
+ line.start_with?('old mode ') ||
152
+ line.start_with?('new mode ')
153
+ end
154
+ end
155
+
156
+ def nonbinary_operation_lines(metadata_lines)
157
+ metadata_lines.select do |line|
158
+ line.start_with?('old mode ') ||
159
+ line.start_with?('new mode ') ||
160
+ line.start_with?('similarity index ') ||
161
+ line.start_with?('rename from ') ||
162
+ line.start_with?('rename to ') ||
163
+ line.start_with?('copy from ') ||
164
+ line.start_with?('copy to ')
165
+ end
166
+ end
167
+
168
+ def binary_metadata_lines(metadata_lines)
169
+ metadata_lines.reject do |line|
170
+ line.start_with?('similarity index ') ||
171
+ line.start_with?('rename from ') ||
172
+ line.start_with?('rename to ') ||
173
+ line.start_with?('copy from ') ||
174
+ line.start_with?('copy to ')
175
+ end
176
+ end
177
+
178
+ def build_operation_hunk(old_path:, new_path:, patch_lines:, hunk_index:)
179
+ label = hunk_label_for(hunk_index)
180
+ row = Row.new(
181
+ id: "#{label}:0",
182
+ kind: :file_operation,
183
+ text: operation_summary(old_path: old_path, new_path: new_path, patch_lines: patch_lines),
184
+ old_lineno: nil,
185
+ new_lineno: nil,
186
+ change_label: "#{label}1",
187
+ change_ordinal: 1
188
+ )
189
+
190
+ [
191
+ Hunk.new(
192
+ label: label,
193
+ old_start: 0,
194
+ old_count: 0,
195
+ new_start: 0,
196
+ new_count: 0,
197
+ section: "file operation #{old_path} -> #{new_path}",
198
+ rows: [row],
199
+ kind: :file_operation,
200
+ patch_lines: patch_lines
201
+ ),
202
+ hunk_index + 1
203
+ ]
204
+ end
205
+
206
+ def build_binary_hunk(lines:, index:, old_path:, new_path:, metadata_lines:, hunk_index:)
207
+ label = hunk_label_for(hunk_index)
208
+ payload_lines = []
209
+
210
+ while index < lines.length
211
+ break if lines[index].start_with?('diff --git ')
212
+
213
+ payload_lines << lines[index]
214
+ index += 1
215
+ end
216
+
217
+ patch_lines = metadata_lines + payload_lines
218
+ row = Row.new(
219
+ id: "#{label}:0",
220
+ kind: :binary,
221
+ text: binary_summary(old_path: old_path, new_path: new_path, patch_lines: patch_lines),
222
+ old_lineno: nil,
223
+ new_lineno: nil,
224
+ change_label: "#{label}1",
225
+ change_ordinal: 1
226
+ )
227
+
228
+ [
229
+ Hunk.new(
230
+ label: label,
231
+ old_start: 0,
232
+ old_count: 0,
233
+ new_start: 0,
234
+ new_count: 0,
235
+ section: "binary change #{old_path} -> #{new_path}",
236
+ rows: [row],
237
+ kind: :binary,
238
+ patch_lines: patch_lines
239
+ ),
240
+ index,
241
+ hunk_index + 1
242
+ ]
243
+ end
244
+
245
+ def parse_text_hunk(lines, start_index, hunk_index)
246
+ header = lines[start_index]
247
+ match = HUNK_HEADER.match(header)
248
+ raise ParseError, "invalid hunk header: #{header}" unless match
249
+
250
+ old_start = Integer(match[1], 10)
251
+ old_count = match[2] ? Integer(match[2], 10) : 1
252
+ new_start = Integer(match[3], 10)
253
+ new_count = match[4] ? Integer(match[4], 10) : 1
254
+ section = match[5]
255
+
256
+ rows = []
257
+ index = start_index + 1
258
+ old_lineno = old_start
259
+ new_lineno = new_start
260
+ change_ordinal = 0
261
+ row_index = 0
262
+ hunk_label = hunk_label_for(hunk_index)
263
+
264
+ while index < lines.length
265
+ line = lines[index]
266
+ break if line.start_with?('@@ ') || line.start_with?('diff --git ') || line.start_with?('--- ')
267
+ break if metadata_line?(line) || line == 'GIT binary patch' || line.start_with?('Binary files ')
268
+
269
+ if line == ''
270
+ index += 1
271
+ next
272
+ end
273
+
274
+ prefix = line[0]
275
+ text = line[1..] || ''
276
+ row_id = "#{hunk_label}:#{row_index}"
277
+
278
+ case prefix
279
+ when ' '
280
+ rows << Row.new(
281
+ id: row_id,
282
+ kind: :context,
283
+ text: text,
284
+ old_lineno: old_lineno,
285
+ new_lineno: new_lineno,
286
+ change_label: nil,
287
+ change_ordinal: nil
288
+ )
289
+ old_lineno += 1
290
+ new_lineno += 1
291
+ when '-'
292
+ change_ordinal += 1
293
+ rows << Row.new(
294
+ id: row_id,
295
+ kind: :deletion,
296
+ text: text,
297
+ old_lineno: old_lineno,
298
+ new_lineno: nil,
299
+ change_label: "#{hunk_label}#{change_ordinal}",
300
+ change_ordinal: change_ordinal
301
+ )
302
+ old_lineno += 1
303
+ when '+'
304
+ change_ordinal += 1
305
+ rows << Row.new(
306
+ id: row_id,
307
+ kind: :addition,
308
+ text: text,
309
+ old_lineno: nil,
310
+ new_lineno: new_lineno,
311
+ change_label: "#{hunk_label}#{change_ordinal}",
312
+ change_ordinal: change_ordinal
313
+ )
314
+ new_lineno += 1
315
+ else
316
+ raise ParseError, "unsupported hunk row: #{line}"
317
+ end
318
+
319
+ row_index += 1
320
+ index += 1
321
+ end
322
+
323
+ [
324
+ Hunk.new(
325
+ label: hunk_label,
326
+ old_start: old_start,
327
+ old_count: old_count,
328
+ new_start: new_start,
329
+ new_count: new_count,
330
+ section: section,
331
+ rows: rows,
332
+ kind: :text,
333
+ patch_lines: []
334
+ ),
335
+ index
336
+ ]
337
+ end
338
+
339
+ def metadata_value(metadata_lines, prefix)
340
+ line = metadata_lines.find { |item| item.start_with?(prefix) }
341
+ return nil unless line
342
+
343
+ line.delete_prefix(prefix)
344
+ end
345
+
346
+ def operation_summary(old_path:, new_path:, patch_lines:)
347
+ old_mode = metadata_value(patch_lines, 'old mode ')
348
+ new_mode = metadata_value(patch_lines, 'new mode ')
349
+ rename_from = metadata_value(patch_lines, 'rename from ')
350
+ copy_from = metadata_value(patch_lines, 'copy from ')
351
+ similarity = metadata_value(patch_lines, 'similarity index ')
352
+
353
+ operation_parts = []
354
+ if rename_from
355
+ text = "rename #{display_path(old_path)} -> #{display_path(new_path)}"
356
+ text += " (#{similarity})" if similarity
357
+ operation_parts << text
358
+ elsif copy_from
359
+ text = "copy #{display_path(old_path)} -> #{display_path(new_path)}"
360
+ text += " (#{similarity})" if similarity
361
+ operation_parts << text
362
+ end
363
+
364
+ operation_parts << "mode #{display_path(new_path)} #{old_mode} -> #{new_mode}" if old_mode && new_mode
365
+
366
+ return operation_parts.join(', ') if operation_parts.any?
367
+
368
+ "file operation #{display_path(old_path)} -> #{display_path(new_path)}"
369
+ end
370
+
371
+ def binary_summary(old_path:, new_path:, patch_lines:)
372
+ old_mode = metadata_value(patch_lines, 'old mode ')
373
+ new_mode = metadata_value(patch_lines, 'new mode ')
374
+
375
+ summary = if old_path == '/dev/null'
376
+ "binary add #{display_path(new_path)}"
377
+ elsif new_path == '/dev/null'
378
+ "binary delete #{display_path(old_path)}"
379
+ else
380
+ "binary #{display_path(new_path)}"
381
+ end
382
+ summary += " (mode #{old_mode} -> #{new_mode})" if old_mode && new_mode
383
+ summary
384
+ end
385
+
386
+ def display_path(path)
387
+ return path if path == '/dev/null'
388
+
389
+ path.sub(%r{\A[ab]/}, '')
390
+ end
391
+
392
+ def paths_from_diff_git_line(diff_git_line)
393
+ match = /\Adiff --git (\S+) (\S+)\z/.match(diff_git_line.to_s)
394
+ raise ParseError, 'missing diff --git header needed to infer diff paths' unless match
395
+
396
+ [match[1], match[2]]
397
+ end
398
+
399
+ def hunk_label_for(index)
400
+ current = index
401
+ label = +''
402
+
403
+ loop do
404
+ label.prepend((97 + (current % 26)).chr)
405
+ current = (current / 26) - 1
406
+ break if current.negative?
407
+ end
408
+
409
+ label
410
+ end
411
+ end
412
+ end