• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

repotag / rjgit / 12725921302

11 Jan 2025 04:41PM UTC coverage: 99.492% (+0.04%) from 99.456%
12725921302

Pull #69

github

bartkamphorst
Update JGit lib from 6.8.0.202311291450-r to 7.0.0.202409031743-r.
Pull Request #69: Update JGit lib from 6.8.0.202311291450-r to 7.0.0.202409031743-r.

2744 of 2758 relevant lines covered (99.49%)

169.96 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.72
/lib/rjgit.rb
1
module RJGit
2✔
2
  begin
1✔
3
    require 'java'
2✔
4
    Dir["#{File.dirname(__FILE__)}/java/jars/*.jar"].each { |jar| require jar }
18✔
5
  rescue LoadError
6
    raise "You need to be running JRuby to use this gem."
×
7
  end
8

9
  def self.version
2✔
10
    VERSION
2✔
11
  end
12

13
  require 'uri'
2✔
14
  require 'stringio'
2✔
15
  # gem requires
16
  require 'mime/types'
2✔
17
  # require helpers first because RJGit#delegate_to is needed
18
  require "#{File.dirname(__FILE__)}/rjgit_helpers.rb"
2✔
19
  # require everything else
20
  begin
21
    Dir["#{File.dirname(__FILE__)}/*.rb"].each do |file|
2✔
22
      require file
28✔
23
    end
24
  end
25

26
  import 'org.eclipse.jgit.lib.ObjectId'
2✔
27
  
28
  module Porcelain
2✔
29

30
    import 'java.io.IOException'
2✔
31
    import 'org.eclipse.jgit.lib.Constants'
2✔
32
    import 'org.eclipse.jgit.api.AddCommand'
2✔
33
    import 'org.eclipse.jgit.api.CommitCommand'
2✔
34
    import 'org.eclipse.jgit.api.BlameCommand'
2✔
35
    import 'org.eclipse.jgit.api.errors.RefNotFoundException'
2✔
36
    import 'org.eclipse.jgit.blame.BlameGenerator'
2✔
37
    import 'org.eclipse.jgit.blame.BlameResult'
2✔
38
    import 'org.eclipse.jgit.errors.IncorrectObjectTypeException'
2✔
39
    import 'org.eclipse.jgit.errors.InvalidPatternException'
2✔
40
    import 'org.eclipse.jgit.errors.MissingObjectException'
2✔
41
    import 'org.eclipse.jgit.treewalk.CanonicalTreeParser'
2✔
42
    import 'org.eclipse.jgit.diff.DiffFormatter'
2✔
43

44
    # http://wiki.eclipse.org/JGit/User_Guide#Porcelain_API
45
    def self.add(repository, file_pattern)
2✔
46
      repository.add(file_pattern)
6✔
47
    end
48

49
    def self.commit(repository, message="")
2✔
50
      repository.commit(message)
12✔
51
    end
52

53
    def self.object_for_tag(repository, tag)
2✔
54
      repository.find(tag.object.name, RJGit.sym_for_type(tag.object_type))
2✔
55
    end
56

57
    # http://dev.eclipse.org/mhonarc/lists/jgit-dev/msg00558.html
58
    def self.cat_file(repository, blob)
2✔
59
      mode = blob.mode if blob.respond_to?(:mode)
82✔
60
      jrepo = RJGit.repository_type(repository)
82✔
61
      jblob = RJGit.blob_type(blob)
82✔
62
      # Try to resolve symlinks; return nil otherwise
63
      mode ||= RJGit.get_file_mode(jrepo, jblob)
82✔
64
      if mode == SYMLINK_TYPE
82✔
65
        symlink_source = jrepo.open(jblob.id).get_bytes.to_a.pack('c*').force_encoding('UTF-8')
×
66
        blob = Blob.find_blob(jrepo, symlink_source)
×
67
        return nil if blob.nil?
×
68
        jblob = blob.jblob
×
69
      end
70
      bytes = jrepo.open(jblob.id).get_bytes
82✔
71
      return bytes.to_a.pack('c*').force_encoding('UTF-8')
82✔
72
    end
73

74
    def self.ls_tree(repository, path=nil, treeish=Constants::HEAD, options={})
