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,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