smorg-rb 7.1.1

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.
data/lib/smorg/rb.rb ADDED
@@ -0,0 +1,975 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'go-merge'
5
+ require 'ast/merge'
6
+ require 'ast-merge-git'
7
+ require 'diff/lcs'
8
+ require 'diff/lcs/hunk'
9
+ require 'json'
10
+ require 'json-merge'
11
+ require 'kettle/jem'
12
+ require 'kettle/jem/tasks/install_task'
13
+ require 'markly/merge'
14
+ require 'plain-merge'
15
+ require_relative 'rb/version'
16
+
17
+ module Smorg
18
+ module RB
19
+ EXIT_SUCCESS = 0
20
+ EXIT_UNRESOLVED_CONFLICT = 1
21
+ EXIT_USER_ERROR = 2
22
+ EXIT_INTERNAL_ERROR = 3
23
+ REVIEW_DIFF_CONTEXT_LINES = 3
24
+
25
+ module_function
26
+
27
+ def run(args, stdout: $stdout, stderr: $stderr)
28
+ command, *rest = args
29
+ case command
30
+ when 'merge-driver'
31
+ run_merge_driver(rest, stdout, stderr)
32
+ when 'diff-driver'
33
+ run_diff_driver(rest, stdout, stderr)
34
+ when 'conflicts'
35
+ run_conflicts(rest, stdout, stderr)
36
+ when 'languages'
37
+ run_languages(rest, stdout, stderr)
38
+ when 'git'
39
+ run_git(rest, stdout, stderr)
40
+ when 'help', '-h', '--help'
41
+ print_usage(stdout)
42
+ EXIT_SUCCESS
43
+ else
44
+ stderr.puts("unknown command #{command.inspect}") if command
45
+ print_usage(stderr)
46
+ EXIT_USER_ERROR
47
+ end
48
+ end
49
+
50
+ def print_usage(out)
51
+ out.puts('usage: smorg-rb merge-driver [--path-name PATH] [--output PATH] [--report PATH] [--strict] [--fallback=none|line|local|full-file] %O %A %B [%P]')
52
+ out.puts(' smorg-rb merge-driver --ancestor %O --current %A --other %B --path-name %P')
53
+ out.puts(' smorg-rb diff-driver [--path-name PATH] OLD NEW')
54
+ out.puts(' smorg-rb diff-driver PATH OLD-FILE OLD-HEX OLD-MODE NEW-FILE NEW-HEX NEW-MODE [OLD-PREFIX NEW-PREFIX]')
55
+ out.puts(' smorg-rb conflicts diff [--path-name PATH] [--exit-code] FILE')
56
+ out.puts(' smorg-rb languages --gitattributes')
57
+ out.puts(' smorg-rb git install [--scope local|global|include-file] [--profile semantic-diff|builtin-diff] [--check] [--undo] [--dry-run] [--json]')
58
+ end
59
+
60
+ def run_git(args, stdout, stderr)
61
+ subcommand, *rest = args
62
+ return git_usage(stderr) unless subcommand == 'install'
63
+
64
+ options = parse_git_install_options(rest, stderr)
65
+ return EXIT_USER_ERROR unless options
66
+
67
+ run_options = git_install_run_options(options)
68
+ step = Kettle::Jem::Tasks::InstallTask.git_drivers_step(Dir.pwd, run_options)
69
+ step = Kettle::Jem::Tasks::InstallTask.execute_orchestration_steps(
70
+ [step],
71
+ project_root: Dir.pwd,
72
+ env: ENV.to_h,
73
+ run_options: run_options,
74
+ command_runner: Kettle::Jem::Tasks::InstallTask.method(:run_system_command)
75
+ ).first
76
+ report = {
77
+ report_version: 1,
78
+ ok: step.fetch(:status) != 'failed',
79
+ profile: step.fetch(:profile, 'semantic-diff'),
80
+ scope: step.fetch(:scope, run_options.fetch(:git_drivers, 'local')),
81
+ install_steps: [step],
82
+ missing: step.fetch(:missing, [])
83
+ }
84
+ if options[:json]
85
+ stdout.puts(JSON.pretty_generate(report))
86
+ else
87
+ stdout.puts("git install: #{step.fetch(:status)} #{report.fetch(:profile)} #{report.fetch(:scope)}")
88
+ step.fetch(:diagnostics, []).each do |diagnostic|
89
+ stdout.puts(" #{diagnostic.fetch(:message)}") if diagnostic[:message]
90
+ end
91
+ end
92
+ report.fetch(:ok) ? EXIT_SUCCESS : EXIT_USER_ERROR
93
+ rescue Kettle::Jem::Error => e
94
+ stderr.puts(e.message)
95
+ EXIT_USER_ERROR
96
+ end
97
+
98
+ def git_usage(stderr)
99
+ stderr.puts('usage: smorg-rb git install [--scope local|global|include-file] [--profile semantic-diff|builtin-diff] [--check] [--undo] [--dry-run] [--json]')
100
+ EXIT_USER_ERROR
101
+ end
102
+
103
+ def parse_git_install_options(args, stderr)
104
+ options = { scope: 'local', profile: 'semantic-diff', json: false, dry_run: false }
105
+ until args.empty?
106
+ value = args.shift
107
+ case value
108
+ when '--scope'
109
+ options[:scope] = args.shift.to_s
110
+ when '--profile'
111
+ options[:profile] = args.shift.to_s
112
+ when '--check'
113
+ options[:check] = true
114
+ when '--undo'
115
+ options[:undo] = true
116
+ when '--dry-run'
117
+ options[:dry_run] = true
118
+ when '--json'
119
+ options[:json] = true
120
+ else
121
+ stderr.puts("unknown git install option #{value.inspect}")
122
+ return nil
123
+ end
124
+ end
125
+ options
126
+ end
127
+
128
+ def git_install_run_options(options)
129
+ git_drivers = if options[:check]
130
+ 'check'
131
+ elsif options[:undo]
132
+ 'undo'
133
+ elsif options.fetch(:scope) == 'global'
134
+ 'global'
135
+ elsif options.fetch(:scope) == 'include-file'
136
+ 'include-file'
137
+ elsif options.fetch(:profile) == 'builtin-diff'
138
+ 'builtin-diff'
139
+ else
140
+ 'semantic-diff'
141
+ end
142
+ { git_drivers: git_drivers, dry_run: options[:dry_run] }.compact
143
+ end
144
+
145
+ def run_merge_driver(args, stdout, stderr)
146
+ options = parse_merge_driver_options(args, stderr)
147
+ return EXIT_USER_ERROR unless options
148
+
149
+ ancestor_source = File.read(options[:ancestor])
150
+ current_source = File.read(options[:current])
151
+ other_source = File.read(options[:other])
152
+
153
+ effective_path = options[:path_name] || options[:current]
154
+ settings = load_path_settings(effective_path)
155
+ options[:profile_id] ||= settings[:profile_id]
156
+ options[:require_profile_status] ||= settings[:require_profile_status]
157
+ profile_exit = report_and_enforce_profile(options, stdout, stderr)
158
+ return profile_exit unless profile_exit == EXIT_SUCCESS
159
+
160
+ fallback_policy = options[:strict] ? 'none' : options[:fallback]
161
+ result = merge_by_path(effective_path, settings[:language], settings[:conflict_marker_size], fallback_policy,
162
+ ancestor_source, current_source, other_source)
163
+ fallbacks = []
164
+ if merge_driver_fallback_eligible?(result, options)
165
+ result, fallbacks = apply_merge_fallbacks(
166
+ result,
167
+ options[:fallback],
168
+ settings[:conflict_marker_size],
169
+ ancestor_source,
170
+ current_source,
171
+ other_source
172
+ )
173
+ end
174
+ output = result[:output]
175
+ unless result[:ok]
176
+ print_diagnostics(stderr, result)
177
+ unless options[:strict] || options[:fallback] == 'none'
178
+ output ||= full_file_conflict_output(settings[:conflict_marker_size], ancestor_source, current_source,
179
+ other_source)
180
+ end
181
+ if output && !result[:output] && !options[:strict] && options[:fallback] != 'none'
182
+ fallbacks << {
183
+ mode: 'full_file',
184
+ requested_mode: options[:fallback],
185
+ reason: fallback_reason(result.fetch(:diagnostics, [])),
186
+ applied: true
187
+ }
188
+ end
189
+ report_exit = write_merge_driver_machine_report(options[:report], effective_path, false,
190
+ EXIT_UNRESOLVED_CONFLICT, fallbacks, result, stderr)
191
+ return report_exit unless report_exit == EXIT_SUCCESS
192
+ return EXIT_UNRESOLVED_CONFLICT if options[:check_only]
193
+
194
+ File.write(options[:output] || options[:current], output) if output
195
+ return EXIT_UNRESOLVED_CONFLICT
196
+ end
197
+ unless output
198
+ stderr.puts('merge completed without output')
199
+ return EXIT_INTERNAL_ERROR
200
+ end
201
+
202
+ if options[:check_only]
203
+ exit_code = options[:exit_code] && output != current_source ? EXIT_UNRESOLVED_CONFLICT : EXIT_SUCCESS
204
+ report_exit = write_merge_driver_machine_report(options[:report], effective_path, true, exit_code, fallbacks,
205
+ result, stderr)
206
+ return report_exit unless report_exit == EXIT_SUCCESS
207
+
208
+ return exit_code
209
+ end
210
+
211
+ File.write(options[:output] || options[:current], output)
212
+ report_exit = write_merge_driver_machine_report(options[:report], effective_path, true, EXIT_SUCCESS, fallbacks,
213
+ result, stderr)
214
+ return report_exit unless report_exit == EXIT_SUCCESS
215
+
216
+ EXIT_SUCCESS
217
+ rescue Errno::ENOENT, Errno::EACCES => e
218
+ stderr.puts("file error: #{e.message}")
219
+ EXIT_USER_ERROR
220
+ rescue StandardError => e
221
+ stderr.puts("internal error: #{e.message}")
222
+ EXIT_INTERNAL_ERROR
223
+ end
224
+
225
+ def apply_merge_fallbacks(result, requested_mode, marker_size, ancestor_source, current_source, other_source)
226
+ fallback_reason_value = fallback_reason(result.fetch(:diagnostics, []))
227
+ git_result = merge_git_file_fallback(marker_size, ancestor_source, current_source, other_source)
228
+ if git_result[:output]
229
+ return [
230
+ merge_fallback_result(result, git_result),
231
+ [
232
+ {
233
+ mode: 'git_merge_file',
234
+ requested_mode: requested_mode,
235
+ reason: fallback_reason_value,
236
+ applied: true
237
+ }
238
+ ]
239
+ ]
240
+ end
241
+
242
+ plain_result = merge_plain_fallback(other_source, current_source)
243
+ if plain_result[:ok] && plain_result[:output]
244
+ return [
245
+ merge_fallback_result(result, plain_result),
246
+ [
247
+ {
248
+ mode: 'git_merge_file',
249
+ requested_mode: requested_mode,
250
+ reason: fallback_reason_value,
251
+ applied: false
252
+ },
253
+ {
254
+ mode: 'plain_text',
255
+ requested_mode: requested_mode,
256
+ reason: fallback_reason(git_result.fetch(:diagnostics, [])),
257
+ applied: true
258
+ }
259
+ ]
260
+ ]
261
+ end
262
+
263
+ [result, []]
264
+ end
265
+
266
+ def merge_driver_fallback_eligible?(result, options)
267
+ return false if result[:ok] || options[:strict] || options[:fallback] == 'none'
268
+
269
+ result.fetch(:diagnostics, []).any? do |diagnostic|
270
+ (diagnostic[:category] || diagnostic['category']).to_s == 'unsupported_language'
271
+ end
272
+ end
273
+
274
+ def merge_plain_fallback(other_source, current_source)
275
+ Plain::Merge.merge_text(other_source, current_source)
276
+ rescue StandardError => e
277
+ {
278
+ ok: false,
279
+ diagnostics: [{ severity: 'error', category: 'plain_text_fallback_error', message: e.message }],
280
+ policies: []
281
+ }
282
+ end
283
+
284
+ def merge_git_file_fallback(marker_size, ancestor_source, current_source, other_source)
285
+ require 'tempfile'
286
+
287
+ files = Array.new(3) { Tempfile.new('smorg-rb-merge-file') }
288
+ files[0].write(current_source)
289
+ files[1].write(ancestor_source)
290
+ files[2].write(other_source)
291
+ files.each(&:flush)
292
+ output = IO.popen(
293
+ [
294
+ 'git',
295
+ 'merge-file',
296
+ '-p',
297
+ '-L',
298
+ 'ours',
299
+ '-L',
300
+ 'base',
301
+ '-L',
302
+ 'theirs',
303
+ "--marker-size=#{marker_size}",
304
+ files[0].path,
305
+ files[1].path,
306
+ files[2].path
307
+ ],
308
+ err: %i[child out],
309
+ &:read
310
+ )
311
+ {
312
+ ok: $CHILD_STATUS.success?,
313
+ diagnostics: if $CHILD_STATUS.success?
314
+ []
315
+ else
316
+ [{ severity: 'error', category: 'git_merge_file_conflict',
317
+ message: 'git merge-file reported unresolved conflicts' }]
318
+ end,
319
+ output: output,
320
+ policies: []
321
+ }
322
+ rescue Errno::ENOENT => e
323
+ {
324
+ ok: false,
325
+ diagnostics: [{ severity: 'error', category: 'git_merge_file_unavailable', message: e.message }],
326
+ policies: []
327
+ }
328
+ ensure
329
+ files&.each do |file|
330
+ file.close
331
+ file.unlink
332
+ end
333
+ end
334
+
335
+ def merge_fallback_result(original_result, fallback_result)
336
+ original_diagnostics = original_result.fetch(:diagnostics, [])
337
+ fallback_diagnostics = fallback_result.fetch(:diagnostics, [])
338
+ {
339
+ **original_result,
340
+ ok: fallback_result[:ok],
341
+ diagnostics: original_diagnostics + fallback_diagnostics,
342
+ output: fallback_result[:output],
343
+ policies: fallback_result.fetch(:policies, original_result.fetch(:policies, []))
344
+ }
345
+ end
346
+
347
+ def full_file_conflict_output(marker_size, ancestor_source, current_source, other_source)
348
+ marker_size = marker_size.to_i
349
+ marker_size = 7 unless marker_size.positive?
350
+ [
351
+ "#{'<' * marker_size} ours",
352
+ current_source,
353
+ "#{'|' * marker_size} base",
354
+ ancestor_source,
355
+ '=' * marker_size,
356
+ other_source,
357
+ "#{'>' * marker_size} theirs",
358
+ ''
359
+ ].join("\n")
360
+ end
361
+
362
+ def parse_merge_driver_options(args, stderr)
363
+ options = { strict: false, fallback: 'full-file', check_only: false, exit_code: false, profile_report: false }
364
+ positionals = []
365
+ index = 0
366
+ while index < args.length
367
+ value = args[index]
368
+ case value
369
+ when '--ancestor'
370
+ index += 1
371
+ options[:ancestor] = args[index]
372
+ when '--current'
373
+ index += 1
374
+ options[:current] = args[index]
375
+ when '--other'
376
+ index += 1
377
+ options[:other] = args[index]
378
+ when '--path-name'
379
+ index += 1
380
+ options[:path_name] = args[index]
381
+ when '--output'
382
+ index += 1
383
+ options[:output] = args[index]
384
+ when '--report'
385
+ index += 1
386
+ options[:report] = args[index]
387
+ when '--strict'
388
+ options[:strict] = true
389
+ when '--check-only'
390
+ options[:check_only] = true
391
+ when '--exit-code'
392
+ options[:exit_code] = true
393
+ when '--profile'
394
+ index += 1
395
+ options[:profile_id] = args[index]
396
+ when '--profile-report'
397
+ options[:profile_report] = true
398
+ when '--require-profile-status'
399
+ index += 1
400
+ options[:require_profile_status] = args[index]
401
+ when '--fallback'
402
+ index += 1
403
+ options[:fallback] = args[index]
404
+ else
405
+ if value.start_with?('--fallback=')
406
+ options[:fallback] = value.delete_prefix('--fallback=')
407
+ elsif value.start_with?('--')
408
+ stderr.puts("unknown merge-driver option #{value.inspect}")
409
+ return nil
410
+ else
411
+ positionals << value
412
+ end
413
+ end
414
+ index += 1
415
+ end
416
+
417
+ options[:ancestor] ||= positionals[0]
418
+ options[:current] ||= positionals[1]
419
+ options[:other] ||= positionals[2]
420
+ options[:path_name] ||= positionals[3]
421
+
422
+ unless options[:ancestor] && options[:current] && options[:other]
423
+ stderr.puts('merge-driver requires ancestor, current, and other paths')
424
+ return nil
425
+ end
426
+ unless %w[none line local full-file].include?(options[:fallback])
427
+ stderr.puts("unsupported fallback mode #{options[:fallback].inspect}")
428
+ return nil
429
+ end
430
+ options
431
+ end
432
+
433
+ def write_merge_driver_machine_report(report_path, path_name, ok, exit_code, fallbacks, result, stderr)
434
+ return EXIT_SUCCESS unless report_path
435
+
436
+ report = {
437
+ command: 'merge-driver',
438
+ path_name: path_name,
439
+ ok: ok,
440
+ exit_code: exit_code,
441
+ fallbacks: fallbacks,
442
+ change_classifications: result.fetch(:change_classifications, []),
443
+ owned_regions: result.fetch(:owned_regions, []),
444
+ render_report: result[:render_report],
445
+ reparse_after_render: result[:reparse_after_render],
446
+ formatting_preservation: result[:formatting_preservation],
447
+ secondary_formatting_metrics: result[:secondary_formatting_metrics],
448
+ default_driver_evaluation: result[:default_driver_evaluation],
449
+ profile: result[:profile],
450
+ diagnostics: result.fetch(:diagnostics, [])
451
+ }
452
+ File.write(report_path, "#{JSON.pretty_generate(Ast::Merge.json_ready(report))}\n")
453
+ EXIT_SUCCESS
454
+ rescue StandardError => e
455
+ stderr.puts("write report: #{e.message}")
456
+ EXIT_INTERNAL_ERROR
457
+ end
458
+
459
+ def fallback_reason(diagnostics)
460
+ first = diagnostics.first
461
+ return 'structured_merge_failed' unless first
462
+
463
+ (first[:category] || first['category'] || 'structured_merge_failed').to_s
464
+ end
465
+
466
+ def report_and_enforce_profile(options, stdout, stderr)
467
+ return EXIT_SUCCESS unless options[:profile_id] || options[:profile_report] || options[:require_profile_status]
468
+
469
+ profile_id = options[:profile_id] || Ast::Merge::PROMOTION_PROFILE_JSON_KEYED_OBJECT
470
+ evaluation = Ast::Merge::ProfilePromotionEvaluation.new(
471
+ profile_id: profile_id,
472
+ status: 'available',
473
+ blocking_reasons: ['profile promotion evidence is not loaded by this CLI command'],
474
+ diagnostics: []
475
+ )
476
+ decision = Ast::Merge.evaluate_profile_selection_requirement(
477
+ Ast::Merge::ProfileSelectionRequirement.new(
478
+ profile_id: profile_id,
479
+ promotion_policy_id: Ast::Merge.initial_profile_promotion_policy.policy_id,
480
+ minimum_profile_status: options[:require_profile_status] || 'available',
481
+ enforcement_mode: options[:require_profile_status] ? 'required' : 'advisory'
482
+ ),
483
+ nil,
484
+ evaluation
485
+ )
486
+ stdout.puts(JSON.generate(Ast::Merge.json_ready(decision.to_h))) if options[:profile_report]
487
+ unless decision.allowed
488
+ stderr.puts(decision.blocking_reasons.first)
489
+ return EXIT_USER_ERROR
490
+ end
491
+ EXIT_SUCCESS
492
+ end
493
+
494
+ def run_diff_driver(args, stdout, stderr)
495
+ options = parse_diff_driver_options(args, stderr)
496
+ return EXIT_USER_ERROR unless options
497
+
498
+ print_structured_diff(
499
+ stdout,
500
+ options[:path_name] || options[:new_path],
501
+ File.read(options[:old_path]),
502
+ File.read(options[:new_path])
503
+ )
504
+ EXIT_SUCCESS
505
+ rescue Errno::ENOENT, Errno::EACCES => e
506
+ stderr.puts("read diff input: #{e.message}")
507
+ EXIT_USER_ERROR
508
+ end
509
+
510
+ def parse_diff_driver_options(args, stderr)
511
+ options = {}
512
+ positionals = []
513
+ index = 0
514
+ while index < args.length
515
+ value = args[index]
516
+ if value == '--path-name'
517
+ index += 1
518
+ options[:path_name] = args[index]
519
+ elsif value.start_with?('--')
520
+ stderr.puts("unknown diff-driver option #{value.inspect}")
521
+ return nil
522
+ else
523
+ positionals << value
524
+ end
525
+ index += 1
526
+ end
527
+
528
+ case positionals.length
529
+ when 2
530
+ options.merge(old_path: positionals[0], new_path: positionals[1])
531
+ when 7, 9
532
+ options.merge(path_name: options[:path_name] || positionals[0], old_path: positionals[1],
533
+ new_path: positionals[4])
534
+ else
535
+ stderr.puts('diff-driver requires either 2, 7, or 9 positional arguments')
536
+ nil
537
+ end
538
+ end
539
+
540
+ def print_structured_diff(stdout, path_name, old_source, new_source)
541
+ stdout.puts("structured-diff #{path_name}")
542
+ if old_source == new_source
543
+ stdout.puts('status unchanged')
544
+ else
545
+ stdout.puts('status changed')
546
+ stdout.puts("old-lines #{line_count(old_source)}")
547
+ stdout.puts("new-lines #{line_count(new_source)}")
548
+ print_structured_diff_review_hunk(stdout, path_name, old_source, new_source)
549
+ end
550
+ end
551
+
552
+ def print_structured_diff_review_hunk(stdout, path_name, old_source, new_source)
553
+ old_lines = diff_source_lines(old_source)
554
+ new_lines = diff_source_lines(new_source)
555
+ stdout.puts('review-diff unified')
556
+ stdout.puts("--- a/#{path_name}")
557
+ stdout.puts("+++ b/#{path_name}")
558
+
559
+ file_length_difference = 0
560
+ Diff::LCS.diff(old_lines, new_lines).each do |piece|
561
+ hunk = Diff::LCS::Hunk.new(old_lines, new_lines, piece, REVIEW_DIFF_CONTEXT_LINES, file_length_difference)
562
+ file_length_difference = hunk.file_length_difference
563
+ write_diff_text(stdout, hunk.diff(:unified))
564
+ end
565
+ end
566
+
567
+ def diff_source_lines(source)
568
+ source.to_s.lines.to_a
569
+ end
570
+
571
+ def write_diff_text(stdout, text)
572
+ return if text.to_s.empty?
573
+
574
+ stdout.write(text)
575
+ stdout.write("\n") unless text.end_with?("\n")
576
+ end
577
+
578
+ def run_conflicts(args, stdout, stderr)
579
+ subcommand, *rest = args
580
+ return run_conflicts_diff(rest, stdout, stderr) if subcommand == 'diff'
581
+
582
+ stderr.puts('conflicts requires the diff subcommand')
583
+ EXIT_USER_ERROR
584
+ end
585
+
586
+ def run_conflicts_diff(args, stdout, stderr)
587
+ options = parse_conflicts_diff_options(args, stderr)
588
+ return EXIT_USER_ERROR unless options
589
+
590
+ effective_path = options[:path_name] || options[:file_path]
591
+ settings = load_path_settings(effective_path)
592
+ regions = find_conflict_regions(File.read(options[:file_path]), settings[:conflict_marker_size])
593
+ print_conflict_diff(stdout, effective_path, regions)
594
+ options[:exit_code] && !regions.empty? ? EXIT_UNRESOLVED_CONFLICT : EXIT_SUCCESS
595
+ rescue Errno::ENOENT, Errno::EACCES => e
596
+ stderr.puts("read conflicted file: #{e.message}")
597
+ EXIT_USER_ERROR
598
+ end
599
+
600
+ def parse_conflicts_diff_options(args, stderr)
601
+ options = { exit_code: false }
602
+ positionals = []
603
+ index = 0
604
+ while index < args.length
605
+ value = args[index]
606
+ case value
607
+ when '--path-name'
608
+ index += 1
609
+ options[:path_name] = args[index]
610
+ when '--exit-code'
611
+ options[:exit_code] = true
612
+ else
613
+ if value.start_with?('--')
614
+ stderr.puts("unknown conflicts diff option #{value.inspect}")
615
+ return nil
616
+ end
617
+ positionals << value
618
+ end
619
+ index += 1
620
+ end
621
+ if positionals.length != 1
622
+ stderr.puts('conflicts diff requires exactly one file path')
623
+ return nil
624
+ end
625
+ options.merge(file_path: positionals[0])
626
+ end
627
+
628
+ def run_languages(args, stdout, stderr)
629
+ unless args == ['--gitattributes']
630
+ stderr.puts('languages currently requires --gitattributes')
631
+ return EXIT_USER_ERROR
632
+ end
633
+
634
+ [
635
+ '*.go merge=smorg-rb diff=smorg-rb smorg.language=go',
636
+ '*.json merge=smorg-rb diff=smorg-rb smorg.language=json',
637
+ '*.jsonc merge=smorg-rb diff=smorg-rb smorg.language=jsonc',
638
+ '*.json5 merge=smorg-rb diff=smorg-rb smorg.language=json5',
639
+ '*.md merge=smorg-rb diff=smorg-rb smorg.language=markdown',
640
+ '*.markdown merge=smorg-rb diff=smorg-rb smorg.language=markdown'
641
+ ].each { |line| stdout.puts(line) }
642
+ EXIT_SUCCESS
643
+ end
644
+
645
+ def merge_by_path(path_name, language, conflict_marker_size, fallback_policy, ancestor_source, current_source,
646
+ other_source)
647
+ case normalize_language(language, path_name)
648
+ when 'go'
649
+ Go::Merge.merge_go(other_source, current_source, 'go')
650
+ when 'json'
651
+ merge3_result(
652
+ Ast::Merge::Git.merge3(
653
+ base_source: ancestor_source,
654
+ ours_source: current_source,
655
+ theirs_source: other_source,
656
+ path_name: path_name,
657
+ language: 'json',
658
+ dialect: 'json',
659
+ profile_id: 'json.keyed-object',
660
+ fallback_policy: fallback_policy,
661
+ conflict_marker_size: conflict_marker_size,
662
+ render_policy: 'canonical'
663
+ )
664
+ )
665
+ when 'jsonc'
666
+ Json::Merge.merge_json(other_source, current_source, 'jsonc')
667
+ when 'json5'
668
+ Json::Merge.merge_json(other_source, current_source, 'json5')
669
+ when 'markdown'
670
+ merge_markdown(ancestor_source, current_source, other_source)
671
+ when 'text'
672
+ Plain::Merge.merge_text(other_source, current_source)
673
+ else
674
+ unsupported_language_result(normalize_language(language, path_name), path_name)
675
+ end
676
+ end
677
+
678
+ def merge_markdown(ancestor_source, current_source, other_source)
679
+ return { ok: true, diagnostics: [], output: current_source, policies: [] } if other_source == ancestor_source
680
+ return { ok: true, diagnostics: [], output: other_source, policies: [] } if current_source == ancestor_source
681
+ if markdown_theirs_changes_already_present?(ancestor_source, current_source, other_source)
682
+ return { ok: true, diagnostics: [], output: current_source, policies: [] }
683
+ end
684
+
685
+ result = Markly::Merge::SmartMerger.new(
686
+ other_source,
687
+ current_source,
688
+ add_template_only_nodes: true,
689
+ inner_merge_code_blocks: true,
690
+ inner_merge_lists: true,
691
+ normalize_whitespace: :basic
692
+ ).merge_result
693
+ {
694
+ ok: result.success?,
695
+ diagnostics: result.conflicts.map do |conflict|
696
+ {
697
+ severity: 'error',
698
+ category: 'merge_conflict',
699
+ message: conflict[:message] || conflict[:reason] || 'unresolved Markdown conflict'
700
+ }
701
+ end,
702
+ output: result.content,
703
+ policies: []
704
+ }
705
+ rescue Markdown::Merge::ParseError => e
706
+ {
707
+ ok: false,
708
+ diagnostics: [{ severity: 'error', category: 'parse_error', message: e.message }],
709
+ policies: []
710
+ }
711
+ rescue StandardError => e
712
+ {
713
+ ok: false,
714
+ diagnostics: [{ severity: 'error', category: 'merge_error', message: e.message }],
715
+ policies: []
716
+ }
717
+ end
718
+
719
+ def markdown_theirs_changes_already_present?(ancestor_source, current_source, other_source)
720
+ ancestor_sections = markdown_sections(ancestor_source)
721
+ current_sections = markdown_sections(current_source)
722
+ other_sections = markdown_sections(other_source)
723
+ return false unless ancestor_sections && current_sections && other_sections
724
+
725
+ ancestor_by_path = sections_by_path(ancestor_sections)
726
+ ancestor_by_heading = sections_by_heading(ancestor_sections)
727
+ current_by_path = sections_by_path(current_sections)
728
+ current_by_heading = sections_by_heading(current_sections)
729
+
730
+ changed_sections = other_sections.filter do |section|
731
+ ancestor_text = ancestor_by_path[section[:path]] || ancestor_by_heading[markdown_heading_key(section[:text])]
732
+ ancestor_text != section[:text]
733
+ end
734
+ return false if changed_sections.empty?
735
+
736
+ changed_sections.all? do |section|
737
+ other_text = section[:text]
738
+ heading_key = markdown_heading_key(other_text)
739
+ ancestor_text = ancestor_by_heading[heading_key] || ancestor_by_path.fetch(section[:path], '')
740
+ current_text = current_by_heading[heading_key] || current_by_path[section[:path]]
741
+ next false unless current_text
742
+
743
+ introduced_tokens = markdown_significant_tokens(other_text) - markdown_significant_tokens(ancestor_text)
744
+ !introduced_tokens.empty? && introduced_tokens.subset?(markdown_significant_tokens(current_text))
745
+ end
746
+ end
747
+
748
+ def markdown_sections(source)
749
+ parsed = Markly::Merge.parse_markdown(source, 'markdown')
750
+ return nil unless parsed[:ok]
751
+
752
+ Markdown::Merge.collect_markdown_sections(
753
+ parsed.dig(:analysis, :normalized_source),
754
+ parsed.dig(:analysis, :owners)
755
+ )
756
+ end
757
+
758
+ def sections_by_path(sections)
759
+ sections.to_h { |section| [section[:path], section[:text]] }
760
+ end
761
+
762
+ def sections_by_heading(sections)
763
+ sections.each_with_object({}) do |section, map|
764
+ key = markdown_heading_key(section[:text])
765
+ map[key] ||= section[:text] unless key.empty?
766
+ end
767
+ end
768
+
769
+ def markdown_heading_key(section_text)
770
+ heading = section_text.each_line.find { |line| line.start_with?('#') }.to_s.strip
771
+ heading = heading.delete_prefix('#').strip while heading.start_with?('#')
772
+ heading.downcase
773
+ end
774
+
775
+ def markdown_significant_tokens(source)
776
+ source.split.each_with_object(Set.new) do |raw_token, tokens|
777
+ token = normalize_markdown_token(raw_token)
778
+ tokens << token if token.length >= 8
779
+ end
780
+ end
781
+
782
+ def normalize_markdown_token(raw_token)
783
+ token = raw_token.downcase
784
+ loop do
785
+ previous = token
786
+ token = token.delete_prefix('(').delete_prefix('[').delete_prefix('{').delete_prefix('<')
787
+ token = token.delete_suffix(')').delete_suffix(']').delete_suffix('}').delete_suffix('>')
788
+ token = token.delete_suffix('.').delete_suffix(',').delete_suffix(';').delete_suffix(':')
789
+ return token if token == previous
790
+ end
791
+ end
792
+
793
+ def unsupported_language_result(language, path_name)
794
+ {
795
+ ok: false,
796
+ diagnostics: [
797
+ {
798
+ severity: 'error',
799
+ category: 'unsupported_language',
800
+ message: "no structured merge driver is configured for #{language.inspect} at #{path_name}"
801
+ }
802
+ ],
803
+ policies: []
804
+ }
805
+ end
806
+
807
+ def merge3_result(result)
808
+ merge3_report_fields = {
809
+ change_classifications: result.fetch(:change_classifications, []),
810
+ reparse_after_render: result[:reparse_after_render],
811
+ formatting_preservation: result[:formatting_preservation],
812
+ secondary_formatting_metrics: result[:secondary_formatting_metrics],
813
+ default_driver_evaluation: result[:default_driver_evaluation]
814
+ }
815
+ if result[:ok] && result[:merged_source]
816
+ {
817
+ ok: true,
818
+ diagnostics: result.fetch(:diagnostics),
819
+ output: result.fetch(:merged_source),
820
+ owned_regions: result.fetch(:owned_regions, []),
821
+ render_report: result.fetch(:render_report),
822
+ profile: result.fetch(:profile),
823
+ policies: [],
824
+ **merge3_report_fields
825
+ }
826
+ elsif !result[:ok] && result[:conflicted_source]
827
+ {
828
+ ok: false,
829
+ diagnostics: result.fetch(:diagnostics),
830
+ output: result.fetch(:conflicted_source),
831
+ owned_regions: result.fetch(:owned_regions, []),
832
+ render_report: result.fetch(:render_report),
833
+ profile: result.fetch(:profile),
834
+ policies: [],
835
+ **merge3_report_fields
836
+ }
837
+ else
838
+ {
839
+ ok: false,
840
+ diagnostics: result.fetch(:diagnostics),
841
+ owned_regions: result.fetch(:owned_regions, []),
842
+ render_report: result.fetch(:render_report),
843
+ profile: result.fetch(:profile),
844
+ policies: [],
845
+ **merge3_report_fields
846
+ }
847
+ end
848
+ end
849
+
850
+ def normalize_language(language, path_name)
851
+ case language.to_s.strip.downcase
852
+ when 'go', 'golang'
853
+ 'go'
854
+ when 'json'
855
+ 'json'
856
+ when 'jsonc', 'json with comments'
857
+ 'jsonc'
858
+ when 'json5'
859
+ 'json5'
860
+ when 'markdown', 'md', 'gfm', 'text/markdown'
861
+ 'markdown'
862
+ when 'plain', 'text', 'plaintext', 'text/plain'
863
+ 'text'
864
+ else
865
+ Ast::Merge.classify_template_target_path(path_name)[:family]
866
+ end
867
+ end
868
+
869
+ def load_path_settings(path_name)
870
+ settings = { conflict_marker_size: 7 }
871
+ attribute_files_for_path(path_name).each do |attributes_path|
872
+ next unless File.file?(attributes_path)
873
+
874
+ apply_attributes(settings, path_name, File.read(attributes_path))
875
+ end
876
+ settings
877
+ end
878
+
879
+ def attribute_files_for_path(path_name)
880
+ clean_path = if File.expand_path(path_name,
881
+ Dir.pwd).start_with?(Dir.pwd)
882
+ Pathname.new(path_name).cleanpath.to_s
883
+ else
884
+ path_name
885
+ end
886
+ dir = File.dirname(clean_path)
887
+ return ['.gitattributes'] if dir == '.' || clean_path.start_with?('..') || Pathname.new(clean_path).absolute?
888
+
889
+ files = ['.gitattributes']
890
+ parts = dir.split(File::SEPARATOR).reject(&:empty?)
891
+ parts.each_index do |index|
892
+ files << File.join(*parts[0..index], '.gitattributes')
893
+ end
894
+ files
895
+ end
896
+
897
+ def apply_attributes(settings, path_name, source)
898
+ source.each_line do |raw_line|
899
+ line = raw_line.strip
900
+ next if line.empty? || line.start_with?('#')
901
+
902
+ pattern, *fields = line.split(/\s+/)
903
+ next if fields.empty? || !attribute_pattern_matches?(pattern, path_name)
904
+
905
+ fields.each do |field|
906
+ key, value = field.split('=', 2)
907
+ next unless value
908
+
909
+ case key
910
+ when 'smorg.language', 'linguist-language'
911
+ settings[:language] = value
912
+ when 'smorg.profile'
913
+ settings[:profile_id] = value
914
+ when 'smorg.requireProfileStatus'
915
+ settings[:require_profile_status] = value
916
+ when 'conflict-marker-size'
917
+ marker_size = value.to_i
918
+ settings[:conflict_marker_size] = marker_size if marker_size.positive?
919
+ end
920
+ end
921
+ end
922
+ end
923
+
924
+ def attribute_pattern_matches?(pattern, path_name)
925
+ return true if pattern == path_name
926
+
927
+ if !pattern.include?('/')
928
+ File.fnmatch?(pattern, File.basename(path_name))
929
+ else
930
+ File.fnmatch?(pattern, path_name)
931
+ end
932
+ end
933
+
934
+ def find_conflict_regions(source, marker_size)
935
+ marker_size = [marker_size.to_i, 1].max
936
+ start_prefix = '<' * marker_size
937
+ separator_prefix = '=' * marker_size
938
+ end_prefix = '>' * marker_size
939
+ regions = []
940
+ current = nil
941
+ source.split("\n").each_with_index do |line, index|
942
+ line_number = index + 1
943
+ if line.start_with?(start_prefix)
944
+ current = { start_line: line_number, separator_line: 0 }
945
+ elsif current && current[:separator_line].zero? && line.start_with?(separator_prefix)
946
+ current[:separator_line] = line_number
947
+ elsif current && line.start_with?(end_prefix)
948
+ regions << current.merge(end_line: line_number)
949
+ current = nil
950
+ end
951
+ end
952
+ regions
953
+ end
954
+
955
+ def print_conflict_diff(stdout, path_name, regions)
956
+ stdout.puts("conflicts #{path_name}")
957
+ stdout.puts("count #{regions.length}")
958
+ regions.each_with_index do |region, index|
959
+ stdout.puts("conflict #{index + 1} lines #{region[:start_line]}-#{region[:end_line]} separator #{region[:separator_line]}")
960
+ end
961
+ end
962
+
963
+ def line_count(source)
964
+ return 0 if source.empty?
965
+
966
+ source.end_with?("\n") ? source.count("\n") : source.count("\n") + 1
967
+ end
968
+
969
+ def print_diagnostics(stderr, result)
970
+ result.fetch(:diagnostics, []).each do |diagnostic|
971
+ stderr.puts("#{diagnostic[:category]}: #{diagnostic[:message]}")
972
+ end
973
+ end
974
+ end
975
+ end