2✔
75
      options = {recursive: false, print: false, io: $stdout, path_filter: nil}.merge options
64✔
76
      jrepo = RJGit.repository_type(repository)
64✔
77
      ref = treeish.respond_to?(:get_name) ? treeish.get_name : treeish
64✔
78

79
      begin
63✔
80
        obj = jrepo.resolve(ref)
64✔
81
        walk = RevWalk.new(jrepo)
64✔
82
        revobj = walk.parse_any(obj)
64✔
83
        jtree = case revobj.get_type
64✔
84
        when Constants::OBJ_TREE
85
          walk.parse_tree(obj)
4✔
86
        when Constants::OBJ_COMMIT
87
          walk.parse_commit(obj).get_tree
60✔
88
        end
89
      rescue Java::OrgEclipseJgitErrors::MissingObjectException, Java::JavaLang::IllegalArgumentException, Java::JavaLang::NullPointerException
90
        return nil
×
91
      end
92
      if path
64✔
93
        treewalk = TreeWalk.forPath(jrepo, path, jtree)
16✔
94
        return nil unless treewalk
16✔
95
        treewalk.enter_subtree
16✔
96
      else
97
        treewalk = TreeWalk.new(jrepo)
48✔
98
        treewalk.add_tree(jtree)
48✔
99
      end
100
      treewalk.set_recursive(options[:recursive])
64✔
101
      treewalk.set_filter(PathFilter.create(options[:path_filter])) if options[:path_filter]
64✔
102
      entries = []
64✔
103

104
      while treewalk.next
64✔
105
        entry = {}
1,350✔
106
        mode = treewalk.get_file_mode(0)
1,350✔
107
        entry[:mode] = mode.get_bits
1,350✔
108
        entry[:type] = Constants.type_string(mode.get_object_type)
1,350✔
109
        entry[:id]   = treewalk.get_object_id(0).name
1,350✔
110
        entry[:path] = treewalk.get_path_string
1,350✔
111
        entries << entry
1,350✔
112
      end
113
      options[:io].puts RJGit.stringify(entries) if options[:print]
64✔
114
      entries
64✔
115
    end
116

117
    def self.blame(repository, file_path, options={})
2✔
118
      options = {:print => false, :io => $stdout}.merge(options)
6✔
119
      jrepo = RJGit.repository_type(repository)
6✔
120
      return nil unless jrepo
6✔
121

122
      blame_command = BlameCommand.new(jrepo)
6✔
123
      blame_command.set_file_path(file_path)
6✔
124
      result = blame_command.call
6✔
125
      content = result.get_result_contents
6✔
126
      blame = []
6✔
127
      for index in (0..content.size - 1) do
6✔
128
        blameline = {}
240✔
129
        blameline[:actor] = Actor.new_from_person_ident(result.get_source_author(index))
240✔
130
        blameline[:line] = result.get_source_line(index)
240✔
131
        blameline[:commit] = Commit.new(repository, result.get_source_commit(index))
240✔
132
        blameline[:line] = content.get_string(index)
240✔
133
        blame << blameline
240✔
134
      end
135
      options[:io].puts RJGit.stringify(blame) if options[:print]
6✔
136
      return blame
6✔
137
    end
138

139
    def self.diff(repository, options = {})
2✔
140
      options = {:namestatus => false, :patch => false}.merge(options)
34✔
141
      repo = RJGit.repository_type(repository)
34✔
142
      git = RubyGit.new(repo).jgit
34✔
143
      diff_command = git.diff
34✔
144
      [:old, :new].each do |which_rev|
34✔
145
        if rev = options["#{which_rev}_rev".to_sym]
68✔
146
          reader = repo.new_object_reader
32✔
147
          parser = CanonicalTreeParser.new
32✔
148
          parser.reset(reader, repo.resolve("#{rev}^{tree}"))
32✔
149
          diff_command.send("set_#{which_rev}_tree".to_sym, parser)
32✔
150
        end
151
      end
152
      diff_command.set_path_filter(PathFilter.create(options[:file_path])) if options[:file_path]
34✔
153
      diff_command.set_show_name_and_status_only(true) if options[:namestatus]
34✔
154
      diff_command.set_cached(true) if options[:cached]
34✔
155
      diff_entries = diff_command.call
