git-pkgs 0.7.0 → 0.9.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.
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Commands
8
+ class Outdated
9
+ include Output
10
+
11
+ def self.description
12
+ "Show packages with newer versions available"
13
+ end
14
+
15
+ def initialize(args)
16
+ @args = args.dup
17
+ @options = parse_options
18
+ end
19
+
20
+ def parse_options
21
+ options = {}
22
+
23
+ parser = OptionParser.new do |opts|
24
+ opts.banner = "Usage: git pkgs outdated [options]"
25
+ opts.separator ""
26
+ opts.separator "Show packages that have newer versions available in their registries."
27
+ opts.separator ""
28
+ opts.separator "Options:"
29
+
30
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
31
+ options[:ecosystem] = v
32
+ end
33
+
34
+ opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v|
35
+ options[:ref] = v
36
+ end
37
+
38
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
39
+ options[:format] = v
40
+ end
41
+
42
+ opts.on("--major", "Show only major version updates") do
43
+ options[:major_only] = true
44
+ end
45
+
46
+ opts.on("--minor", "Show only minor or major updates (skip patch)") do
47
+ options[:minor_only] = true
48
+ end
49
+
50
+ opts.on("--stateless", "Parse manifests directly without database") do
51
+ options[:stateless] = true
52
+ end
53
+
54
+ opts.on("-h", "--help", "Show this help") do
55
+ puts opts
56
+ exit
57
+ end
58
+ end
59
+
60
+ parser.parse!(@args)
61
+ options
62
+ end
63
+
64
+ def run
65
+ repo = Repository.new
66
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
67
+
68
+ if use_stateless
69
+ Database.connect_memory
70
+ deps = get_dependencies_stateless(repo)
71
+ else
72
+ Database.connect(repo.git_dir)
73
+ deps = get_dependencies_with_database(repo)
74
+ end
75
+
76
+ if deps.empty?
77
+ empty_result "No dependencies found"
78
+ return
79
+ end
80
+
81
+ if @options[:ecosystem]
82
+ deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
83
+ end
84
+
85
+ deps_with_versions = Analyzer.lockfile_dependencies(deps).select do |dep|
86
+ dep[:requirement] && !dep[:requirement].match?(/[<>=~^]/)
87
+ end
88
+
89
+ if deps_with_versions.empty?
90
+ empty_result "No dependencies with pinned versions found"
91
+ return
92
+ end
93
+
94
+ packages_to_check = deps_with_versions.map do |dep|
95
+ purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s
96
+ {
97
+ purl: purl,
98
+ name: dep[:name],
99
+ ecosystem: dep[:ecosystem],
100
+ current_version: dep[:requirement],
101
+ manifest_path: dep[:manifest_path]
102
+ }
103
+ end.uniq { |p| p[:purl] }
104
+
105
+ enrich_packages(packages_to_check.map { |p| p[:purl] })
106
+
107
+ outdated = []
108
+ packages_to_check.each do |pkg|
109
+ db_pkg = Models::Package.first(purl: pkg[:purl])
110
+ next unless db_pkg&.latest_version
111
+
112
+ latest = db_pkg.latest_version
113
+ current = pkg[:current_version]
114
+
115
+ next if current == latest
116
+
117
+ update_type = classify_update(current, latest)
118
+ next if @options[:major_only] && update_type != :major
119
+ next if @options[:minor_only] && update_type == :patch
120
+
121
+ outdated << pkg.merge(
122
+ latest_version: latest,
123
+ update_type: update_type
124
+ )
125
+ end
126
+
127
+ if outdated.empty?
128
+ puts "All packages are up to date"
129
+ return
130
+ end
131
+
132
+ type_order = { major: 0, minor: 1, patch: 2, unknown: 3 }
133
+ outdated.sort_by! { |o| [type_order[o[:update_type]], o[:name]] }
134
+
135
+ if @options[:format] == "json"
136
+ require "json"
137
+ puts JSON.pretty_generate(outdated)
138
+ else
139
+ output_text(outdated)
140
+ end
141
+ end
142
+
143
+ def enrich_packages(purls)
144
+ packages_by_purl = {}
145
+ purls.each do |purl|
146
+ parsed = Purl::PackageURL.parse(purl)
147
+ ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
148
+ pkg = Models::Package.find_or_create_by_purl(
149
+ purl: purl,
150
+ ecosystem: ecosystem,
151
+ name: parsed.name
152
+ )
153
+ packages_by_purl[purl] = pkg
154
+ end
155
+
156
+ stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
157
+ return if stale_purls.empty?
158
+
159
+ client = EcosystemsClient.new
160
+ begin
161
+ results = Spinner.with_spinner("Fetching package metadata...") do
162
+ client.bulk_lookup(stale_purls)
163
+ end
164
+ results.each do |purl, data|
165
+ packages_by_purl[purl]&.enrich_from_api(data)
166
+ end
167
+ rescue EcosystemsClient::ApiError => e
168
+ $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
169
+ end
170
+ end
171
+
172
+ def classify_update(current, latest)
173
+ current_parts = parse_version(current)
174
+ latest_parts = parse_version(latest)
175
+
176
+ return :unknown if current_parts.nil? || latest_parts.nil?
177
+
178
+ if latest_parts[0] > current_parts[0]
179
+ :major
180
+ elsif latest_parts[1] > current_parts[1]
181
+ :minor
182
+ elsif latest_parts[2] > current_parts[2]
183
+ :patch
184
+ else
185
+ :unknown
186
+ end
187
+ end
188
+
189
+ def parse_version(version)
190
+ cleaned = version.to_s.sub(/^v/i, "")
191
+ parts = cleaned.split(".").first(3).map { |p| p.to_i }
192
+ return nil if parts.empty?
193
+
194
+ parts + [0] * (3 - parts.length)
195
+ end
196
+
197
+ def output_text(outdated)
198
+ max_name = outdated.map { |o| o[:name].length }.max || 20
199
+ max_current = outdated.map { |o| o[:current_version].length }.max || 10
200
+ max_latest = outdated.map { |o| o[:latest_version].length }.max || 10
201
+
202
+ outdated.each do |pkg|
203
+ name = pkg[:name].ljust(max_name)
204
+ current = pkg[:current_version].ljust(max_current)
205
+ latest = pkg[:latest_version].ljust(max_latest)
206
+ update = pkg[:update_type].to_s
207
+
208
+ line = "#{name} #{current} -> #{latest} (#{update})"
209
+
210
+ colored = case pkg[:update_type]
211
+ when :major then Color.red(line)
212
+ when :minor then Color.yellow(line)
213
+ when :patch then Color.cyan(line)
214
+ else line
215
+ end
216
+
217
+ puts colored
218
+ end
219
+
220
+ puts ""
221
+ summary = "#{outdated.size} outdated package#{"s" if outdated.size != 1}"
222
+ by_type = outdated.group_by { |o| o[:update_type] }
223
+ parts = []
224
+ parts << "#{by_type[:major].size} major" if by_type[:major]&.any?
225
+ parts << "#{by_type[:minor].size} minor" if by_type[:minor]&.any?
226
+ parts << "#{by_type[:patch].size} patch" if by_type[:patch]&.any?
227
+ puts "#{summary}: #{parts.join(", ")}" if parts.any?
228
+ end
229
+
230
+ def get_dependencies_stateless(repo)
231
+ ref = @options[:ref] || "HEAD"
232
+ commit_sha = repo.rev_parse(ref)
233
+ rugged_commit = repo.lookup(commit_sha)
234
+
235
+ error "Could not resolve '#{ref}'" unless rugged_commit
236
+
237
+ analyzer = Analyzer.new(repo)
238
+ analyzer.dependencies_at_commit(rugged_commit)
239
+ end
240
+
241
+ def get_dependencies_with_database(repo)
242
+ ref = @options[:ref] || "HEAD"
243
+ commit_sha = repo.rev_parse(ref)
244
+ target_commit = Models::Commit.first(sha: commit_sha)
245
+
246
+ return get_dependencies_stateless(repo) unless target_commit
247
+
248
+ branch_name = repo.default_branch
249
+ branch = Models::Branch.first(name: branch_name)
250
+ return [] unless branch
251
+
252
+ compute_dependencies_at_commit(target_commit, branch)
253
+ end
254
+
255
+ def compute_dependencies_at_commit(target_commit, branch)
256
+ snapshot_commit = branch.commits_dataset
257
+ .join(:dependency_snapshots, commit_id: :id)
258
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
259
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
260
+ .distinct
261
+ .first
262
+
263
+ deps = {}
264
+ if snapshot_commit
265
+ snapshot_commit.dependency_snapshots.each do |s|
266
+ key = [s.manifest.path, s.name]
267
+ deps[key] = {
268
+ manifest_path: s.manifest.path,
269
+ manifest_kind: s.manifest.kind,
270
+ name: s.name,
271
+ ecosystem: s.ecosystem,
272
+ requirement: s.requirement,
273
+ dependency_type: s.dependency_type
274
+ }
275
+ end
276
+ end
277
+
278
+ if snapshot_commit && snapshot_commit.id != target_commit.id
279
+ commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
280
+ changes = Models::DependencyChange
281
+ .join(:commits, id: :commit_id)
282
+ .where(Sequel[:commits][:id] => commit_ids)
283
+ .where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
284
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
285
+ .order(Sequel[:commits][:committed_at])
286
+ .eager(:manifest)
287
+ .all
288
+
289
+ changes.each do |change|
290
+ key = [change.manifest.path, change.name]
291
+ case change.change_type
292
+ when "added", "modified"
293
+ deps[key] = {
294
+ manifest_path: change.manifest.path,
295
+ manifest_kind: change.manifest.kind,
296
+ name: change.name,
297
+ ecosystem: change.ecosystem,
298
+ requirement: change.requirement,
299
+ dependency_type: change.dependency_type
300
+ }
301
+ when "removed"
302
+ deps.delete(key)
303
+ end
304
+ end
305
+ end
306
+
307
+ deps.values
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "sbom"
5
+
6
+ module Git
7
+ module Pkgs
8
+ module Commands
9
+ class Sbom
10
+ include Output
11
+
12
+ def self.description
13
+ "Export dependencies as SBOM (SPDX or CycloneDX)"
14
+ end
15
+
16
+ def initialize(args)
17
+ @args = args.dup
18
+ @options = parse_options
19
+ end
20
+
21
+ def run
22
+ repo = Repository.new
23
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
24
+
25
+ if use_stateless
26
+ Database.connect_memory
27
+ deps = get_dependencies_stateless(repo)
28
+ else
29
+ Database.connect(repo.git_dir)
30
+ deps = get_dependencies_with_database(repo)
31
+ end
32
+
33
+ if deps.empty?
34
+ empty_result "No dependencies found"
35
+ return
36
+ end
37
+
38
+ if @options[:ecosystem]
39
+ deps = deps.select { |d| d[:ecosystem].downcase == @options[:ecosystem].downcase }
40
+ end
41
+
42
+ deps = Analyzer.pair_manifests_with_lockfiles(deps)
43
+
44
+ if deps.empty?
45
+ empty_result "No dependencies found"
46
+ return
47
+ end
48
+
49
+ packages = build_packages(deps)
50
+ enrich_packages(packages) unless @options[:skip_enrichment]
51
+
52
+ output_sbom(repo, packages)
53
+ end
54
+
55
+ def build_packages(deps)
56
+ deps.map do |dep|
57
+ purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:requirement])
58
+ {
59
+ purl: purl.to_s,
60
+ name: dep[:name],
61
+ ecosystem: dep[:ecosystem],
62
+ version: dep[:requirement],
63
+ integrity: dep[:integrity]
64
+ }
65
+ end.uniq { |p| p[:purl] }
66
+ end
67
+
68
+ def enrich_packages(packages)
69
+ client = EcosystemsClient.new
70
+
71
+ # Enrich package-level data (license, latest version)
72
+ base_purls = packages.map { |p| PurlHelper.build_purl(ecosystem: p[:ecosystem], name: p[:name]).to_s }
73
+
74
+ packages_by_purl = {}
75
+ base_purls.each do |purl|
76
+ parsed = Purl::PackageURL.parse(purl)
77
+ ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
78
+ pkg = Models::Package.find_or_create_by_purl(
79
+ purl: purl,
80
+ ecosystem: ecosystem,
81
+ name: parsed.name
82
+ )
83
+ packages_by_purl[purl] = pkg
84
+ end
85
+
86
+ stale_pkg_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
87
+
88
+ if stale_pkg_purls.any?
89
+ begin
90
+ results = Spinner.with_spinner("Fetching package metadata...") do
91
+ client.bulk_lookup(stale_pkg_purls)
92
+ end
93
+ results.each do |purl, data|
94
+ packages_by_purl[purl]&.enrich_from_api(data)
95
+ end
96
+ rescue EcosystemsClient::ApiError => e
97
+ $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
98
+ end
99
+ end
100
+
101
+ # Enrich version-level data (integrity, published_at)
102
+ versions_by_purl = {}
103
+ packages.each do |pkg|
104
+ base_purl = PurlHelper.build_purl(ecosystem: pkg[:ecosystem], name: pkg[:name]).to_s
105
+ version = Models::Version.find_or_create_by_purl(
106
+ purl: pkg[:purl],
107
+ package_purl: base_purl
108
+ )
109
+ versions_by_purl[pkg[:purl]] = version
110
+ end
111
+
112
+ stale_version_purls = versions_by_purl.select { |_, v| v.needs_enrichment? }.keys
113
+
114
+ if stale_version_purls.any?
115
+ begin
116
+ Spinner.with_spinner("Fetching version metadata...") do
117
+ stale_version_purls.each do |purl|
118
+ data = client.lookup_version(purl)
119
+ versions_by_purl[purl]&.enrich_from_api(data) if data
120
+ end
121
+ end
122
+ rescue EcosystemsClient::ApiError => e
123
+ $stderr.puts "Warning: Could not fetch version data: #{e.message}" unless Git::Pkgs.quiet
124
+ end
125
+ end
126
+
127
+ # Apply enriched data to packages
128
+ packages.each do |pkg|
129
+ base_purl = PurlHelper.build_purl(ecosystem: pkg[:ecosystem], name: pkg[:name]).to_s
130
+ db_pkg = packages_by_purl[base_purl]
131
+ db_version = versions_by_purl[pkg[:purl]]
132
+
133
+ pkg[:license] ||= db_version&.license || db_pkg&.license
134
+ pkg[:integrity] ||= db_version&.integrity
135
+ pkg[:supplier_name] ||= db_pkg&.supplier_name
136
+ pkg[:supplier_type] ||= db_pkg&.supplier_type
137
+ end
138
+ end
139
+
140
+ def output_sbom(repo, packages)
141
+ sbom_type = @options[:type]&.to_sym || :cyclonedx
142
+ format = @options[:format]&.to_sym || :json
143
+
144
+ generator = ::Sbom::Generator.new(sbom_type: sbom_type, format: format)
145
+
146
+ sbom_packages = packages.map do |pkg|
147
+ sbom_pkg = ::Sbom::Data::Package.new
148
+ sbom_pkg.name = pkg[:name]
149
+ sbom_pkg.version = pkg[:version]
150
+ sbom_pkg.purl = pkg[:purl]
151
+ sbom_pkg.license_concluded = pkg[:license] if pkg[:license]
152
+
153
+ if pkg[:supplier_name]
154
+ sbom_pkg.set_supplier(pkg[:supplier_type] || "organization", pkg[:supplier_name])
155
+ end
156
+
157
+ if pkg[:integrity]
158
+ algorithm, hash = parse_integrity(pkg[:integrity])
159
+ sbom_pkg.add_checksum(algorithm, hash) if algorithm && hash
160
+ end
161
+
162
+ sbom_pkg
163
+ end
164
+
165
+ project_name = @options[:name] || File.basename(repo.path)
166
+ generator.generate(project_name, { packages: sbom_packages })
167
+ puts generator.output
168
+ end
169
+
170
+ def parse_integrity(integrity)
171
+ return nil unless integrity
172
+
173
+ case integrity
174
+ when /^sha256[-:=](.+)$/i
175
+ ["SHA256", $1]
176
+ when /^sha512[-:=](.+)$/i
177
+ ["SHA512", $1]
178
+ when /^sha1[-:=](.+)$/i
179
+ ["SHA1", $1]
180
+ when /^md5[-:=](.+)$/i
181
+ ["MD5", $1]
182
+ when /^h1:(.+)$/
183
+ # Go modules use base64-encoded SHA256 in go.sum
184
+ # SPDX/CycloneDX require hex, so convert
185
+ require "base64"
186
+ hex = Base64.decode64($1).unpack1("H*")
187
+ ["SHA256", hex]
188
+ else
189
+ nil
190
+ end
191
+ end
192
+
193
+ def parse_options
194
+ options = {}
195
+
196
+ parser = OptionParser.new do |opts|
197
+ opts.banner = "Usage: git pkgs sbom [options]"
198
+ opts.separator ""
199
+ opts.separator "Export dependencies as SBOM (Software Bill of Materials)."
200
+ opts.separator ""
201
+ opts.separator "Options:"
202
+
203
+ opts.on("-t", "--type=TYPE", "SBOM type: cyclonedx (default) or spdx") do |v|
204
+ options[:type] = v.downcase
205
+ end
206
+
207
+ opts.on("-f", "--format=FORMAT", "Output format: json (default) or xml") do |v|
208
+ options[:format] = v.downcase
209
+ end
210
+
211
+ opts.on("-n", "--name=NAME", "Project name (default: repository directory name)") do |v|
212
+ options[:name] = v
213
+ end
214
+
215
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
216
+ options[:ecosystem] = v
217
+ end
218
+
219
+ opts.on("-r", "--ref=REF", "Git ref to export (default: HEAD)") do |v|
220
+ options[:ref] = v
221
+ end
222
+
223
+ opts.on("--skip-enrichment", "Skip fetching license data from registries") do
224
+ options[:skip_enrichment] = true
225
+ end
226
+
227
+ opts.on("--stateless", "Parse manifests directly without database") do
228
+ options[:stateless] = true
229
+ end
230
+
231
+ opts.on("-h", "--help", "Show this help") do
232
+ puts opts
233
+ exit
234
+ end
235
+ end
236
+
237
+ parser.parse!(@args)
238
+ options
239
+ end
240
+
241
+ def get_dependencies_stateless(repo)
242
+ ref = @options[:ref] || "HEAD"
243
+ commit_sha = repo.rev_parse(ref)
244
+ rugged_commit = repo.lookup(commit_sha)
245
+
246
+ error "Could not resolve '#{ref}'" unless rugged_commit
247
+
248
+ analyzer = Analyzer.new(repo)
249
+ analyzer.dependencies_at_commit(rugged_commit)
250
+ end
251
+
252
+ def get_dependencies_with_database(repo)
253
+ ref = @options[:ref] || "HEAD"
254
+ commit_sha = repo.rev_parse(ref)
255
+ target_commit = Models::Commit.first(sha: commit_sha)
256
+
257
+ return get_dependencies_stateless(repo) unless target_commit
258
+
259
+ branch_name = repo.default_branch
260
+ branch = Models::Branch.first(name: branch_name)
261
+ return [] unless branch
262
+
263
+ compute_dependencies_at_commit(target_commit, branch)
264
+ end
265
+
266
+ def compute_dependencies_at_commit(target_commit, branch)
267
+ snapshot_commit = branch.commits_dataset
268
+ .join(:dependency_snapshots, commit_id: :id)
269
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
270
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
271
+ .distinct
272
+ .first
273
+
274
+ deps = {}
275
+ if snapshot_commit
276
+ snapshot_commit.dependency_snapshots.each do |s|
277
+ key = [s.manifest.path, s.name]
278
+ deps[key] = {
279
+ manifest_path: s.manifest.path,
280
+ manifest_kind: s.manifest.kind,
281
+ name: s.name,
282
+ ecosystem: s.ecosystem,
283
+ requirement: s.requirement,
284
+ dependency_type: s.dependency_type,
285
+ integrity: s.integrity
286
+ }
287
+ end
288
+ end
289
+
290
+ if snapshot_commit && snapshot_commit.id != target_commit.id
291
+ commit_ids = branch.commits_dataset.select_map(Sequel[:commits][:id])
292
+ changes = Models::DependencyChange
293
+ .join(:commits, id: :commit_id)
294
+ .where(Sequel[:commits][:id] => commit_ids)
295
+ .where { Sequel[:commits][:committed_at] > snapshot_commit.committed_at }
296
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
297
+ .order(Sequel[:commits][:committed_at])
298
+ .eager(:manifest)
299
+ .all
300
+
301
+ changes.each do |change|
302
+ key = [change.manifest.path, change.name]
303
+ case change.change_type
304
+ when "added", "modified"
305
+ deps[key] = {
306
+ manifest_path: change.manifest.path,
307
+ manifest_kind: change.manifest.kind,
308
+ name: change.name,
309
+ ecosystem: change.ecosystem,
310
+ requirement: change.requirement,
311
+ dependency_type: change.dependency_type,
312
+ integrity: nil
313
+ }
314
+ when "removed"
315
+ deps.delete(key)
316
+ end
317
+ end
318
+ end
319
+
320
+ deps.values
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
@@ -43,7 +43,8 @@ module Git
43
43
  ecosystem: s.ecosystem,
