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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data/LICENSE.md +13 -0
- data/README.md +283 -0
- data/exe/smorg-rb +15 -0
- data/lib/smorg/rb/version.rb +13 -0
- data/lib/smorg/rb.rb +975 -0
- data/lib/smorg-rb.rb +9 -0
- data/sig/smorg/rb.rbs +8 -0
- data.tar.gz.sig +2 -0
- metadata +432 -0
- metadata.gz.sig +0 -0
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
|