34✔
156
      diff_entries = diff_entries.to_array.to_ary
34✔
157
        if options[:patch]
34✔
158
          result = []
14✔
159
          out_stream = ByteArrayOutputStream.new
14✔
160
          formatter = DiffFormatter.new(out_stream)
14✔
161
          formatter.set_repository(repo)
14✔
162
          diff_entries.each do |diff_entry|
14✔
163
            formatter.format(diff_entry)
6,038✔
164
            result.push [diff_entry, out_stream.to_string]
6,038✔
165
            out_stream.reset
6,038✔
166
          end
167
        end
168
      diff_entries = options[:patch] ? result : diff_entries.map {|entry| [entry]}
48✔
169
      RJGit.convert_diff_entries(diff_entries)
34✔
170
    end
171

172
    def self.describe(repository, ref, options = {})
2✔
173
      options = {:always => false, :long => false, :tags => false, :match => []}.merge(options)
16✔
174
      repo = RJGit.repository_type(repository)
16✔
175
      git = RubyGit.new(repo).jgit
16✔
176
      command = git.describe.
40✔
177
        set_always(options[:always]).
7✔
178
        set_long(options[:long]).
7✔
179
        set_tags(options[:tags])
7✔
180
      begin
15✔
181
        command = command.set_target(ref)
16✔
182
      rescue IncorrectObjectTypeException, IOException, MissingObjectException, RefNotFoundException
183
        return nil
2✔
184
      end
185
      options[:match].each do |match|
14✔
186
        begin
13✔
187
          command = command.set_match(match)
14✔
188
        rescue InvalidPatternException
189
          return nil
2✔
190
        end
191
      end
192
      command.call
12✔
193
    end
194

195
    # options:
196
    #  * ref
197
    #  * path_filter
198
    #  * case_insensitive
199
    def self.grep(repository, query, options={})
2✔
200
      case_insensitive = options[:case_insensitive]
36✔
201
      repo = RJGit.repository_type(repository)
36✔
202
      walk = RevWalk.new(repo)
36✔
203
      ls_tree_options = {:recursive => true, :path_filter => options[:path_filter]}
36✔
204

205
      query = case query
36✔
206
      when Regexp then query
20✔
207
      when String then Regexp.new(Regexp.escape(query))
14✔
208
      else raise "A #{query.class} was passed to #{self}.grep().  Only Regexps and Strings are supported!"
2✔
209
      end
210

211
      query = Regexp.new(query.source, query.options | Regexp::IGNORECASE) if case_insensitive
34✔
212

213
      # We optimize below by first grepping the entire file, and then, if a match is found, then identifying the individual line.
214
      # To avoid missing full-line matches during the optimization, we first convert multiline anchors to single-line anchors.
215
      query = Regexp.new(query.source.gsub(/\A\\A/, '^').gsub(/\\z\z/, '$'), query.options)
34✔
216

217
      ref = options.fetch(:ref, 'HEAD')
34✔
218
      files_to_scan = ls_tree(repo, nil, ref, ls_tree_options)
34✔
219

220
      files_to_scan.each_with_object({}) do |file, result|
34✔
221
        id = if file[:mode] == SYMLINK_TYPE
82✔
222
          symlink_source = repo.open(ObjectId.from_string(file[:id])).get_bytes.to_a.pack('c*').force_encoding('UTF-8')
2✔
223
          unless symlink_source[File::SEPARATOR]
2✔
224
            dir = file[:path].split(File::SEPARATOR)
2✔
225
            dir[-1] = symlink_source
2✔
226
            symlink_source = File.join(dir)
2✔
227
          end
228
          Blob.find_blob(repo, symlink_source, ref).jblob.id
2✔
229
        else
230
          ObjectId.from_string(file[:id])
80✔
231
        end
232
        bytes = repo.open(id).get_bytes
82✔
233

234
        next if RawText.is_binary(bytes)
82✔
235

236
        file_contents = bytes.to_s
72✔
237
        next unless query.match(file_contents)
72✔
238

239
        rows = file_contents.split("\n")
20✔
240
        data = rows.grep(query)
20✔
241
        next if data.empty?
20✔
242

243
        result[file[:path]] = data
20✔
244
      end
245
    end
246
  end
247