44
44
  purl: s.purl,
45
45
  requirement: s.requirement,
46
- dependency_type: s.dependency_type
46
+ dependency_type: s.dependency_type,
47
+ integrity: s.integrity
47
48
  }
48
49
  end
49
50
  end
@@ -110,6 +111,7 @@ module Git
110
111
  s.purl = dep_info[:purl]
111
112
  s.requirement = dep_info[:requirement]
112
113
  s.dependency_type = dep_info[:dependency_type]
114
+ s.integrity = dep_info[:integrity]
113
115
  end
114
116
  end
115
117
  end
@@ -143,7 +143,9 @@ module Git
143
143
 
144
144
  client = OsvClient.new
145
145
  results = begin
146
- client.query_batch(packages.map { |p| p.slice(:ecosystem, :name, :version) })
146
+ Spinner.with_spinner("Checking vulnerabilities...") do
147
+ client.query_batch(packages.map { |p| p.slice(:ecosystem, :name, :version) })
148
+ end
147
149
  rescue OsvClient::ApiError => e
148
150
  error "Failed to query OSV API: #{e.message}"
149
151
  end
@@ -176,20 +178,22 @@ module Git
176
178
  return if packages_to_sync.empty?
177
179
 
178
180
  client = OsvClient.new
179
- packages_to_sync.each_slice(100) do |batch|
180
- queries = batch.map do |pkg|
181
- osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
182
- next unless osv_ecosystem
181
+ Spinner.with_spinner("Syncing vulnerability data...") do
182
+ packages_to_sync.each_slice(100) do |batch|
183
+ queries = batch.map do |pkg|
184
+ osv_ecosystem = Ecosystems.to_osv(pkg.ecosystem)
185
+ next unless osv_ecosystem
183
186
 
184
- { ecosystem: osv_ecosystem, name: pkg.name }
185
- end.compact
187
+ { ecosystem: osv_ecosystem, name: pkg.name }
188
+ end.compact
186
189
 
187
- results = client.query_batch(queries)
188
- fetch_vulnerability_details(client, results)
190
+ results = client.query_batch(queries)
191
+ fetch_vulnerability_details(client, results)
189
192
 
190
- batch.each do |pkg|
191
- purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name)
192
- mark_package_synced(purl, pkg.ecosystem, pkg.name) if purl
193
+ batch.each do |pkg|
194
+ purl = Ecosystems.generate_purl(pkg.ecosystem, pkg.name)
195
+ mark_package_synced(purl, pkg.ecosystem, pkg.name) if purl
196
+ end
193
197
  end
194
198
  end
195
199
  end