datadog-ci 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -2
  3. data/ext/datadog_ci_native/ci.c +10 -0
  4. data/ext/{datadog_cov → datadog_ci_native}/datadog_cov.c +119 -147
  5. data/ext/datadog_ci_native/datadog_cov.h +3 -0
  6. data/ext/datadog_ci_native/datadog_source_code.c +28 -0
  7. data/ext/datadog_ci_native/datadog_source_code.h +3 -0
  8. data/ext/{datadog_cov → datadog_ci_native}/extconf.rb +1 -1
  9. data/lib/datadog/ci/contrib/minitest/test.rb +17 -7
  10. data/lib/datadog/ci/contrib/rspec/example.rb +14 -7
  11. data/lib/datadog/ci/ext/environment/providers/appveyor.rb +1 -1
  12. data/lib/datadog/ci/ext/environment/providers/buildkite.rb +4 -0
  13. data/lib/datadog/ci/ext/environment/providers/gitlab.rb +0 -4
  14. data/lib/datadog/ci/ext/telemetry.rb +1 -2
  15. data/lib/datadog/ci/ext/test.rb +1 -0
  16. data/lib/datadog/ci/git/base_branch_sha_detection/base.rb +66 -0
  17. data/lib/datadog/ci/git/base_branch_sha_detection/branch_metric.rb +34 -0
  18. data/lib/datadog/ci/git/base_branch_sha_detection/guesser.rb +137 -0
  19. data/lib/datadog/ci/git/base_branch_sha_detection/merge_base_extractor.rb +29 -0
  20. data/lib/datadog/ci/git/base_branch_sha_detector.rb +63 -0
  21. data/lib/datadog/ci/git/changed_lines.rb +109 -0
  22. data/lib/datadog/ci/git/cli.rb +56 -0
  23. data/lib/datadog/ci/git/diff.rb +90 -0
  24. data/lib/datadog/ci/git/local_repository.rb +94 -321
  25. data/lib/datadog/ci/git/telemetry.rb +14 -0
  26. data/lib/datadog/ci/impacted_tests_detection/component.rb +15 -11
  27. data/lib/datadog/ci/test.rb +16 -0
  28. data/lib/datadog/ci/test_optimisation/component.rb +10 -6
  29. data/lib/datadog/ci/test_optimisation/coverage/ddcov.rb +1 -1
  30. data/lib/datadog/ci/test_visibility/telemetry.rb +3 -0
  31. data/lib/datadog/ci/utils/command.rb +117 -0
  32. data/lib/datadog/ci/utils/source_code.rb +31 -0
  33. data/lib/datadog/ci/version.rb +1 -1
  34. metadata +18 -5
  35. data/lib/datadog/ci/impacted_tests_detection/telemetry.rb +0 -16
@@ -5,6 +5,10 @@ require "pathname"
5
5
  require "set"
6
6
 
7
7
  require_relative "../ext/telemetry"
8
+ require_relative "../utils/command"
9
+ require_relative "base_branch_sha_detector"
10
+ require_relative "cli"
11
+ require_relative "diff"
8
12
  require_relative "telemetry"
9
13
  require_relative "user"
10
14
 
@@ -12,21 +16,6 @@ module Datadog
12
16
  module CI
13
17
  module Git
14
18
  module LocalRepository