248
  module Plumbing
2✔
249
    import org.eclipse.jgit.lib.Constants
2✔
250

251
    class TreeBuilder
2✔
252
      import org.eclipse.jgit.lib.FileMode
2✔
253
      import org.eclipse.jgit.lib.TreeFormatter
2✔
254
      import org.eclipse.jgit.patch.Patch
2✔
255

256
      attr_accessor :treemap
2✔
257
      attr_reader :log
2✔
258

259
      def initialize(repository)
2✔
260
        @jrepo = RJGit.repository_type(repository)
34✔
261
        @treemap = {}
34✔
262
        init_log
34✔
263
      end
264

265
      def object_inserter
2✔
266
        @object_inserter ||= @jrepo.newObjectInserter
1,188✔
267
      end
268

269
      def init_log
2✔
270
        @log = {:deleted => [], :added => [] }
58✔
271
      end
272

273
      def only_contains_deletions(hashmap)
2✔
274
        hashmap.each do |key, value|
3,746✔
275
          if value.is_a?(Hash)
10,388✔
276
            return false unless only_contains_deletions(value)
2,776✔
277
          elsif value.is_a?(String)
7,610✔
278
            return false
38✔
279
          end
280
        end
281
        true
3,702✔
282
      end
283

284
      def build_tree(start_tree, treemap = nil, flush = false)
2✔
285
        existing_trees = {}
1,006✔
286
        untouched_objects = {}
1,006✔
287
        formatter = TreeFormatter.new
1,006✔
288
        treemap ||= self.treemap
1,006✔
289
        if start_tree
1,006✔
290
          treewalk = TreeWalk.new(@jrepo)
988✔
291
          treewalk.add_tree(start_tree)
988✔
292
          while treewalk.next
988✔
293
            filename = treewalk.get_name_string
3,188✔
294
            if treemap.keys.include?(filename)
3,188✔
295
              kind = treewalk.isSubtree ? :tree : :blob
3,002✔
296
                if treemap[filename] == false
3,002✔
297
                  @log[:deleted] << [kind, filename, treewalk.get_object_id(0)]
1,960✔
298
                else
299
                  existing_trees[filename] = treewalk.get_object_id(0) if kind == :tree
1,042✔
300
                end
301
            else
302
              mode = treewalk.get_file_mode(0)
186✔
303
              filename = "#{filename}/" if mode == FileMode::TREE
186✔
304
              untouched_objects[filename] = [mode, treewalk.get_object_id(0)]
186✔
305
            end
306
          end
307
        end
308

309
        sorted_treemap = treemap.inject({}) {|h, (k,v)| v.is_a?(Hash) ? h["#{k}/"] = v : h[k] = v; h }.merge(untouched_objects).sort
4,062✔
310
        sorted_treemap.each do |object_name, data|
1,006✔
311
          case data
3,242✔
312
            when Array
313
              object_name = object_name[0...-1] if data[0] == FileMode::TREE
186✔
314
              formatter.append(object_name.to_java_string, data[0], data[1])
186✔
315
            when Hash
316
              object_name = object_name[0...-1]
966✔
317
              next_tree = build_tree(existing_trees[object_name], data)
966✔
318
              formatter.append(object_name.to_java_string, FileMode::TREE, next_tree)
966✔
319
              @log[:added] << [:tree, object_name, next_tree] unless only_contains_deletions(data)
966✔
320
            when String
321
              blobid = write_blob(data)
124✔
322
              formatter.append(object_name.to_java_string, FileMode::REGULAR_FILE, blobid)
124✔
323
              @log[:added] << [:blob, object_name, blobid]
124✔
324
            end
325
        end
326
        object_inserter.insert(formatter)
1,006✔
327
      end
328

329
      def write_blob(contents, flush = false)
2✔
330
        blobid = object_inserter.insert(Constants::OBJ_BLOB, contents.to_java_bytes)
126✔
331
        object_inserter.flush if flush
126✔
332
        blobid
126✔
333
      end
334

335
    end
336

337
    class Index
2✔
338
      import org.eclipse.jgit.lib.CommitBuilder
2✔
339

340
      attr_accessor :treemap, :current_tree
2✔
341
      attr_reader :jrepo
2✔
342

