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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/Rakefile +8 -0
- data/SKILL.md +157 -0
- data/exe/patch_util +12 -0
- data/lib/patch_util/cli.rb +22 -0
- data/lib/patch_util/diff.rb +132 -0
- data/lib/patch_util/git/cli.rb +664 -0
- data/lib/patch_util/git/rewrite_cli.rb +393 -0
- data/lib/patch_util/git/rewrite_session_manager.rb +480 -0
- data/lib/patch_util/git/rewrite_state_store.rb +81 -0
- data/lib/patch_util/git/rewriter.rb +233 -0
- data/lib/patch_util/git.rb +11 -0
- data/lib/patch_util/parser.rb +412 -0
- data/lib/patch_util/selection.rb +98 -0
- data/lib/patch_util/source.rb +69 -0
- data/lib/patch_util/split/applier.rb +38 -0
- data/lib/patch_util/split/cli.rb +167 -0
- data/lib/patch_util/split/emitter.rb +52 -0
- data/lib/patch_util/split/inspector.rb +203 -0
- data/lib/patch_util/split/plan.rb +33 -0
- data/lib/patch_util/split/plan_store.rb +106 -0
- data/lib/patch_util/split/planner.rb +24 -0
- data/lib/patch_util/split/projector.rb +252 -0
- data/lib/patch_util/split/verifier.rb +133 -0
- data/lib/patch_util/split.rb +21 -0
- data/lib/patch_util/version.rb +5 -0
- data/lib/patch_util.rb +21 -0
- metadata +92 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'shellwords'
|
|
6
|
+
|
|
7
|
+
module PatchUtil
|
|
8
|
+
module Git
|
|
9
|
+
class Cli
|
|
10
|
+
ConflictMarkerDetail = Data.define(:path, :marker_count, :first_marker_line, :excerpt)
|
|
11
|
+
ConflictBlockDetail = Data.define(:path, :block_id, :start_line, :end_line, :ours, :theirs, :ancestor, :excerpt)
|
|
12
|
+
CommitMetadata = Data.define(:subject, :body,
|
|
13
|
+
:author_name, :author_email, :author_date,
|
|
14
|
+
:committer_name, :committer_email, :committer_date)
|
|
15
|
+
|
|
16
|
+
def inside_repo?(path)
|
|
17
|
+
_stdout, _stderr, status = run(path, %w[rev-parse --is-inside-work-tree], raise_on_error: false)
|
|
18
|
+
status.success?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def repo_root(path)
|
|
22
|
+
stdout, = run(path, %w[rev-parse --show-toplevel])
|
|
23
|
+
stdout.strip
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def git_dir(path)
|
|
27
|
+
stdout, = run(path, %w[rev-parse --absolute-git-dir])
|
|
28
|
+
stdout.strip
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def rev_parse(path, revision)
|
|
32
|
+
stdout, = run(path, ['rev-parse', revision])
|
|
33
|
+
stdout.strip
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_commit_patch(path, revision)
|
|
37
|
+
stdout, = run(path, ['show', '--binary', '--format=', '--no-ext-diff', revision, '--'])
|
|
38
|
+
stdout
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parent_shas(path, revision)
|
|
42
|
+
stdout, = run(path, ['rev-list', '--parents', '-n', '1', revision])
|
|
43
|
+
parts = stdout.strip.split(' ')
|
|
44
|
+
parts.drop(1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def merge_commit?(path, revision)
|
|
48
|
+
parent_shas(path, revision).length > 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def show_subject(path, revision)
|
|
52
|
+
stdout, = run(path, ['show', '-s', '--format=%s', revision])
|
|
53
|
+
stdout.strip
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def show_commit_metadata(path, revision)
|
|
57
|
+
stdout, = run(path, ['show', '-s', '--format=%s%x00%b%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI', revision])
|
|
58
|
+
subject, body, author_name, author_email, author_date,
|
|
59
|
+
committer_name, committer_email, committer_date = stdout.split("\0", 8)
|
|
60
|
+
|
|
61
|
+
CommitMetadata.new(
|
|
62
|
+
subject: subject,
|
|
63
|
+
body: (body || '').sub(/\n\z/, ''),
|
|
64
|
+
author_name: author_name,
|
|
65
|
+
author_email: author_email,
|
|
66
|
+
author_date: author_date.to_s.strip,
|
|
67
|
+
committer_name: committer_name,
|
|
68
|
+
committer_email: committer_email,
|
|
69
|
+
committer_date: committer_date.to_s.strip
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def head_sha(path)
|
|
74
|
+
rev_parse(path, 'HEAD')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def current_branch(path)
|
|
78
|
+
stdout, = run(path, %w[branch --show-current])
|
|
79
|
+
stdout.strip
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def worktree_clean?(path)
|
|
83
|
+
stdout, = run(path, %w[status --porcelain])
|
|
84
|
+
stdout.strip.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ancestor?(path, ancestor, descendant)
|
|
88
|
+
_stdout, _stderr, status = run(path, ['merge-base', '--is-ancestor', ancestor, descendant],
|
|
89
|
+
raise_on_error: false)
|
|
90
|
+
status.success?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def rev_list(path, revision_range)
|
|
94
|
+
stdout, = run(path, ['rev-list', '--reverse', revision_range])
|
|
95
|
+
stdout.lines(chomp: true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def worktree_add(path, worktree_path, revision)
|
|
99
|
+
run(path, ['worktree', 'add', '--detach', File.expand_path(worktree_path), revision])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def worktree_remove(path, worktree_path)
|
|
103
|
+
run(path, ['worktree', 'remove', '--force', File.expand_path(worktree_path)])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def apply_patch_text(path, patch_text)
|
|
107
|
+
stdout, stderr, status = Open3.capture3('git', '-C', File.expand_path(path), 'apply', '--whitespace=nowarn',
|
|
108
|
+
'-', stdin_data: patch_text)
|
|
109
|
+
raise PatchUtil::Error, "git apply failed: #{stderr.strip}" unless status.success?
|
|
110
|
+
|
|
111
|
+
stdout
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def commit_all(path, message, env: {})
|
|
115
|
+
add_stdout, add_stderr, add_status = Open3.capture3(env, 'git', '-C', File.expand_path(path), 'add', '-A')
|
|
116
|
+
raise PatchUtil::Error, "git add failed: #{add_stderr.strip}" unless add_status.success?
|
|
117
|
+
|
|
118
|
+
stdout, stderr, status = Open3.capture3(env, 'git', '-C', File.expand_path(path), 'commit', '-m', message)
|
|
119
|
+
raise PatchUtil::Error, "git commit failed: #{stderr.strip}" unless status.success?
|
|
120
|
+
|
|
121
|
+
add_stdout + stdout
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def cherry_pick(path, revision, env: {})
|
|
125
|
+
stdout, stderr, status = Open3.capture3(env, 'git', '-C', File.expand_path(path), 'cherry-pick', revision)
|
|
126
|
+
raise PatchUtil::Error, "git cherry-pick failed for #{revision}: #{stderr.strip}" unless status.success?
|
|
127
|
+
|
|
128
|
+
stdout
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def cherry_pick_continue(path, env: {})
|
|
132
|
+
stdout, stderr, status = Open3.capture3(env, 'git', '-C', File.expand_path(path), 'cherry-pick', '--continue')
|
|
133
|
+
raise PatchUtil::Error, "git cherry-pick --continue failed: #{stderr.strip}" unless status.success?
|
|
134
|
+
|
|
135
|
+
stdout
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cherry_pick_in_progress?(path)
|
|
139
|
+
_stdout, _stderr, status = run(path, %w[rev-parse -q --verify CHERRY_PICK_HEAD], raise_on_error: false)
|
|
140
|
+
status.success?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def cherry_pick_head(path)
|
|
144
|
+
rev_parse(path, 'CHERRY_PICK_HEAD')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unresolved_paths(path)
|
|
148
|
+
stdout, = run(path, %w[diff --name-only --diff-filter=U])
|
|
149
|
+
stdout.lines(chomp: true)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def conflict_marker_details(path, file_paths: nil)
|
|
153
|
+
worktree_path = File.expand_path(path)
|
|
154
|
+
details = []
|
|
155
|
+
|
|
156
|
+
candidate_paths(worktree_path, file_paths).each do |relative_path|
|
|
157
|
+
absolute_path = File.join(worktree_path, relative_path)
|
|
158
|
+
next unless File.file?(absolute_path)
|
|
159
|
+
|
|
160
|
+
detail = conflict_marker_detail(relative_path, absolute_path)
|
|
161
|
+
details << detail if detail
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
details.sort_by(&:path)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def conflict_block_details(path, file_paths: nil)
|
|
168
|
+
worktree_path = File.expand_path(path)
|
|
169
|
+
details = []
|
|
170
|
+
|
|
171
|
+
candidate_paths(worktree_path, file_paths).each do |relative_path|
|
|
172
|
+
absolute_path = File.join(worktree_path, relative_path)
|
|
173
|
+
next unless File.file?(absolute_path)
|
|
174
|
+
|
|
175
|
+
details.concat(conflict_blocks_for_file(relative_path, absolute_path))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
details.sort_by { |detail| [detail.path, detail.block_id] }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def resolve_conflict_block(path, file_path:, block_id:, side:)
|
|
182
|
+
raise PatchUtil::ValidationError, "unsupported conflict side: #{side}" unless %w[ours theirs
|
|
183
|
+
ancestor].include?(side)
|
|
184
|
+
|
|
185
|
+
worktree_path = File.expand_path(path)
|
|
186
|
+
absolute_path = File.join(worktree_path, file_path)
|
|
187
|
+
blocks = conflict_blocks_for_file(file_path, absolute_path)
|
|
188
|
+
block = blocks.find { |candidate| candidate.block_id == block_id }
|
|
189
|
+
raise PatchUtil::ValidationError, "unknown conflict block #{block_id} for #{file_path}" unless block
|
|
190
|
+
|
|
191
|
+
lines = File.readlines(absolute_path, chomp: true)
|
|
192
|
+
replacement = case side
|
|
193
|
+
when 'ours'
|
|
194
|
+
block.ours
|
|
195
|
+
when 'theirs'
|
|
196
|
+
block.theirs
|
|
197
|
+
when 'ancestor'
|
|
198
|
+
if block.ancestor.empty?
|
|
199
|
+
raise PatchUtil::ValidationError,
|
|
200
|
+
"conflict block #{block_id} for #{file_path} has no ancestor section"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
block.ancestor
|
|
204
|
+
end
|
|
205
|
+
replacement_lines = replacement.empty? ? [] : replacement.split("\n", -1)
|
|
206
|
+
updated_lines = lines[0...(block.start_line - 1)] + replacement_lines + lines[block.end_line..]
|
|
207
|
+
File.write(absolute_path, updated_lines.join("\n") + "\n")
|
|
208
|
+
|
|
209
|
+
remaining_blocks = conflict_blocks_for_file(file_path, absolute_path)
|
|
210
|
+
add_paths(path, [file_path]) if remaining_blocks.empty?
|
|
211
|
+
|
|
212
|
+
{
|
|
213
|
+
remaining_blocks: remaining_blocks,
|
|
214
|
+
staged: remaining_blocks.empty?
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def export_conflict_block_template(path, file_path:, block_id:, output_path:)
|
|
219
|
+
worktree_path = File.expand_path(path)
|
|
220
|
+
absolute_path = File.join(worktree_path, file_path)
|
|
221
|
+
blocks = conflict_blocks_for_file(file_path, absolute_path)
|
|
222
|
+
block = blocks.find { |candidate| candidate.block_id == block_id }
|
|
223
|
+
raise PatchUtil::ValidationError, "unknown conflict block #{block_id} for #{file_path}" unless block
|
|
224
|
+
|
|
225
|
+
target_path = File.expand_path(output_path)
|
|
226
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
227
|
+
File.write(target_path, conflict_block_template(block))
|
|
228
|
+
target_path
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def apply_conflict_block_edit(path, file_path:, block_id:, input_path:)
|
|
232
|
+
template_text = File.read(File.expand_path(input_path))
|
|
233
|
+
metadata = extract_template_metadata(template_text)
|
|
234
|
+
validate_template_metadata(metadata, expected_path: file_path, expected_block_id: block_id)
|
|
235
|
+
replacement = extract_editable_content(template_text)
|
|
236
|
+
resolve_conflict_block_with_text(path, file_path: file_path, block_id: block_id, replacement: replacement)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def export_conflict_block_session_template(path, output_path:, file_paths: nil)
|
|
240
|
+
blocks = conflict_block_details(path, file_paths: file_paths)
|
|
241
|
+
raise PatchUtil::ValidationError, 'no conflict blocks found for export' if blocks.empty?
|
|
242
|
+
|
|
243
|
+
target_path = File.expand_path(output_path)
|
|
244
|
+
FileUtils.mkdir_p(File.dirname(target_path))
|
|
245
|
+
File.write(target_path, conflict_block_session_template(blocks))
|
|
246
|
+
{
|
|
247
|
+
output_path: target_path,
|
|
248
|
+
blocks: blocks
|
|
249
|
+
}
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def apply_conflict_block_session_edit(path, input_path:)
|
|
253
|
+
template_text = File.read(File.expand_path(input_path))
|
|
254
|
+
session_metadata = extract_session_metadata(template_text)
|
|
255
|
+
validate_session_template_metadata(session_metadata)
|
|
256
|
+
entries = extract_session_block_entries(template_text)
|
|
257
|
+
raise PatchUtil::ValidationError, 'edited block session template contains no blocks' if entries.empty?
|
|
258
|
+
|
|
259
|
+
validate_session_block_entries(entries, expected_block_count: session_metadata[:block_count])
|
|
260
|
+
preflight_session_block_entries(path, entries)
|
|
261
|
+
|
|
262
|
+
results = []
|
|
263
|
+
grouped_entries = entries.group_by { |entry| entry[:path] }
|
|
264
|
+
grouped_entries.keys.sort.each do |file_path|
|
|
265
|
+
file_entries = grouped_entries.fetch(file_path)
|
|
266
|
+
file_entries.sort_by { |entry| -entry[:block_id] }.each do |entry|
|
|
267
|
+
result = resolve_conflict_block_with_text(path, file_path: entry[:path], block_id: entry[:block_id],
|
|
268
|
+
replacement: entry[:replacement])
|
|
269
|
+
results << entry.merge(remaining_blocks: result[:remaining_blocks], staged: result[:staged])
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
applied_blocks: results.sort_by { |entry| [entry[:path], entry[:block_id]] },
|
|
275
|
+
remaining_blocks_by_file: results.each_with_object({}) do |entry, acc|
|
|
276
|
+
acc[entry[:path]] = entry[:remaining_blocks]
|
|
277
|
+
end,
|
|
278
|
+
staged_paths: results.select { |entry| entry[:staged] }.map { |entry| entry[:path] }.uniq.sort
|
|
279
|
+
}
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def checkout_conflict_side(path, side, file_paths)
|
|
283
|
+
raise PatchUtil::ValidationError, "unsupported conflict side: #{side}" unless %w[ours theirs].include?(side)
|
|
284
|
+
|
|
285
|
+
run(path, ['checkout', "--#{side}", '--', *file_paths])
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def add_paths(path, file_paths)
|
|
289
|
+
run(path, ['add', '--', *file_paths])
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def update_ref(path, ref, new_oid, old_oid = nil)
|
|
293
|
+
args = ['update-ref', ref, new_oid]
|
|
294
|
+
args << old_oid if old_oid
|
|
295
|
+
run(path, args)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def reset_hard(path, revision)
|
|
299
|
+
run(path, ['reset', '--hard', revision])
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
def candidate_paths(worktree_path, file_paths)
|
|
305
|
+
return file_paths.uniq if file_paths && !file_paths.empty?
|
|
306
|
+
|
|
307
|
+
glob = File.join(worktree_path, '**', '*')
|
|
308
|
+
Dir.glob(glob, File::FNM_DOTMATCH).filter_map do |absolute_path|
|
|
309
|
+
next if File.directory?(absolute_path)
|
|
310
|
+
|
|
311
|
+
relative_path = absolute_path.delete_prefix("#{worktree_path}/")
|
|
312
|
+
next if relative_path.start_with?('.git/') || relative_path == '.git'
|
|
313
|
+
|
|
314
|
+
relative_path
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def conflict_marker_detail(relative_path, absolute_path)
|
|
319
|
+
lines = File.readlines(absolute_path, chomp: true)
|
|
320
|
+
marker_indexes = []
|
|
321
|
+
lines.each_with_index do |line, index|
|
|
322
|
+
marker_indexes << index if line.start_with?('<<<<<<< ')
|
|
323
|
+
end
|
|
324
|
+
return nil if marker_indexes.empty?
|
|
325
|
+
|
|
326
|
+
first_index = marker_indexes.first
|
|
327
|
+
end_index = find_conflict_end(lines, first_index)
|
|
328
|
+
excerpt_start = [first_index - 1, 0].max
|
|
329
|
+
excerpt_end = [end_index + 1, lines.length - 1].min
|
|
330
|
+
|
|
331
|
+
ConflictMarkerDetail.new(
|
|
332
|
+
path: relative_path,
|
|
333
|
+
marker_count: marker_indexes.length,
|
|
334
|
+
first_marker_line: first_index + 1,
|
|
335
|
+
excerpt: lines[excerpt_start..excerpt_end].join("\n")
|
|
336
|
+
)
|
|
337
|
+
rescue Errno::ENOENT, EncodingError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def find_conflict_end(lines, start_index)
|
|
342
|
+
index = start_index
|
|
343
|
+
while index < lines.length
|
|
344
|
+
return index if lines[index].start_with?('>>>>>>> ')
|
|
345
|
+
|
|
346
|
+
index += 1
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
lines.length - 1
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def conflict_blocks_for_file(relative_path, absolute_path)
|
|
353
|
+
lines = File.readlines(absolute_path, chomp: true)
|
|
354
|
+
index = 0
|
|
355
|
+
block_id = 1
|
|
356
|
+
blocks = []
|
|
357
|
+
|
|
358
|
+
while index < lines.length
|
|
359
|
+
unless lines[index].start_with?('<<<<<<< ')
|
|
360
|
+
index += 1
|
|
361
|
+
next
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
start_index = index
|
|
365
|
+
ancestor_index = nil
|
|
366
|
+
separator_index = nil
|
|
367
|
+
end_index = nil
|
|
368
|
+
cursor = index + 1
|
|
369
|
+
|
|
370
|
+
while cursor < lines.length
|
|
371
|
+
line = lines[cursor]
|
|
372
|
+
ancestor_index ||= cursor if line.start_with?('||||||| ')
|
|
373
|
+
separator_index ||= cursor if line == '======='
|
|
374
|
+
if line.start_with?('>>>>>>> ')
|
|
375
|
+
end_index = cursor
|
|
376
|
+
break
|
|
377
|
+
end
|
|
378
|
+
cursor += 1
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
break unless separator_index && end_index
|
|
382
|
+
|
|
383
|
+
ours_start = start_index + 1
|
|
384
|
+
ours_end = (ancestor_index || separator_index) - 1
|
|
385
|
+
theirs_start = separator_index + 1
|
|
386
|
+
theirs_end = end_index - 1
|
|
387
|
+
ancestor = if ancestor_index
|
|
388
|
+
ancestor_start = ancestor_index + 1
|
|
389
|
+
ancestor_end = separator_index - 1
|
|
390
|
+
slice(lines, ancestor_start, ancestor_end)
|
|
391
|
+
else
|
|
392
|
+
''
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
blocks << ConflictBlockDetail.new(
|
|
396
|
+
path: relative_path,
|
|
397
|
+
block_id: block_id,
|
|
398
|
+
start_line: start_index + 1,
|
|
399
|
+
end_line: end_index + 1,
|
|
400
|
+
ours: slice(lines, ours_start, ours_end),
|
|
401
|
+
theirs: slice(lines, theirs_start, theirs_end),
|
|
402
|
+
ancestor: ancestor,
|
|
403
|
+
excerpt: lines[start_index..end_index].join("\n")
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
block_id += 1
|
|
407
|
+
index = end_index + 1
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
blocks
|
|
411
|
+
rescue Errno::ENOENT, EncodingError
|
|
412
|
+
[]
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def slice(lines, start_index, end_index)
|
|
416
|
+
return '' if end_index < start_index
|
|
417
|
+
|
|
418
|
+
lines[start_index..end_index].join("\n")
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def resolve_conflict_block_with_text(path, file_path:, block_id:, replacement:)
|
|
422
|
+
worktree_path = File.expand_path(path)
|
|
423
|
+
absolute_path = File.join(worktree_path, file_path)
|
|
424
|
+
blocks = conflict_blocks_for_file(file_path, absolute_path)
|
|
425
|
+
block = blocks.find { |candidate| candidate.block_id == block_id }
|
|
426
|
+
raise PatchUtil::ValidationError, "unknown conflict block #{block_id} for #{file_path}" unless block
|
|
427
|
+
|
|
428
|
+
lines = File.readlines(absolute_path, chomp: true)
|
|
429
|
+
replacement_lines = replacement.empty? ? [] : replacement.split("\n", -1)
|
|
430
|
+
updated_lines = lines[0...(block.start_line - 1)] + replacement_lines + lines[block.end_line..]
|
|
431
|
+
File.write(absolute_path, updated_lines.join("\n") + "\n")
|
|
432
|
+
|
|
433
|
+
remaining_blocks = conflict_blocks_for_file(file_path, absolute_path)
|
|
434
|
+
add_paths(path, [file_path]) if remaining_blocks.empty?
|
|
435
|
+
|
|
436
|
+
{
|
|
437
|
+
remaining_blocks: remaining_blocks,
|
|
438
|
+
staged: remaining_blocks.empty?
|
|
439
|
+
}
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def conflict_block_template(block)
|
|
443
|
+
sections = []
|
|
444
|
+
sections << '# patch_util retained conflict block edit template'
|
|
445
|
+
sections << '# format: patch_util-conflict-block-v1'
|
|
446
|
+
sections << "# path: #{block.path}"
|
|
447
|
+
sections << "# block id: #{block.block_id}"
|
|
448
|
+
sections << '# Edit only the content between BEGIN/END EDIT.'
|
|
449
|
+
sections << '### BEGIN EDIT ###'
|
|
450
|
+
sections << block.ours
|
|
451
|
+
sections << '### END EDIT ###'
|
|
452
|
+
sections << '### OURS ###'
|
|
453
|
+
sections << block.ours
|
|
454
|
+
sections << '### END OURS ###'
|
|
455
|
+
unless block.ancestor.empty?
|
|
456
|
+
sections << '### ANCESTOR ###'
|
|
457
|
+
sections << block.ancestor
|
|
458
|
+
sections << '### END ANCESTOR ###'
|
|
459
|
+
end
|
|
460
|
+
sections << '### THEIRS ###'
|
|
461
|
+
sections << block.theirs
|
|
462
|
+
sections << '### END THEIRS ###'
|
|
463
|
+
sections << '### EXCERPT ###'
|
|
464
|
+
sections << block.excerpt
|
|
465
|
+
sections << '### END EXCERPT ###'
|
|
466
|
+
sections.join("\n") + "\n"
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def conflict_block_session_template(blocks)
|
|
470
|
+
sections = []
|
|
471
|
+
sections << '# patch_util retained conflict block edit session template'
|
|
472
|
+
sections << '# format: patch_util-conflict-session-v1'
|
|
473
|
+
sections << "# block count: #{blocks.length}"
|
|
474
|
+
blocks.each do |block|
|
|
475
|
+
sections << '### BLOCK START ###'
|
|
476
|
+
sections << "# path: #{block.path}"
|
|
477
|
+
sections << "# block id: #{block.block_id}"
|
|
478
|
+
sections << '# Edit only the content between BEGIN/END EDIT.'
|
|
479
|
+
sections << '### BEGIN EDIT ###'
|
|
480
|
+
sections << block.ours
|
|
481
|
+
sections << '### END EDIT ###'
|
|
482
|
+
sections << '### OURS ###'
|
|
483
|
+
sections << block.ours
|
|
484
|
+
sections << '### END OURS ###'
|
|
485
|
+
unless block.ancestor.empty?
|
|
486
|
+
sections << '### ANCESTOR ###'
|
|
487
|
+
sections << block.ancestor
|
|
488
|
+
sections << '### END ANCESTOR ###'
|
|
489
|
+
end
|
|
490
|
+
sections << '### THEIRS ###'
|
|
491
|
+
sections << block.theirs
|
|
492
|
+
sections << '### END THEIRS ###'
|
|
493
|
+
sections << '### EXCERPT ###'
|
|
494
|
+
sections << block.excerpt
|
|
495
|
+
sections << '### END EXCERPT ###'
|
|
496
|
+
sections << '### BLOCK END ###'
|
|
497
|
+
end
|
|
498
|
+
sections.join("\n") + "\n"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def extract_editable_content(text)
|
|
502
|
+
lines = text.lines(chomp: true)
|
|
503
|
+
begin_index = lines.index('### BEGIN EDIT ###')
|
|
504
|
+
end_index = lines.index('### END EDIT ###')
|
|
505
|
+
if begin_index.nil?
|
|
506
|
+
raise PatchUtil::ValidationError,
|
|
507
|
+
'edited block template is missing ### BEGIN EDIT ### marker'
|
|
508
|
+
end
|
|
509
|
+
raise PatchUtil::ValidationError, 'edited block template is missing ### END EDIT ### marker' if end_index.nil?
|
|
510
|
+
|
|
511
|
+
if end_index < begin_index
|
|
512
|
+
raise PatchUtil::ValidationError,
|
|
513
|
+
'edited block template has END EDIT before BEGIN EDIT'
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
lines[(begin_index + 1)...end_index].join("\n")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def extract_template_metadata(text)
|
|
520
|
+
metadata = {}
|
|
521
|
+
text.lines(chomp: true).each do |line|
|
|
522
|
+
metadata[:format] = line.delete_prefix('# format: ').strip if line.start_with?('# format: ')
|
|
523
|
+
metadata[:path] = line.delete_prefix('# path: ').strip if line.start_with?('# path: ')
|
|
524
|
+
if line.start_with?('# block id: ')
|
|
525
|
+
raw = line.delete_prefix('# block id: ').strip
|
|
526
|
+
metadata[:block_id] = Integer(raw, 10)
|
|
527
|
+
end
|
|
528
|
+
rescue ArgumentError
|
|
529
|
+
raise PatchUtil::ValidationError, "edited block template has invalid block id: #{raw.inspect}"
|
|
530
|
+
end
|
|
531
|
+
metadata
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def extract_session_metadata(text)
|
|
535
|
+
metadata = {}
|
|
536
|
+
text.lines(chomp: true).each do |line|
|
|
537
|
+
metadata[:format] = line.delete_prefix('# format: ').strip if line.start_with?('# format: ')
|
|
538
|
+
if line.start_with?('# block count: ')
|
|
539
|
+
raw = line.delete_prefix('# block count: ').strip
|
|
540
|
+
metadata[:block_count] = Integer(raw, 10)
|
|
541
|
+
end
|
|
542
|
+
rescue ArgumentError
|
|
543
|
+
raise PatchUtil::ValidationError, "edited block session template has invalid block count: #{raw.inspect}"
|
|
544
|
+
end
|
|
545
|
+
metadata
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def validate_session_template_metadata(metadata)
|
|
549
|
+
unless metadata[:format] == 'patch_util-conflict-session-v1'
|
|
550
|
+
raise PatchUtil::ValidationError,
|
|
551
|
+
'edited block session template is missing or has unknown # format metadata'
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
return if metadata.key?(:block_count)
|
|
555
|
+
|
|
556
|
+
raise PatchUtil::ValidationError,
|
|
557
|
+
'edited block session template is missing # block count metadata'
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def validate_session_block_entries(entries, expected_block_count:)
|
|
561
|
+
if entries.length != expected_block_count
|
|
562
|
+
raise PatchUtil::ValidationError,
|
|
563
|
+
"edited block session template declares #{expected_block_count} blocks but contains #{entries.length} blocks"
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
duplicates = entries.group_by { |entry| [entry[:path], entry[:block_id]] }
|
|
567
|
+
.select { |_identity, grouped_entries| grouped_entries.length > 1 }
|
|
568
|
+
return if duplicates.empty?
|
|
569
|
+
|
|
570
|
+
path, block_id = duplicates.keys.sort.first
|
|
571
|
+
raise PatchUtil::ValidationError,
|
|
572
|
+
"edited block session template repeats block #{block_id} for #{path}"
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def preflight_session_block_entries(path, entries)
|
|
576
|
+
selected_paths = entries.map { |entry| entry[:path] }.uniq
|
|
577
|
+
available_blocks = conflict_block_details(path,
|
|
578
|
+
file_paths: selected_paths).each_with_object({}) do |block, index|
|
|
579
|
+
index[[block.path, block.block_id]] = true
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
entries.each do |entry|
|
|
583
|
+
next if available_blocks[[entry[:path], entry[:block_id]]]
|
|
584
|
+
|
|
585
|
+
raise PatchUtil::ValidationError,
|
|
586
|
+
"edited block session template references missing block #{entry[:block_id]} for #{entry[:path]}"
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def extract_session_block_entries(text)
|
|
591
|
+
lines = text.lines(chomp: true)
|
|
592
|
+
entries = []
|
|
593
|
+
index = 0
|
|
594
|
+
|
|
595
|
+
while index < lines.length
|
|
596
|
+
unless lines[index] == '### BLOCK START ###'
|
|
597
|
+
index += 1
|
|
598
|
+
next
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
end_index = lines[(index + 1)..]&.index('### BLOCK END ###')
|
|
602
|
+
if end_index.nil?
|
|
603
|
+
raise PatchUtil::ValidationError,
|
|
604
|
+
'edited block session template is missing ### BLOCK END ### marker'
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
section_lines = lines[(index + 1)...(index + 1 + end_index)]
|
|
608
|
+
section_text = section_lines.join("\n")
|
|
609
|
+
metadata = extract_template_metadata(section_text)
|
|
610
|
+
unless metadata.key?(:path)
|
|
611
|
+
raise PatchUtil::ValidationError,
|
|
612
|
+
'edited block session block is missing # path metadata'
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
unless metadata.key?(:block_id)
|
|
616
|
+
raise PatchUtil::ValidationError,
|
|
617
|
+
'edited block session block is missing # block id metadata'
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
entries << {
|
|
621
|
+
path: metadata[:path],
|
|
622
|
+
block_id: metadata[:block_id],
|
|
623
|
+
replacement: extract_editable_content(section_text)
|
|
624
|
+
}
|
|
625
|
+
index += end_index + 2
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
entries
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def validate_template_metadata(metadata, expected_path:, expected_block_id:)
|
|
632
|
+
unless metadata[:format] == 'patch_util-conflict-block-v1'
|
|
633
|
+
raise PatchUtil::ValidationError,
|
|
634
|
+
'edited block template is missing or has unknown # format metadata'
|
|
635
|
+
end
|
|
636
|
+
raise PatchUtil::ValidationError, 'edited block template is missing # path metadata' unless metadata.key?(:path)
|
|
637
|
+
|
|
638
|
+
unless metadata[:path] == expected_path
|
|
639
|
+
raise PatchUtil::ValidationError,
|
|
640
|
+
"edited block template path #{metadata[:path].inspect} does not match requested path #{expected_path.inspect}"
|
|
641
|
+
end
|
|
642
|
+
unless metadata.key?(:block_id)
|
|
643
|
+
raise PatchUtil::ValidationError,
|
|
644
|
+
'edited block template is missing # block id metadata'
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
return if metadata[:block_id] == expected_block_id
|
|
648
|
+
|
|
649
|
+
raise PatchUtil::ValidationError,
|
|
650
|
+
"edited block template block id #{metadata[:block_id]} does not match requested block #{expected_block_id}"
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def run(path, args, raise_on_error: true)
|
|
654
|
+
command = ['git', '-C', File.expand_path(path), *args]
|
|
655
|
+
stdout, stderr, status = Open3.capture3(*command)
|
|
656
|
+
if raise_on_error && !status.success?
|
|
657
|
+
raise PatchUtil::Error, "git command failed: #{command.join(' ')}\n#{stderr.strip}"
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
[stdout, stderr, status]
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
end
|