15
- class GitCommandExecutionError < StandardError
16
- attr_reader :output, :command, :status
17
- def initialize(message, output:, command:, status:)
18
- super(message)
19
-
20
- @output = output
21
- @command = command
22
- @status = status
23
- end
24
- end
25
-
26
- COMMAND_RETRY_COUNT = 3
27
- POSSIBLE_BASE_BRANCHES = %w[main master preprod prod dev development trunk].freeze
28
- DEFAULT_LIKE_BRANCH_FILTER = /^(#{POSSIBLE_BASE_BRANCHES.join("|")}|release\/.*|hotfix\/.*)$/.freeze
29
-
30
19
  def self.root
31
20
  return @root if defined?(@root)
32
21
 
@@ -34,7 +23,7 @@ module Datadog
34
23
  end
35
24
 
36
25
  # ATTENTION: this function is running in a hot path
37
- # and should be optimized for performance
26
+ # and must be optimized for performance
38
27
  def self.relative_to_root(path)
39
28
  return "" if path.nil?
40
29
 
@@ -49,14 +38,22 @@ module Datadog
49
38
  # that root is a prefix of the path
50
39
  return "" if path.size < prefix_index
51
40
 
52
- prefix_index += 1 if path[prefix_index] == File::SEPARATOR
53
- res = path[prefix_index..]
41
+ # this means that the root is not a prefix of this path somehow
42
+ return "" if path[prefix_index] != File::SEPARATOR
43
+
44
+ res = path[prefix_index + 1..]
54
45
  else
55
46
  # prefix_to_root is a difference between the root path and the given path
56
- if @prefix_to_root == ""
57
- return path
58
- elsif @prefix_to_root
59
- return File.join(@prefix_to_root, path)
47
+ if defined?(@prefix_to_root)
48
+ # if path starts with ./ remove the dot before applying the optimization
49
+ # @type var path: String
50
+ path = path[1..] if path.start_with?("./")
51
+
52
+ if @prefix_to_root == ""
53
+ return path
54
+ elsif @prefix_to_root
55
+ return File.join(@prefix_to_root, path)
56
+ end
60
57
  end
61
58
 
62
59
  pathname = Pathname.new(File.expand_path(path))
@@ -97,26 +94,26 @@ module Datadog
97
94
  res = nil
98
95
 
99
96
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
100
- res = exec_git_command("git ls-remote --get-url")
97
+ res = CLI.exec_git_command(["ls-remote", "--get-url"])
101
98
  end
102
99
 
103
100
  Telemetry.git_command_ms(Ext::Telemetry::Command::GET_REPOSITORY, duration_ms)
104
101
  res
105
102
  rescue => e
106
103
  log_failure(e, "git repository url")
107
- telemetry_track_error(e, Ext::Telemetry::Command::GET_REPOSITORY)
104
+ Telemetry.track_error(e, Ext::Telemetry::Command::GET_REPOSITORY)
108
105
  nil
109
106
  end
110
107
 
111
108
  def self.git_root
112
- exec_git_command("git rev-parse --show-toplevel")
109
+ CLI.exec_git_command(["rev-parse", "--show-toplevel"])
113
110
  rescue => e
114
111
  log_failure(e, "git root path")
115
112
  nil
116
113
  end
117
114
 
118
115
  def self.git_commit_sha
119
- exec_git_command("git rev-parse HEAD")
116
+ CLI.exec_git_command(["rev-parse", "HEAD"])
120
117
  rescue => e
121
118
  log_failure(e, "git commit sha")
122
119
  nil
@@ -128,26 +125,26 @@ module Datadog
128
125
  res = nil
129
126
 
130
127
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
131
- res = exec_git_command("git rev-parse --abbrev-ref HEAD")
128
+ res = CLI.exec_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
132
129
  end
133
130
 
134
131
  Telemetry.git_command_ms(Ext::Telemetry::Command::GET_BRANCH, duration_ms)
135
132
  res
136
133
  rescue => e
137
134
  log_failure(e, "git branch")
138
- telemetry_track_error(e, Ext::Telemetry::Command::GET_BRANCH)
135
+ Telemetry.track_error(e, Ext::Telemetry::Command::GET_BRANCH)
139
136
  nil
140
137
  end
141
138
 
142
139
  def self.git_tag
143
- exec_git_command("git tag --points-at HEAD")
140
+ CLI.exec_git_command(["tag", "--points-at", "HEAD"])
144
141
  rescue => e
145
142
  log_failure(e, "git tag")
146
143
  nil
147
144
  end
148
145
 
149
146
  def self.git_commit_message
150
- exec_git_command("git log -n 1 --format=%B")
147
+ CLI.exec_git_command(["log", "-n", "1", "--format=%B"])
151
148
  rescue => e
152
149
  log_failure(e, "git commit message")
153
150
  nil
@@ -155,7 +152,7 @@ module Datadog
155
152
 
156
153
  def self.git_commit_users
157
154
  # Get committer and author information in one command.
158
- output = exec_git_command("git show -s --format='%an\t%ae\t%at\t%cn\t%ce\t%ct'")
155
+ output = CLI.exec_git_command(["show", "-s", "--format=%an\t%ae\t%at\t%cn\t%ce\t%ct"])
159
156
  unless output
160
157
  Datadog.logger.debug(
161
158
  "Unable to read git commit users: git command output is nil"
@@ -186,7 +183,7 @@ module Datadog
186
183
  output = nil
187
184
 
188
185
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
189
- output = exec_git_command("git log --format=%H -n 1000 --since=\"1 month ago\"")
186
+ output = CLI.exec_git_command(["log", "--format=%H", "-n", "1000", "--since=\"1 month ago\""], timeout: CLI::LONG_TIMEOUT)
190
187
  end
191
188
 
192
189
  Telemetry.git_command_ms(Ext::Telemetry::Command::GET_LOCAL_COMMITS, duration_ms)
@@ -196,27 +193,28 @@ module Datadog
196
193
  output.split("\n")
197
194
  rescue => e
198
195
  log_failure(e, "git commits")
199
- telemetry_track_error(e, Ext::Telemetry::Command::GET_LOCAL_COMMITS)
196
+ Telemetry.track_error(e, Ext::Telemetry::Command::GET_LOCAL_COMMITS)
200
197
  []
201
198
  end
202
199
 
203
200
  def self.git_commits_rev_list(included_commits:, excluded_commits:)
204
201
  Telemetry.git_command(Ext::Telemetry::Command::GET_OBJECTS)
205
- included_commits = filter_invalid_commits(included_commits).join(" ")
206
- excluded_commits = filter_invalid_commits(excluded_commits).map! { |sha| "^#{sha}" }.join(" ")
202
+ included_commits_list = filter_invalid_commits(included_commits)
203
+ excluded_commits_list = filter_invalid_commits(excluded_commits).map { |sha| "^#{sha}" }
207
204
 
208
205
  # @type var res: String?
209
206
  res = nil
210
207
 
211
208
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
212
- res = exec_git_command(
213
- "git rev-list " \
214
- "--objects " \
215
- "--no-object-names " \
216
- "--filter=blob:none " \
217
- "--since=\"1 month ago\" " \
218
- "#{excluded_commits} #{included_commits}"
219
- )
209
+ cmd = [
210
+ "rev-list",
211
+ "--objects",
212
+ "--no-object-names",
213
+ "--filter=blob:none",
214
+ "--since=\"1 month ago\""
215
+ ] + excluded_commits_list + included_commits_list
216
+
217
+ res = CLI.exec_git_command(cmd, timeout: CLI::LONG_TIMEOUT)
220
218
  end
221
219
 
222
220
  Telemetry.git_command_ms(Ext::Telemetry::Command::GET_OBJECTS, duration_ms)
@@ -224,7 +222,7 @@ module Datadog
224
222
  res
225
223
  rescue => e
226
224
  log_failure(e, "git commits rev list")
227
- telemetry_track_error(e, Ext::Telemetry::Command::GET_OBJECTS)
225
+ Telemetry.track_error(e, Ext::Telemetry::Command::GET_OBJECTS)
228
226
  nil
229
227
  end
230
228
 
@@ -239,9 +237,10 @@ module Datadog
239
237
  Telemetry.git_command(Ext::Telemetry::Command::PACK_OBJECTS)
240
238
 
241
239
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
242
- exec_git_command(
243
- "git pack-objects --compression=9 --max-pack-size=3m #{path}/#{basename}",
244
- stdin: commit_tree
240
+ CLI.exec_git_command(
241
+ ["pack-objects", "--compression=9", "--max-pack-size=3m", "#{path}/#{basename}"],
242
+ stdin: commit_tree,
243
+ timeout: CLI::LONG_TIMEOUT
245
244
  )
246
245
  end
247
246
  Telemetry.git_command_ms(Ext::Telemetry::Command::PACK_OBJECTS, duration_ms)
@@ -249,7 +248,7 @@ module Datadog
249
248
  basename
250
249
  rescue => e
251
250
  log_failure(e, "git generate packfiles")
252
- telemetry_track_error(e, Ext::Telemetry::Command::PACK_OBJECTS)
251
+ Telemetry.track_error(e, Ext::Telemetry::Command::PACK_OBJECTS)
253
252
  nil
254
253
  end
255
254
 
@@ -258,14 +257,14 @@ module Datadog
258
257
  res = false
259
258
 
260
259
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
261
- res = exec_git_command("git rev-parse --is-shallow-repository") == "true"
260
+ res = CLI.exec_git_command(["rev-parse", "--is-shallow-repository"]) == "true"
262
261
  end
263
262
  Telemetry.git_command_ms(Ext::Telemetry::Command::CHECK_SHALLOW, duration_ms)
264
263
 
265
264
  res
266
265
  rescue => e
267
266
  log_failure(e, "git shallow clone")
268
- telemetry_track_error(e, Ext::Telemetry::Command::CHECK_SHALLOW)
267
+ Telemetry.track_error(e, Ext::Telemetry::Command::CHECK_SHALLOW)
269
268
  false
270
269
  end
271
270
 
@@ -274,19 +273,15 @@ module Datadog
274
273
  # @type var res: String?
275
274
  res = nil
276
275
 
277
- unshallow_command =
278
- "git fetch " \
279
- "--shallow-since=\"1 month ago\" " \
280
- "--update-shallow " \
281
- "--filter=\"blob:none\" " \
282
- "--recurse-submodules=no " \
283
- "$(git config --default origin --get clone.defaultRemoteName)"
276
+ default_remote = CLI.exec_git_command(["config", "--default", "origin", "--get", "clone.defaultRemoteName"])&.strip
277
+ head_commit = git_commit_sha
278
+ upstream_branch = get_upstream_branch
284
279
 
285
- unshallow_remotes = [
286
- "$(git rev-parse HEAD)",
287
- "$(git rev-parse --abbrev-ref --symbolic-full-name @{upstream})",
288
- nil
289
- ]
280
+ # Build array of remotes to try, filtering out nil values
281
+ unshallow_remotes = []
282
+ unshallow_remotes << head_commit if head_commit
283
+ unshallow_remotes << upstream_branch if upstream_branch
284
+ unshallow_remotes << nil # Ensure the loop runs at least once, even if no valid remotes are available. This acts as a fallback mechanism.
290
285
 
291
286
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
292
287
  unshallow_remotes.each do |remote|
@@ -294,17 +289,27 @@ module Datadog
294
289
 
295
290
  res =
296
291
  begin
297
- exec_git_command(
298
- "#{unshallow_command} #{remote}"
299
- )
292
+ # @type var cmd: Array[String]
293
+ cmd = [
294
+ "fetch",
295
+ "--shallow-since=\"1 month ago\"",
296
+ "--update-shallow",
297
+ "--filter=blob:none",
298
+ "--recurse-submodules=no",
299
+ default_remote
300
+ ]
301
+ cmd << remote if remote
302
+
303
+ CLI.exec_git_command(cmd, timeout: CLI::UNSHALLOW_TIMEOUT)
300
304
  rescue => e
301
305
  log_failure(e, "git unshallow")
302
- telemetry_track_error(e, Ext::Telemetry::Command::UNSHALLOW)
306
+ Telemetry.track_error(e, Ext::Telemetry::Command::UNSHALLOW)
303
307
  unshallowing_errored = true
304
308
  nil
305
309
  end
306
310
 
307
- break [] unless unshallowing_errored
311
+ # If the command succeeded, break and return the result
312
+ break [] if res && !unshallowing_errored
308
313
  end
309
314
  end
310
315
 
@@ -312,10 +317,10 @@ module Datadog
312
317
  res
313
318
  end
314
319
 
315
- # Returns a Set of normalized file paths changed since the given base_commit.
320
+ # Returns a Diff object with relative file paths for files that were changed since the given base_commit.
316
321
  # If base_commit is nil, returns nil. On error, returns nil.
317
- def self.get_changed_files_from_diff(base_commit)
318
- return nil if base_commit.nil?
322
+ def self.get_changes_since(base_commit)
323
+ return Diff.new if base_commit.nil?
319
324
 
320
325
  Datadog.logger.debug { "calculating git diff from base_commit: #{base_commit}" }
321
326
 
@@ -327,35 +332,20 @@ module Datadog
327
332
  # @type var output: String?
328
333
  output = nil
329
334
  duration_ms = Core::Utils::Time.measure(:float_millisecond) do
330
- output = exec_git_command("git diff -U0 --word-diff=porcelain #{base_commit} HEAD")
335
+ output = CLI.exec_git_command(["diff", "-U0", "--word-diff=porcelain", base_commit, "HEAD"], timeout: CLI::LONG_TIMEOUT)
331
336
  end
332
337
  Telemetry.git_command_ms(Ext::Telemetry::Command::DIFF, duration_ms)
333
338
 
334
339
  Datadog.logger.debug { "git diff output: #{output}" }
335
340
 
336
- return nil if output.nil?
337
-
338
- # 2. Parse the output to extract which files changed
339
- changed_files = Set.new
340
- output.each_line do |line|
341
- # Match lines like: diff --git a/foo/bar.rb b/foo/bar.rb
342
- # This captures git changes on file level
343
- match = /^diff --git a\/(?<file>.+) b\/(?<file2>.+)$/.match(line)
344
- if match && match[:file]
345
- changed_file = match[:file]
346
- # Normalize to repo root
347
- normalized_changed_file = relative_to_root(changed_file)
348
- changed_files << normalized_changed_file unless normalized_changed_file.nil? || normalized_changed_file.empty?
349
-
350
- Datadog.logger.debug { "matched changed_file: #{changed_file} from line: #{line}" }
351
- Datadog.logger.debug { "normalized_changed_file: #{normalized_changed_file}" }
352
- end
353
- end
354
- changed_files
341
+ return Diff.new if output.nil?
342
+
343
+ # 2. Parse the output using Git::Diff
344
+ Diff.parse_diff_output(output)
355
345
  rescue => e
356
- telemetry_track_error(e, Ext::Telemetry::Command::DIFF)
346
+ Telemetry.track_error(e, Ext::Telemetry::Command::DIFF)
357
347
  log_failure(e, "get changed files from diff")
358
- nil
348
+ Diff.new
359
349
  end
360
350
  end
361
351
 
@@ -364,245 +354,28 @@ module Datadog
364
354
  def self.base_commit_sha(base_branch: nil)
365
355
  Telemetry.git_command(Ext::Telemetry::Command::BASE_COMMIT_SHA)
366
356
 
367
- remote_name = get_remote_name
368
- Datadog.logger.debug { "Remote name: '#{remote_name}'" }
369
-
370
- source_branch = get_source_branch
371
- return nil if source_branch.nil?
372
-
373
- Datadog.logger.debug { "Source branch: '#{source_branch}'" }
374
-
375
- # Early exit if source is a main-like branch
376
- if main_like_branch?(source_branch, remote_name)
377
- Datadog.logger.debug { "Branch '#{source_branch}' already matches base branch filter (#{DEFAULT_LIKE_BRANCH_FILTER})" }
378
- return nil
379
- end
380
-
381
- possible_base_branches = base_branch.nil? ? POSSIBLE_BASE_BRANCHES : [base_branch]
382
- # Check and fetch base branches if they don't exist in local git repository
383
- check_and_fetch_base_branches(possible_base_branches, remote_name)
384
-
385
- default_branch = detect_default_branch(remote_name)
386
- Datadog.logger.debug { "Default branch: '#{default_branch}'" }
387
-
388
- candidates = build_candidate_list(remote_name, source_branch, base_branch)
389
- if candidates.nil? || candidates.empty?
390
- Datadog.logger.debug { "No candidate branches found." }
391
- return nil
392
- end
393
-
394
- metrics = compute_branch_metrics(candidates, source_branch)
395
- Datadog.logger.debug { "Branch metrics: '#{metrics}'" }
396
-
397
- best_branch_sha = find_best_branch(metrics, default_branch, remote_name)
398
- Datadog.logger.debug { "Best branch: '#{best_branch_sha}'" }
399
-
400
- best_branch_sha
357
+ BaseBranchShaDetector.base_branch_sha(base_branch)
401
358
  rescue => e
402
- telemetry_track_error(e, Ext::Telemetry::Command::BASE_COMMIT_SHA)
359
+ Telemetry.track_error(e, Ext::Telemetry::Command::BASE_COMMIT_SHA)
403
360
  log_failure(e, "git base ref")
404
361
  nil
405
362
  end
406
363
 
407
- def self.check_and_fetch_branch(branch, remote_name)
408
- # Check if branch exists locally
409
- exec_git_command("git show-ref --verify --quiet refs/heads/#{branch}")
410
- Datadog.logger.debug { "Branch '#{branch}' exists locally, skipping" }
411
- rescue GitCommandExecutionError => e
412
- Datadog.logger.debug { "Branch '#{branch}' doesn't exist locally, checking remote: #{e}" }
413
- begin
414
- remote_heads = exec_git_command("git ls-remote --heads #{remote_name} #{branch}")
415
- if remote_heads.nil? || remote_heads.empty?
416
- Datadog.logger.debug { "Branch '#{branch}' doesn't exist in remote" }
417
- return
418
- end
419
-
420
- Datadog.logger.debug { "Branch '#{branch}' exists in remote, fetching" }
421
- exec_git_command("git fetch --depth 1 #{remote_name} #{branch}:#{branch}")
422
- rescue GitCommandExecutionError => e
423
- Datadog.logger.debug { "Branch '#{branch}' couldn't be fetched from remote: #{e}" }
424
- end
425
- end
426
-
427
- def self.check_and_fetch_base_branches(branches, remote_name)
428
- branches.each do |branch|
429
- check_and_fetch_branch(branch, remote_name)
430
- end
431
- end
432
-
433
- def self.get_source_branch
434
- source_branch = exec_git_command("git rev-parse --abbrev-ref HEAD")&.strip
435
- if source_branch.nil?
436
- Datadog.logger.debug { "Could not get current branch" }
437
- return nil
438
- end
439
-
440
- exec_git_command("git rev-parse --verify --quiet #{source_branch} > /dev/null")
441
- source_branch
442
- end
443
-
444
- def self.remove_remote_prefix(branch_name, remote_name)
445
- branch_name&.sub(/^#{Regexp.escape(remote_name)}\//, "")
446
- end
447
-
448
- def self.main_like_branch?(branch_name, remote_name)
449
- short_branch_name = remove_remote_prefix(branch_name, remote_name)
450
- short_branch_name&.match?(DEFAULT_LIKE_BRANCH_FILTER)
451
- end
452
-
453
- def self.detect_default_branch(remote_name)
454
- # @type var default_branch: String?
455
- default_branch = nil
456
- begin
457
- default_ref = exec_git_command("git symbolic-ref --quiet --short \"refs/remotes/#{remote_name}/HEAD\" 2>/dev/null")
458
- default_branch = remove_remote_prefix(default_ref, remote_name) unless default_ref.nil?
459
- rescue
460
- Datadog.logger.debug { "Could not get symbolic-ref, trying to find a fallback (main, master)..." }
461
- end
462
-
463
- default_branch = find_fallback_default_branch(remote_name) if default_branch.nil?
464
- default_branch
465
- end
466
-
467
- def self.find_fallback_default_branch(remote_name)
468
- ["main", "master"].each do |fallback|
469
- exec_git_command("git show-ref --verify --quiet \"refs/remotes/#{remote_name}/#{fallback}\"")
470
- Datadog.logger.debug { "Found fallback default branch '#{fallback}'" }
471
- return fallback
472
- rescue
473
- next
474
- end
364
+ def self.get_upstream_branch
365
+ CLI.exec_git_command(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"])
366
+ rescue => e
367
+ Datadog.logger.debug { "Error getting upstream: #{e}" }
475
368
  nil
476
369
  end
477
370
 
478
- def self.build_candidate_list(remote_name, source_branch, base_branch)
479
- unless base_branch.nil?
480
- return [base_branch]
481
- end
482
-
483
- candidates = exec_git_command("git for-each-ref --format='%(refname:short)' refs/heads \"refs/remotes/#{remote_name}\"")&.lines&.map(&:strip)
484
- Datadog.logger.debug { "Available branches: '#{candidates}'" }
485
- candidates&.select! { |b| b.match?(DEFAULT_LIKE_BRANCH_FILTER) && b != source_branch }
486
- Datadog.logger.debug { "Candidate branches: '#{candidates}'" }
487
- candidates
488
- end
489
-
490
- def self.compute_branch_metrics(candidates, source_branch)
491
- metrics = {}
492
- candidates.each do |cand|
493
- base_sha = exec_git_command("git merge-base #{cand} #{source_branch} 2>/dev/null")&.strip
494
- next if base_sha.nil? || base_sha.empty?
495
-
496
- behind, ahead = exec_git_command("git rev-list --left-right --count #{cand}...#{source_branch}")&.strip&.split&.map(&:to_i)
497
- metrics[cand] = {behind: behind, ahead: ahead, base_sha: base_sha}
498
- end
499
- metrics
500
- end
501
-
502
- def self.find_best_branch(metrics, default_branch, remote_name)
503
- return nil if metrics.empty?
504
-
505
- _, best_data = metrics.min_by do |cand, data|
506
- [
507
- data[:ahead],
508
- default_branch?(cand, default_branch, remote_name) ? 0 : 1 # prefer default branch on tie
509
- ]
510
- end
511
-
512
- best_data ? best_data[:base_sha] : nil
513
- end
514
-
515
- def self.default_branch?(branch, default_branch, remote_name)
516
- branch == default_branch || branch == "#{remote_name}/#{default_branch}"
517
- end
518
-
519
- def self.get_remote_name
520
- # Try to find remote from upstream tracking
521
- upstream = nil
522
- begin
523
- upstream = exec_git_command("git rev-parse --abbrev-ref --symbolic-full-name @{upstream}")&.strip
524
- rescue => e
525
- Datadog.logger.debug { "Error getting upstream: #{e}" }
526
- end
527
-
528
- if upstream
529
- upstream.split("/").first
530
- else
531
- # Fallback to first remote if no upstream is set
532
- first_remote_value = exec_git_command("git remote")&.split("\n")&.first
533
- Datadog.logger.debug { "First remote value: '#{first_remote_value}'" }
534
- first_remote_value || "origin"
535
- end
371
+ def self.filter_invalid_commits(commits)
372
+ commits.filter { |commit| Utils::Git.valid_commit_sha?(commit) }
536
373
  end
537
374
 
538
- # makes .exec_git_command private to make sure that this method
539
- # is not called from outside of this module with insecure parameters
540
- class << self
541
- private
542
-
543
- def filter_invalid_commits(commits)
544
- commits.filter { |commit| Utils::Git.valid_commit_sha?(commit) }
545
- end
546
-
547
- def exec_git_command(cmd, stdin: nil)
548
- # Shell injection is alleviated by making sure that no outside modules call this method.
549
- # It is called only internally with static parameters.
550
-
551
- # @type var out: String
552
- # @type var status: Process::Status?
553
- out, status = Open3.capture2e(cmd, stdin_data: stdin)
554
-
555
- if status.nil?
556
- # @type var retry_count: Integer
557
- retry_count = COMMAND_RETRY_COUNT
558
- Datadog.logger.debug { "Opening pipe failed, starting retries..." }
559
- while status.nil? && retry_count.positive?
560
- # no-dd-sa:ruby-security/shell-injection
561
- out, status = Open3.capture2e(cmd, stdin_data: stdin)
562
- Datadog.logger.debug { "After retry status is [#{status}]" }
563
- retry_count -= 1
564
- end
565
- end
566
-
567
- if status.nil? || !status.success?
568
- raise GitCommandExecutionError.new(
569
- "Failed to run git command [#{cmd}] with input [#{stdin}] and output [#{out}]",
570
- output: out,
571
- command: cmd,
572
- status: status
573
- )
574
- end
575
-
576
- # Sometimes Encoding.default_external is somehow set to US-ASCII which breaks
577
- # commit messages with UTF-8 characters like emojis
578
- # We force output's encoding to be UTF-8 in this case
579
- # This is safe to do as UTF-8 is compatible with US-ASCII
580
- if Encoding.default_external == Encoding::US_ASCII
581
- out = out.force_encoding(Encoding::UTF_8)
582
- end
583
- out.strip! # There's always a "\n" at the end of the command output
584
-
585
- return nil if out.empty?
586
-
587
- out
588
- end
589
-
590
- def log_failure(e, action)
591
- Datadog.logger.debug(
592
- "Unable to perform #{action}: #{e.class.name} #{e.message} at #{Array(e.backtrace).first}"
593
- )
594
- end
595
-
596
- def telemetry_track_error(e, command)
597
- case e
598
- when Errno::ENOENT
599
- Telemetry.git_command_errors(command, executable_missing: true)
600
- when GitCommandExecutionError
601
- Telemetry.git_command_errors(command, exit_code: e.status&.to_i)
602
- else
603
- Telemetry.git_command_errors(command, exit_code: -9000)
604
- end
605
- end
375
+ def self.log_failure(e, action)
376
+ Datadog.logger.debug(
377
+ "Unable to perform #{action}: #{e.class.name} #{e.message} at #{Array(e.backtrace).first}"
378
+ )
606
379
  end
607
380
  end
608
381
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../utils/telemetry"
4
+ require_relative "cli"
5
+
3
6
  module Datadog
4
7
  module CI
5
8
  module Git
@@ -21,6 +24,17 @@ module Datadog
21
24
  Utils::Telemetry.distribution(Ext::Telemetry::METRIC_GIT_COMMAND_MS, duration_ms, tags_for_command(command))
22
25
  end
23
26
 
27
+ def self.track_error(e, command)
28
+ case e
29
+ when Errno::ENOENT
30
+ git_command_errors(command, executable_missing: true)
31
+ when CLI::GitCommandExecutionError
32
+ git_command_errors(command, exit_code: e.status&.to_i)
33
+ else
34
+ git_command_errors(command, exit_code: -9000)
35
+ end
36
+ end
37
+
24
38
  def self.tags_for_command(command)
25
39
  {Ext::Telemetry::TAG_COMMAND => command}
26
40
  end