343
      def initialize(repository)
2✔
344
        @treemap = {}
24✔
345
        @jrepo = RJGit.repository_type(repository)
24✔
346
        @treebuilder = TreeBuilder.new(@jrepo)
24✔
347
      end
348

349
      def add(path, data)
2✔
350
        path = path[1..-1] if path[0] == '/'
82✔
351
        path = path.split('/')
82✔
352
        filename = path.pop
82✔
353

354
        current = self.treemap
82✔
355

356
        path.each do |dir|
82✔
357
          current[dir] ||= {}
96✔
358
          node = current[dir]
96✔
359
          current = node
96✔
360
        end
361

362
        current[filename] = data
82✔
363
        @treemap
82✔
364
      end
365

366
      def delete(path)
2✔
367
        path = path[1..-1] if path[0] == '/'
1,958✔
368
        path = path.split('/')
1,958✔
369
        last = path.pop
1,958✔
370

371
        current = self.treemap
1,958✔
372

373
        path.each do |dir|
1,958✔
374
          current[dir] ||= {}
7,652✔
375
          node = current[dir]
7,652✔
376
          current = node
7,652✔
377
        end
378

379
        current[last] = false
1,958✔
380
        @treemap
1,958✔
381
      end
382

383
      def do_commit(message, author, parents, new_tree)
2✔
384
        commit_builder = CommitBuilder.new
26✔
385
        person = author.person_ident
26✔
386
        commit_builder.setCommitter(person)
26✔
387
        commit_builder.setAuthor(person)
26✔
388
        commit_builder.setMessage(message)
26✔
389
        commit_builder.setTreeId(RJGit.tree_type(new_tree))
26✔
390
        if parents.is_a?(Array)
26✔
391
          parents.each {|parent| commit_builder.addParentId(RJGit.commit_type(parent)) }
8✔
392
        elsif parents
20✔
393
          commit_builder.addParentId(RJGit.commit_type(parents))
16✔
394
        end
395
        result = @treebuilder.object_inserter.insert(commit_builder)
26✔
396
        @treebuilder.object_inserter.flush
26✔
397
        result
26✔
398
      end
399

400
      def commit(message, author, parents = nil, ref = "refs/heads/#{Constants::MASTER}", force = false)
10✔
401
        new_tree = build_new_tree(@treemap, "#{ref}^{tree}")
20✔
402
        return false if @current_tree && new_tree.name == @current_tree.name
20✔
403

404
        parents = parents ? parents : @jrepo.resolve(ref+"^{commit}")
20✔
405
        new_head = do_commit(message, author, parents, new_tree)
20✔
406

407
        # Point ref to the newest commit
408
        ru = @jrepo.updateRef(ref)
20✔
409
        ru.setNewObjectId(new_head)
20✔
410
        ru.setForceUpdate(force)
20✔
411
        ru.setRefLogIdent(author.person_ident)
20✔
412
        ru.setRefLogMessage("commit: #{message}", false)
20✔
413
        res = ru.update.to_string
20✔
414

415
        @current_tree = new_tree
20✔
416
        log = @treebuilder.log
20✔
417
        @treebuilder.init_log
20✔
418
        sha =  ObjectId.to_string(new_head)
20✔
419
        return res, log, sha
20✔
420
      end
421

422
      def self.successful?(result)
2✔
423
        ["NEW", "FAST_FORWARD", "FORCED"].include?(result)
6✔
424
      end
425
      
426
      private
2✔
427
      
428
      def build_new_tree(treemap, ref)
2✔
429
        @treebuilder.treemap = treemap
22✔
430
        new_tree = @treebuilder.build_tree(@current_tree ? RJGit.tree_type(@current_tree) : @jrepo.resolve(ref))
22✔
431
      end
432

433
    end
434

435
    class ApplyPatchToIndex < RJGit::Plumbing::Index
2✔
436

437
      import org.eclipse.jgit.patch.Patch
2✔
438
      import org.eclipse.jgit.diff.DiffEntry
2✔
439

440
      ADD    = org.eclipse.jgit.diff.DiffEntry::ChangeType::ADD
2✔
441
      COPY   = org.eclipse.jgit.diff.DiffEntry::ChangeType::COPY
