ast-merge-git 7.1.1 → 7.1.4

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,580 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'open3'
7
+ require 'shellwords'
8
+
9
+ module Ast
10
+ module Merge
11
+ module Git
12
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity -- evidence validation and raw-result assembly remain explicit and auditable
13
+ # Validates and executes pinned Git-history corpus cases.
14
+ class Corpus
15
+ Error = Class.new(StandardError)
16
+ CLASSIFICATIONS = %w[
17
+ exact_automatic_resolution
18
+ structurally_equivalent_resolution
19
+ conflict_expected
20
+ ambiguous_manual_review
21
+ excluded
22
+ ].freeze
23
+ BACKLOG_STATUSES = %w[blocked admitted resolved].freeze
24
+ SHA_PATTERN = /\A[0-9a-f]{40}\z/
25
+ CASE_ID_PATTERN = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
26
+ REQUIRED_CASE_KEYS = %w[
27
+ case_id merge_commit base_commit parent_commits path blob_oids selector
28
+ capability_tags stratum oracle
29
+ ].freeze
30
+
31
+ attr_reader :manifest
32
+
33
+ def self.load(path)
34
+ new(JSON.parse(File.binread(path)))
35
+ rescue JSON::ParserError => e
36
+ raise Error, "invalid manifest JSON: #{e.message}"
37
+ rescue SystemCallError => e
38
+ raise Error, "cannot read manifest: #{e.message}"
39
+ end
40
+
41
+ def initialize(manifest)
42
+ @manifest = manifest
43
+ end
44
+
45
+ def validate!
46
+ require_keys(manifest, %w[schema_version corpus_id source claim_policy admission_backlog cases], 'manifest')
47
+ raise Error, 'schema_version must be 1' unless manifest['schema_version'] == 1
48
+
49
+ validate_source!
50
+ validate_backlog!
51
+ raise Error, 'cases must be a non-empty array' unless manifest['cases'].is_a?(Array) && manifest['cases'].any?
52
+
53
+ ids = manifest['cases'].map { |item| validate_case!(item) }
54
+ raise Error, 'case_id values must be unique' unless ids.uniq.length == ids.length
55
+
56
+ validate_admitted_backlog!(ids)
57
+
58
+ true
59
+ end
60
+
61
+ def cases(case_id = nil)
62
+ validate!
63
+ return manifest['cases'] unless case_id
64
+
65
+ [manifest['cases'].find { |item| item['case_id'] == case_id } ||
66
+ raise(Error, "unknown case: #{case_id}")]
67
+ end
68
+
69
+ private
70
+
71
+ def validate_source!
72
+ source = manifest['source']
73
+ require_keys(source, %w[repository remote_url revision spdx_license license_evidence_url oracle_rationale],
74
+ 'source')
75
+ validate_sha!(source['revision'], 'source.revision')
76
+ raise Error, 'source.remote_url must use https' unless source['remote_url'].start_with?('https://')
77
+ raise Error, 'source.license_evidence_url must use https' unless source['license_evidence_url'].start_with?('https://')
78
+ end
79
+
80
+ def validate_backlog!
81
+ backlog = manifest['admission_backlog']
82
+ raise Error, 'admission_backlog must be an array' unless backlog.is_a?(Array)
83
+
84
+ backlog.each do |item|
85
+ require_keys(item, %w[candidate_id status reason score_eligible], 'admission_backlog item')
86
+ unless BACKLOG_STATUSES.include?(item['status'])
87
+ raise Error, "#{item['candidate_id']}: unsupported backlog status"
88
+ end
89
+ if item['status'] == 'blocked' && item['score_eligible']
90
+ raise Error, "#{item['candidate_id']}: blocked candidate cannot be score eligible"
91
+ end
92
+ next unless item['status'] == 'admitted'
93
+
94
+ require_keys(item, %w[case_id], 'admitted backlog item')
95
+ end
96
+ end
97
+
98
+ def validate_admitted_backlog!(case_ids)
99
+ manifest['admission_backlog'].select { |item| item['status'] == 'admitted' }.each do |item|
100
+ raise Error, "#{item['candidate_id']}: admitted case is missing" unless case_ids.include?(item['case_id'])
101
+
102
+ admitted = manifest['cases'].find { |candidate| candidate['case_id'] == item['case_id'] }
103
+ next if item['score_eligible'] == admitted.dig('oracle', 'score_eligible')
104
+
105
+ raise Error, "#{item['candidate_id']}: backlog and case score eligibility differ"
106
+ end
107
+ end
108
+
109
+ def validate_case!(item)
110
+ require_keys(item, REQUIRED_CASE_KEYS, 'case')
111
+ raise Error, 'case_id must be lowercase kebab-case' unless CASE_ID_PATTERN.match?(item['case_id'].to_s)
112
+
113
+ validate_sha!(item['merge_commit'], "#{item['case_id']}.merge_commit")
114
+ validate_sha!(item['base_commit'], "#{item['case_id']}.base_commit")
115
+ parents = item['parent_commits']
116
+ unless parents.is_a?(Array) && parents.length == 2
117
+ raise Error,
118
+ "#{item['case_id']}: exactly two parents required"
119
+ end
120
+
121
+ parents.each { |sha| validate_sha!(sha, "#{item['case_id']}.parent_commits") }
122
+ validate_blobs!(item)
123
+ validate_selector!(item)
124
+ validate_oracle!(item)
125
+ item['case_id']
126
+ end
127
+
128
+ def validate_blobs!(item)
129
+ require_keys(item['blob_oids'], %w[base ours theirs human], "#{item['case_id']}.blob_oids")
130
+ item['blob_oids'].each_value { |oid| validate_sha!(oid, "#{item['case_id']}.blob_oids") }
131
+ path = item['path']
132
+ return unless path.empty? || Pathname(path).absolute? || path.split('/').include?('..')
133
+
134
+ raise Error,
135
+ "#{item['case_id']}: path must be relative"
136
+ end
137
+
138
+ def validate_selector!(item)
139
+ selector = item['selector']
140
+ require_keys(selector, %w[provider_id family dialect backend profile require], "#{item['case_id']}.selector")
141
+ require_keys(item['stratum'], %w[provider dialect conflict_type], "#{item['case_id']}.stratum")
142
+ return if item['capability_tags'].is_a?(Array) && item['capability_tags'].any?
143
+
144
+ raise Error,
145
+ "#{item['case_id']}: capability_tags must not be empty"
146
+ end
147
+
148
+ def validate_oracle!(item)
149
+ oracle = item['oracle']
150
+ require_keys(
151
+ oracle,
152
+ %w[classification human_resolution_rationale ambiguity_status reclassification_status
153
+ false_auto_merge_review score_eligible],
154
+ "#{item['case_id']}.oracle"
155
+ )
156
+ unless CLASSIFICATIONS.include?(oracle['classification'])
157
+ raise Error, "#{item['case_id']}: unsupported oracle classification"
158
+ end
159
+
160
+ eligible = oracle['score_eligible']
161
+ reviewed = oracle['false_auto_merge_review'] == 'complete'
162
+ unscorable = %w[ambiguous_manual_review excluded].include?(oracle['classification'])
163
+ raise Error, "#{item['case_id']}: case cannot be score eligible" if eligible && (!reviewed || unscorable)
164
+ return unless oracle['classification'] == 'structurally_equivalent_resolution'
165
+
166
+ require_keys(item, %w[conflict_evidence review], item['case_id'])
167
+ require_keys(
168
+ item['conflict_evidence'],
169
+ %w[method result review_status],
170
+ "#{item['case_id']}.conflict_evidence"
171
+ )
172
+ require_keys(item['review'], %w[provenance status], "#{item['case_id']}.review")
173
+ require_keys(oracle, %w[provider_coverage], "#{item['case_id']}.oracle")
174
+ coverage = oracle['provider_coverage']
175
+ require_keys(coverage, %w[status reason], "#{item['case_id']}.oracle.provider_coverage")
176
+ complete = item.dig('conflict_evidence', 'result') == 'content_conflict' &&
177
+ item.dig('conflict_evidence', 'review_status') == 'complete' &&
178
+ item.dig('review', 'status') == 'complete'
179
+ raise Error, "#{item['case_id']}: reviewed conflict evidence is incomplete" unless complete
180
+ return unless eligible && coverage['status'] != 'supported'
181
+
182
+ raise Error, "#{item['case_id']}: unsupported provider coverage cannot be score eligible"
183
+ end
184
+
185
+ def require_keys(hash, keys, context)
186
+ raise Error, "#{context} must be an object" unless hash.is_a?(Hash)
187
+
188
+ missing = keys.reject { |key| hash.key?(key) }
189
+ raise Error, "#{context} missing: #{missing.join(', ')}" if missing.any?
190
+ end
191
+
192
+ def validate_sha!(sha, context)
193
+ raise Error, "#{context} must be a full lowercase SHA" unless SHA_PATTERN.match?(sha.to_s)
194
+ end
195
+ end
196
+
197
+ # Executes validated corpus cases without changing the source checkout.
198
+ class CorpusRunner
199
+ DEFAULT_TIMEOUT = 30
200
+ OUTCOMES = %w[
201
+ correct_clean false_conflict true_conflict false_auto_merge error unsupported excluded_ambiguous
202
+ ].freeze
203
+
204
+ def initialize(corpus:, repository:, driver_path:, tmp_root:, timeout: DEFAULT_TIMEOUT)
205
+ @corpus = corpus
206
+ @repository = Pathname(repository).expand_path
207
+ @driver_path = Pathname(driver_path).expand_path
208
+ @tmp_root = Pathname(tmp_root).expand_path
209
+ @timeout = Integer(timeout)
210
+ end
211
+
212
+ def run(case_id: nil)
213
+ verify_environment!
214
+ @corpus.cases(case_id).map { |item| run_case(item) }
215
+ end
216
+
217
+ private
218
+
219
+ def verify_environment!
220
+ raise Corpus::Error, "missing repository: #{@repository}" unless @repository.join('.git').exist?
221
+ unless @driver_path.file? && @driver_path.executable?
222
+ raise Corpus::Error,
223
+ "missing installed driver: #{@driver_path}"
224
+ end
225
+ raise Corpus::Error, 'tmp_root must be inside the ast-merge-git repository' unless inside_gem_root?(@tmp_root)
226
+
227
+ status = git_source('status', '--porcelain')
228
+ raise Corpus::Error, 'source repository is dirty; corpus reads require a clean checkout' unless status.empty?
229
+
230
+ git_source('cat-file', '-e', "#{@corpus.manifest.dig('source', 'revision')}^{commit}")
231
+ end
232
+
233
+ def inside_gem_root?(path)
234
+ root = Pathname(__dir__).join('..', '..', '..', '..').realpath
235
+ resolved = path.exist? ? path.realpath : path.dirname.realpath.join(path.basename)
236
+ resolved.to_s.start_with?("#{root}/")
237
+ end
238
+
239
+ def run_case(item)
240
+ roles = prove_and_read_blobs(item)
241
+ workspace = @tmp_root.join("#{item['case_id']}-#{Process.pid}")
242
+ FileUtils.rm_rf(workspace)
243
+ FileUtils.mkdir_p(workspace)
244
+ git_workspace(workspace, 'init', '--quiet')
245
+ configure_driver(workspace, item)
246
+
247
+ baseline = execute_baseline(workspace, roles)
248
+ candidate = execute_candidate(workspace, roles, item)
249
+ rerun = execute_candidate(workspace, roles, item)
250
+ build_result(item, roles, baseline, candidate, rerun)
251
+ ensure
252
+ FileUtils.rm_rf(workspace) if workspace
253
+ end
254
+
255
+ def prove_and_read_blobs(item)
256
+ git_source(
257
+ 'merge-base',
258
+ '--is-ancestor',
259
+ item['merge_commit'],
260
+ @corpus.manifest.dig('source', 'revision')
261
+ )
262
+ parents = git_source('rev-list', '--parents', '-n', '1', item['merge_commit']).split
263
+ raise Corpus::Error, "#{item['case_id']}: merge must have exactly two parents" unless parents.length == 3
264
+ raise Corpus::Error, "#{item['case_id']}: parent SHAs differ" unless parents.drop(1) == item['parent_commits']
265
+
266
+ base = git_source('merge-base', *item['parent_commits']).strip
267
+ raise Corpus::Error, "#{item['case_id']}: merge-base differs" unless base == item['base_commit']
268
+
269
+ revisions = {
270
+ 'base' => item['base_commit'],
271
+ 'ours' => item['parent_commits'][0],
272
+ 'theirs' => item['parent_commits'][1],
273
+ 'human' => item['merge_commit']
274
+ }
275
+ revisions.to_h do |role, revision|
276
+ spec = "#{revision}:#{item['path']}"
277
+ oid = git_source('rev-parse', spec).strip
278
+ raise Corpus::Error, "#{item['case_id']}: #{role} blob differs" unless oid == item.dig('blob_oids', role)
279
+
280
+ [role, git_source_binary('cat-file', 'blob', spec)]
281
+ end
282
+ end
283
+
284
+ def configure_driver(workspace, item)
285
+ selector = item['selector']
286
+ env = selector_env(selector).map { |key, value| "#{key}=#{Shellwords.escape(value)}" }.join(' ')
287
+ command = "#{env} #{Shellwords.escape(@driver_path.to_s)} %O %A %B %P %L"
288
+ git_workspace(workspace, 'config', 'merge.structuredmerge-corpus.name', 'StructuredMerge corpus driver')
289
+ git_workspace(workspace, 'config', 'merge.structuredmerge-corpus.driver', command)
290
+ end
291
+
292
+ def execute_baseline(workspace, roles)
293
+ write_roles(workspace, roles)
294
+ timed_capture({}, 'git', 'merge-file', '-p', 'ours', 'base', 'theirs', chdir: workspace).then do |capture|
295
+ capture.merge(output: capture[:stdout])
296
+ end
297
+ end
298
+
299
+ def execute_candidate(workspace, roles, item)
300
+ write_roles(workspace, roles)
301
+ capture = timed_capture(
302
+ candidate_env(item['selector']),
303
+ @driver_path.to_s,
304
+ 'base',
305
+ 'ours',
306
+ 'theirs',
307
+ item['path'],
308
+ '7',
309
+ chdir: workspace
310
+ )
311
+ capture.merge(output: workspace.join('ours').binread)
312
+ end
313
+
314
+ def write_roles(workspace, roles)
315
+ %w[base ours theirs].each { |role| workspace.join(role).binwrite(roles.fetch(role)) }
316
+ end
317
+
318
+ def selector_env(selector)
319
+ {
320
+ 'AST_MERGE_PROVIDER' => selector['provider_id'],
321
+ 'AST_MERGE_FAMILY' => selector['family'],
322
+ 'AST_MERGE_DIALECT' => selector['dialect'],
323
+ 'AST_MERGE_BACKEND' => selector['backend'],
324
+ 'AST_MERGE_PROFILE' => selector['profile'],
325
+ 'AST_MERGE_REQUIRE' => selector['require']
326
+ }
327
+ end
328
+
329
+ def candidate_env(selector)
330
+ forbidden = ENV.keys.grep(/ORACLE|HUMAN|EXPECTED/i).to_h { |key| [key, nil] }
331
+ forbidden.merge(selector_env(selector))
332
+ end
333
+
334
+ def build_result(item, roles, baseline, candidate, rerun)
335
+ {
336
+ schema_version: 1,
337
+ case_id: item['case_id'],
338
+ source: item.slice('merge_commit', 'base_commit', 'parent_commits', 'path', 'blob_oids'),
339
+ oracle: item['oracle'],
340
+ baseline: outcome(baseline, roles['human'], item, adapter: :git_merge_file),
341
+ candidate: outcome(candidate, roles['human'], item, adapter: :structured_merge),
342
+ human_result: { sha256: digest(roles['human']), bytes: roles['human'].bytesize },
343
+ deterministic_rerun: deterministic?(candidate, rerun),
344
+ claim_eligibility: claim_eligibility(item),
345
+ runtime_policy: { comparable: false, deterministic: false }
346
+ }
347
+ end
348
+
349
+ def outcome(capture, human, item, adapter:)
350
+ output = capture.fetch(:output)
351
+ markers = conflict_markers(output)
352
+ exact = output == human
353
+ provider = provider_equivalence(output, human, item, exact)
354
+ exit_class = exit_classification(capture[:status], adapter)
355
+ classified = classify(item, exit_class, exact || provider[:equivalent], adapter: adapter)
356
+ {
357
+ exit_status: capture[:status],
358
+ exit_classification: exit_class,
359
+ stdout: capture[:stdout],
360
+ stderr: capture[:stderr],
361
+ output: output,
362
+ output_sha256: digest(output),
363
+ exact_human_result: exact,
364
+ structurally_equivalent_human_result: provider[:equivalent],
365
+ provider_check: provider,
366
+ parse_valid: provider[:available] ? provider[:valid] : nil,
367
+ outcome: classified,
368
+ conflict_markers: markers,
369
+ duration_ns: capture[:duration_ns],
370
+ runtime_comparable: false
371
+ }
372
+ end
373
+
374
+ def timed_capture(env, *command, chdir:)
375
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
376
+ stdin, stdout_io, stderr_io, process = Open3.popen3(env, *command, chdir: chdir.to_s)
377
+ [stdin, stdout_io, stderr_io].each(&:binmode)
378
+ stdin.close
379
+ stdout_reader = Thread.new { stdout_io.read }
380
+ stderr_reader = Thread.new { stderr_io.read }
381
+ timed_out = process.join(@timeout).nil?
382
+ terminate_process(process) if timed_out
383
+ stdout = stdout_reader.value
384
+ stderr = stderr_reader.value
385
+ stderr = [stderr, "timeout after #{@timeout}s"].reject(&:empty?).join("\n") if timed_out
386
+ status = timed_out ? 2 : process.value.exitstatus
387
+ { stdout: stdout, stderr: stderr, status: status,
388
+ duration_ns: Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - started }
389
+ rescue Errno::ENOENT => e
390
+ { stdout: '', stderr: e.message, status: 2, output: '', duration_ns: 0 }
391
+ ensure
392
+ [stdin, stdout_io, stderr_io].compact.each { |io| io.close unless io.closed? }
393
+ end
394
+
395
+ def terminate_process(process)
396
+ Process.kill('TERM', process.pid)
397
+ return if process.join(1)
398
+
399
+ Process.kill('KILL', process.pid)
400
+ process.join
401
+ rescue Errno::ESRCH, Errno::ECHILD
402
+ process.join
403
+ end
404
+
405
+ def git_source(*args)
406
+ run_git(@repository, *args)
407
+ end
408
+
409
+ def git_source_binary(*args)
410
+ run_git(@repository, *args, binary: true)
411
+ end
412
+
413
+ def git_workspace(workspace, *args)
414
+ run_git(workspace, *args)
415
+ end
416
+
417
+ def run_git(directory, *args, binary: false)
418
+ options = args.last.is_a?(Hash) ? args.pop : {}
419
+ stdout, stderr, status = Open3.capture3('git', '-C', directory.to_s, *args, binmode: true)
420
+ raise Corpus::Error, "git #{args.first} failed: #{stderr.strip}" unless status.success?
421
+
422
+ binary || options[:binary] ? stdout : stdout.force_encoding(Encoding::UTF_8)
423
+ end
424
+
425
+ def digest(content)
426
+ Digest::SHA256.hexdigest(content)
427
+ end
428
+
429
+ def exit_classification(status, adapter)
430
+ return 'error' unless status
431
+
432
+ if adapter == :git_merge_file
433
+ return 'clean' if status.zero?
434
+
435
+ return status == 255 ? 'error' : 'conflict'
436
+ end
437
+
438
+ { 0 => 'clean', 1 => 'conflict', 2 => 'error' }.fetch(status, 'error')
439
+ end
440
+
441
+ def classify(item, exit_class, equivalent, adapter:)
442
+ classification = item.dig('oracle', 'classification')
443
+ return 'excluded_ambiguous' if %w[ambiguous_manual_review excluded].include?(classification)
444
+ if adapter == :structured_merge && item.dig('oracle', 'provider_coverage', 'status') == 'unsupported'
445
+ return 'unsupported'
446
+ end
447
+ return 'error' if exit_class == 'error'
448
+ if classification == 'conflict_expected'
449
+ return exit_class == 'conflict' ? 'true_conflict' : 'false_auto_merge'
450
+ end
451
+ return 'false_conflict' if exit_class == 'conflict'
452
+ return 'correct_clean' if equivalent
453
+
454
+ 'false_auto_merge'
455
+ end
456
+
457
+ def deterministic?(first, second)
458
+ %i[status stdout stderr].all? { |key| first[key] == second[key] } &&
459
+ first[:output] == second[:output]
460
+ end
461
+
462
+ def claim_eligibility(item)
463
+ oracle = item['oracle']
464
+ eligible = oracle['score_eligible'] &&
465
+ oracle['false_auto_merge_review'] == 'complete' &&
466
+ !%w[ambiguous_manual_review excluded].include?(oracle['classification'])
467
+ { score_eligible: eligible,
468
+ quality_claim_allowed: eligible && @corpus.manifest.dig('claim_policy', 'quality_claims_allowed') }
469
+ end
470
+
471
+ def provider_equivalence(output, human, item, exact)
472
+ selector = item.fetch('selector')
473
+ method = 'selected_provider.diff2(expected_human, candidate_output).changes.empty?'
474
+ if exact
475
+ return { equivalent: true, available: true, valid: true,
476
+ provider_id: selector['provider_id'], method: 'exact_bytes' }
477
+ end
478
+
479
+ require selector.fetch('require')
480
+ result = Ast::Merge.dispatch_provider(
481
+ :diff2,
482
+ {
483
+ provider_id: selector.fetch('provider_id'),
484
+ family: selector.fetch('family'),
485
+ dialect: selector.fetch('dialect'),
486
+ backend: selector.fetch('backend'),
487
+ profile_id: selector.fetch('profile'),
488
+ before_source: human,
489
+ after_source: output,
490
+ path_name: item.fetch('path')
491
+ }
492
+ )
493
+ valid = result[:ok] == true
494
+ { equivalent: valid && result.fetch(:changes).empty?, available: true, valid: valid,
495
+ provider_id: selector['provider_id'], method: method,
496
+ diagnostic_codes: Array(result[:diagnostics]).filter_map { |entry| entry[:code] || entry['code'] } }
497
+ rescue Ast::Merge::Error, KeyError, LoadError => e
498
+ { equivalent: false, available: false, valid: false,
499
+ provider_id: selector['provider_id'], method: method,
500
+ error: "#{e.class}: #{e.message}" }
501
+ end
502
+
503
+ # Conflict markers are Git's line protocol, not syntax nodes; byte scans
504
+ # intentionally avoid parser-specific ownership claims.
505
+ def conflict_markers(output)
506
+ ranges = []
507
+ start_byte = nil
508
+ offset = 0
509
+ output.each_line do |line|
510
+ start_byte = offset if line.start_with?('<<<<<<<')
511
+ if start_byte && line.start_with?('>>>>>>>')
512
+ ranges << { start_byte: start_byte,
513
+ end_byte: offset + line.bytesize }
514
+ end
515
+ start_byte = nil if start_byte && line.start_with?('>>>>>>>')
516
+ offset += line.bytesize
517
+ end
518
+ { present: ranges.any?, count: ranges.length, byte_ranges: ranges }
519
+ end
520
+ end
521
+
522
+ # Explicitly acquires a pinned remote without changing an existing tree.
523
+ class CorpusAcquirer
524
+ class << self
525
+ def acquire(corpus:, destination:, tmp_root:)
526
+ corpus.validate!
527
+ destination = Pathname(destination).expand_path
528
+ tmp_root = Pathname(tmp_root).expand_path
529
+ validate_destination!(destination, tmp_root)
530
+
531
+ FileUtils.mkdir_p(destination.dirname)
532
+ clone!(corpus.manifest.dig('source', 'remote_url'), destination)
533
+ revision = corpus.manifest.dig('source', 'revision')
534
+ git!('pinned revision missing after clone', '-C', destination.to_s, 'cat-file', '-e',
535
+ "#{revision}^{commit}")
536
+ git!('pinned revision checkout failed', '-C', destination.to_s, 'checkout', '--detach', '--quiet',
537
+ revision)
538
+ destination
539
+ end
540
+
541
+ private
542
+
543
+ def validate_destination!(destination, tmp_root)
544
+ gem_root = Pathname(__dir__).join('..', '..', '..', '..').realpath
545
+ resolved_tmp = tmp_root.exist? ? tmp_root.realpath : tmp_root.dirname.realpath.join(tmp_root.basename)
546
+ unless resolved_tmp.to_s.start_with?("#{gem_root}/")
547
+ raise Corpus::Error, 'clone tmp root must be inside the ast-merge-git repository'
548
+ end
549
+
550
+ FileUtils.mkdir_p(tmp_root)
551
+ resolved_tmp = tmp_root.realpath
552
+ resolved_destination = resolve_destination(destination)
553
+ unless resolved_destination.to_s.start_with?("#{resolved_tmp}/")
554
+ raise Corpus::Error, 'clone destination must be inside the configured repo-local tmp root'
555
+ end
556
+ raise Corpus::Error, "clone destination already exists: #{destination}" if destination.exist?
557
+ end
558
+
559
+ def resolve_destination(destination)
560
+ return destination.realpath if destination.exist?
561
+
562
+ ancestor = destination.cleanpath.dirname
563
+ ancestor = ancestor.dirname until ancestor.exist?
564
+ ancestor.realpath.join(destination.cleanpath.relative_path_from(ancestor)).cleanpath
565
+ end
566
+
567
+ def clone!(remote, destination)
568
+ git!('git clone failed', 'clone', '--no-checkout', '--filter=blob:none', remote, destination.to_s)
569
+ end
570
+
571
+ def git!(message, *arguments)
572
+ _stdout, stderr, status = Open3.capture3('git', *arguments, binmode: true)
573
+ raise Corpus::Error, "#{message}: #{stderr.strip}" unless status.success?
574
+ end
575
+ end
576
+ end
577
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
578
+ end
579
+ end
580
+ end