2✔
442
      MODIFY = org.eclipse.jgit.diff.DiffEntry::ChangeType::MODIFY
2✔
443
      DELETE = org.eclipse.jgit.diff.DiffEntry::ChangeType::DELETE
2✔
444
      RENAME = org.eclipse.jgit.diff.DiffEntry::ChangeType::RENAME
2✔
445

446
      # Take the result of RJGit::Porcelain.diff with options[:patch] = true and return a patch String
447
      def self.diffs_to_patch(diffs)
2✔
448
        diffs.inject(""){|result, diff| result << diff[:patch]}
3,024✔
449
      end
450

451
      def initialize(repository, patch, ref = Constants::HEAD)
2✔
452
        super(repository)
14✔
453
        @ref   = ref
14✔
454
        @patch = Patch.new
14✔
455
        @patch.parse(ByteArrayInputStream.new(patch.to_java_bytes))
14✔
456
        raise_patch_apply_error unless @patch.getErrors.isEmpty()
14✔
457
        @current_tree = Commit.find_head(@jrepo, ref).tree
12✔
458
      end
459

460
      def commit(message, author, parents = nil, force = false)
2✔
461
        super(message, author, parents, @ref, force)
2✔
462
      end
463

464
      def build_map
2✔
465
        raise_patch_apply_error if @patch.getFiles.isEmpty()
12✔
466
        @patch.getFiles.each do |file_header|
12✔
467
          case file_header.getChangeType
2,020✔
468
          when ADD
469
            add(file_header.getNewPath, apply('', file_header))
2✔
470
          when MODIFY
471
            add(file_header.getOldPath, apply(getData(file_header.getOldPath), file_header))
64✔
472
          when DELETE
473
            delete(file_header.getOldPath)
1,952✔
474
          when RENAME
475
            delete(file_header.getOldPath)
2✔
476
            add(file_header.getNewPath, getData(file_header.getOldPath))
2✔
477
          when COPY
478
            add(file_header.getNewPath, getData(file_header.getOldPath))
×
479
          end
480
        end
481
        @treemap
8✔
482
      end
483
      
484
      # Build the new tree based on the patch, but don't commit it
485
      # Return the String object id of the new tree, and an Array of affected paths
486
      def new_tree
2✔
487
        map = build_map
2✔
488
        return ObjectId.to_string(build_new_tree(map, @ref)), map.keys
2✔
489
      end
490

491
      private
2✔
492

493
      def raise_patch_apply_error
2✔
494
        raise ::RJGit::PatchApplyException.new('Patch failed to apply')
6✔
495
      end
496

497
      def getData(path)
2✔
498
        begin
33✔
499
          (@current_tree / path).data
66✔
500
        rescue NoMethodError
501
          raise_patch_apply_error
2✔
502
        end
503
      end
504

505
      def hunk_sanity_check(hunk, hunk_line, pos, newLines)
2✔
506
        raise_patch_apply_error unless newLines[hunk.getNewStartLine - 1 + pos] == hunk_line[1..-1]
1,734✔
507
      end
508

509
      def apply(original, file_header)
2✔
510
        newLines = original.lines
64✔
511
        file_header.getHunks.each do |hunk|
64✔
512
          length = hunk.getEndOffset - hunk.getStartOffset
124✔
513
          buffer_text = hunk.getBuffer.to_s.slice(hunk.getStartOffset, length)
124✔
514
          pos = 0
124✔
515
          buffer_text.each_line do |hunk_line|
124✔
516
            case hunk_line[0]
2,372✔
517
              when ' '
518
                hunk_sanity_check(hunk, hunk_line, pos, newLines)
902✔
519
                pos += 1
900✔
520
              when '-'
521
                if hunk.getNewStartLine == 0
832✔
522
                  newLines = []
×
523
                else
524
                  hunk_sanity_check(hunk, hunk_line, pos, newLines)
832✔
525
                  newLines.slice!(hunk.getNewStartLine - 1 + pos)
832✔
526
                end
527
              when '+'
528
                newLines.insert(hunk.getNewStartLine - 1 + pos, hunk_line[1..-1])
490✔
529
                pos += 1
490✔
530
            end
531
          end
532
        end
533
        newLines.join
62✔
534
      end
535
    end
536

537
  end
538

539
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc