git-pkgs 0.8.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e23d09227a873e67670fa403a6c81c4f93a301cfda036f62d8b02d4d7f1e0ce5
4
- data.tar.gz: ae4c878fa0cca631cb5d0485ab4a5caa999300ba1afc564c6d23461527a487cf
3
+ metadata.gz: 3bd1b968e2bc7ed2d3ecbc8a750925afdea362ca1d0a01f6d69a6bf5aef58b02
4
+ data.tar.gz: 1deed6e6742a7977585c97db65fe13e649e4b5e3b6a0f9674f5352d977dc8fcf
5
5
  SHA512:
6
- metadata.gz: 5a675b67669d345dc39419219adfe51d2c36b1167be446b7f1b937eab42b92c0d935ebd8102e534585760c5944de6059ac722818558090c504758fc4ca7d9388
7
- data.tar.gz: 7cb84ae314e29ed3cbf0c360ddcd79f43174ce153fca35af476693a6f37a98c84e297b8b3bf3543150e546df2459e69fbcae371959cd4dac04d2aa7d139f1969
6
+ metadata.gz: 5d7ed904f5e7b3fc0b7a7054d596dfe59d1918f30d493ac5613cea125c41f9b38ab22daab2c84a5536b55030ca407334b01e6d0bd79bc828a371abfdccf5a16d
7
+ data.tar.gz: e1170c4c9eeb345730e9ab614536c6431f1c56ee9bafc8bdff111e7df3bf8f577149ba1f94a77787ace083ee401b2980978148f76e85d16c5a3a1101902a2a08
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.9.0] - 2026-01-14
4
+
5
+ - `git pkgs sbom` command to export dependencies as SPDX or CycloneDX
6
+ - `git pkgs integrity` command to show and verify lockfile integrity hashes
7
+ - Parse go.sum for Go module integrity hashes (no longer ignored)
8
+ - Convert Go h1: hashes (base64) to hex for SBOM compatibility
9
+ - `--drift` flag to detect packages with different hashes for the same version
10
+ - Registry integrity comparison via ecosyste.ms API
11
+ - Store integrity hashes from lockfiles in dependency_snapshots table
12
+ - SBOM export includes supplier info from ecosyste.ms (owner/maintainer)
13
+ - License commands use version-level license data when available
14
+ - Store supplier_name and supplier_type on packages (schema v5, run `git pkgs upgrade`)
15
+ - Update ecosystems-bibliothecary to ~> 15.3 (integrity extraction from lockfiles)
16
+ - Update purl to >= 1.7.1 (ecosyste.ms API URL support)
17
+
3
18
  ## [0.8.0] - 2026-01-14
4
19
 
5
20
  - `git pkgs outdated` command to find dependencies with newer versions available in registries
data/Formula/git-pkgs.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  class GitPkgs < Formula
2
2
  desc "Track package dependencies across git history"
3
3
  homepage "https://github.com/andrew/git-pkgs"
4
- url "https://github.com/andrew/git-pkgs/archive/refs/tags/v0.7.0.tar.gz"
5
- sha256 "5c5aebf75e9570945b324777e5fa33cd5e35d31f6172c2415a4bd91db02477cc"
4
+ url "https://github.com/andrew/git-pkgs/archive/refs/tags/v0.8.0.tar.gz"
5
+ sha256 "b2e8ebfefc86fd137fb76225934c0fe2a1e01b2fcaac88a91f1134eecc4e9e71"
6
6
  license "AGPL-3.0"
7
7
 
8
8
  depends_on "cmake" => :build
data/README.md CHANGED
@@ -322,6 +322,33 @@ Output formats: `text` (default), `json`, and `sarif`. SARIF integrates with Git
322
322
 
323
323
  Vulnerability data is cached locally and refreshed automatically when stale (>24h). Use `git pkgs vulns sync --refresh` to force an update. See [docs/vulns.md](docs/vulns.md) for full documentation.
324
324
 
325
+ ### Integrity verification
326
+
327
+ Show SHA256 hashes from lockfiles. Modern lockfiles include checksums that verify package contents haven't been tampered with.
328
+
329
+ ```bash
330
+ git pkgs integrity # show hashes for current dependencies
331
+ git pkgs integrity --drift # detect same version with different hashes
332
+ git pkgs integrity -f json # JSON output
333
+ git pkgs integrity --stateless # no database needed
334
+ ```
335
+
336
+ The `--drift` flag scans your history for packages where the same version has different integrity hashes, which could indicate a supply chain issue.
337
+
338
+ ### SBOM export
339
+
340
+ Export dependencies as a Software Bill of Materials in CycloneDX or SPDX format:
341
+
342
+ ```bash
343
+ git pkgs sbom # CycloneDX JSON (default)
344
+ git pkgs sbom --type spdx # SPDX JSON
345
+ git pkgs sbom -f xml # XML instead of JSON
346
+ git pkgs sbom --name my-project # custom project name
347
+ git pkgs sbom --stateless # no database needed
348
+ ```
349
+
350
+ Includes package URLs (purls), versions, and licenses (fetched from registries). Use `--skip-enrichment` to omit license lookups.
351
+
325
352
  ### Diff between commits
326
353
 
327
354
  ```bash
@@ -141,7 +141,8 @@ module Git
141
141
  purl: generate_purl(result[:platform], dep[:name]),
142
142
  change_type: "added",
143
143
  requirement: dep[:requirement],
144
- dependency_type: dep[:type]
144
+ dependency_type: dep[:type],
145
+ integrity: dep[:integrity]
145
146
  }
146
147
 
147
148
  key = [manifest_path, dep[:name]]
@@ -150,7 +151,8 @@ module Git
150
151
  kind: result[:kind],
151
152
  purl: generate_purl(result[:platform], dep[:name]),
152
153
  requirement: dep[:requirement],
153
- dependency_type: dep[:type]
154
+ dependency_type: dep[:type],
155
+ integrity: dep[:integrity]
154
156
  }
155
157
  end
156
158
  end
@@ -179,7 +181,8 @@ module Git
179
181
  purl: generate_purl(after_result[:platform], name),
180
182
  change_type: "added",
181
183
  requirement: dep[:requirement],
182
- dependency_type: dep[:type]
184
+ dependency_type: dep[:type],
185
+ integrity: dep[:integrity]
183
186
  }
184
187
 
185
188
  key = [manifest_path, name]
@@ -188,7 +191,8 @@ module Git
188
191
  kind: after_result[:kind],
189
192
  purl: generate_purl(after_result[:platform], name),
190
193
  requirement: dep[:requirement],
191
- dependency_type: dep[:type]
194
+ dependency_type: dep[:type],
195
+ integrity: dep[:integrity]
192
196
  }
193
197
  end
194
198
 
@@ -202,7 +206,8 @@ module Git
202
206
  purl: generate_purl(before_result[:platform], name),
203
207
  change_type: "removed",
204
208
  requirement: dep[:requirement],
205
- dependency_type: dep[:type]
209
+ dependency_type: dep[:type],
210
+ integrity: dep[:integrity]
206
211
  }
207
212
 
208
213
  key = [manifest_path, name]
@@ -223,7 +228,8 @@ module Git
223
228
  change_type: "modified",
224
229
  requirement: after_dep[:requirement],
225
230
  previous_requirement: before_dep[:requirement],
226
- dependency_type: after_dep[:type]
231
+ dependency_type: after_dep[:type],
232
+ integrity: after_dep[:integrity]
227
233
  }
228
234
 
229
235
  key = [manifest_path, name]
@@ -232,7 +238,8 @@ module Git
232
238
  kind: after_result[:kind],
233
239
  purl: generate_purl(after_result[:platform], name),
234
240
  requirement: after_dep[:requirement],
235
- dependency_type: after_dep[:type]
241
+ dependency_type: after_dep[:type],
242
+ integrity: after_dep[:integrity]
236
243
  }
237
244
  end
238
245
  end
@@ -252,7 +259,8 @@ module Git
252
259
  purl: generate_purl(result[:platform], dep[:name]),
253
260
  change_type: "removed",
254
261
  requirement: dep[:requirement],
255
- dependency_type: dep[:type]
262
+ dependency_type: dep[:type],
263
+ integrity: dep[:integrity]
256
264
  }
257
265
 
258
266
  key = [manifest_path, dep[:name]]
@@ -329,7 +337,8 @@ module Git
329
337
  kind: result[:kind],
330
338
  purl: generate_purl(result[:platform], dep[:name]),
331
339
  requirement: dep[:requirement],
332
- dependency_type: dep[:type]
340
+ dependency_type: dep[:type],
341
+ integrity: dep[:integrity]
333
342
  }
334
343
  end
335
344
  end
data/lib/git/pkgs/cli.rb CHANGED
@@ -35,7 +35,9 @@ module Git
35
35
  "stats" => "Show dependency statistics",
36
36
  "stale" => "Show dependencies that haven't been updated",
37
37
  "outdated" => "Show packages with newer versions available",
38
- "licenses" => "Show licenses for dependencies"
38
+ "licenses" => "Show licenses for dependencies",
39
+ "integrity" => "Show and verify lockfile integrity hashes",
40
+ "sbom" => "Export dependencies as SBOM (SPDX or CycloneDX)"
39
41
  },
40
42
  "Security" => {
41
43
  "vulns" => "Scan for known vulnerabilities"
@@ -191,6 +191,7 @@ module Git
191
191
  ecosystem: s[:ecosystem],
192
192
  requirement: s[:requirement],
193
193
  dependency_type: s[:dependency_type],
194
+ integrity: s[:integrity],
194
195
  created_at: now,
195
196
  updated_at: now
196
197
  }
@@ -266,7 +267,8 @@ module Git
266
267
  name: name,
267
268
  ecosystem: dep_info[:ecosystem],
268
269
  requirement: dep_info[:requirement],
269
- dependency_type: dep_info[:dependency_type]
270
+ dependency_type: dep_info[:dependency_type],
271
+ integrity: dep_info[:integrity]
270
272
  }
271
273
  end
272
274
  snapshots_stored += snapshot.size
@@ -286,7 +288,8 @@ module Git
286
288
  name: name,
287
289
  ecosystem: dep_info[:ecosystem],
288
290
  requirement: dep_info[:requirement],
289
- dependency_type: dep_info[:dependency_type]
291
+ dependency_type: dep_info[:dependency_type],
292
+ integrity: dep_info[:integrity]
290
293
  }
291
294
  end
292
295
  snapshots_stored += snapshot.size
@@ -125,6 +125,7 @@ module Git
125
125
  purl: s[:purl],
126
126
  requirement: s[:requirement],
127
127
  dependency_type: s[:dependency_type],
128
+ integrity: s[:integrity],
128
129
  created_at: now,
129
130
  updated_at: now
130
131
  }
@@ -207,7 +208,8 @@ module Git
207
208
  ecosystem: dep_info[:ecosystem],
208
209
  purl: dep_info[:purl],
209
210
  requirement: dep_info[:requirement],
210
- dependency_type: dep_info[:dependency_type]
211
+ dependency_type: dep_info[:dependency_type],
212
+ integrity: dep_info[:integrity]
211
213
  }
212
214
  end
213
215
  snapshots_stored += snapshot.size
@@ -228,7 +230,8 @@ module Git
228
230
  ecosystem: dep_info[:ecosystem],
229
231
  purl: dep_info[:purl],
230
232
  requirement: dep_info[:requirement],
231
- dependency_type: dep_info[:dependency_type]
233
+ dependency_type: dep_info[:dependency_type],
234
+ integrity: dep_info[:integrity]
232
235
  }
233
236
  end
234
237
  snapshots_stored += snapshot.size
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Integrity
7
+ include Output
8
+
9
+ def self.description
10
+ "Show and verify lockfile integrity hashes"
11
+ end
12
+
13
+ def initialize(args)
14
+ @args = args
15
+ @options = parse_options
16
+ end
17
+
18
+ def run
19
+ repo = Repository.new
20
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
21
+
22
+ if @options[:drift]
23
+ error "--drift requires database (run 'git pkgs init' first)" if use_stateless
24
+ run_drift_detection(repo)
25
+ else
26
+ run_show(repo, use_stateless)
27
+ end
28
+ end
29
+
30
+ def run_show(repo, use_stateless)
31
+ if use_stateless
32
+ deps = run_stateless(repo)
33
+ else
34
+ deps = run_with_database(repo)
35
+ end
36
+
37
+ # Filter to only lockfile deps with integrity
38
+ deps = Analyzer.lockfile_dependencies(deps)
39
+ deps = deps.select { |d| d[:integrity] }
40
+
41
+ if @options[:ecosystem]
42
+ deps = deps.select { |d| d[:ecosystem] == @options[:ecosystem] }
43
+ end
44
+
45
+ if deps.empty?
46
+ empty_result "No dependencies with integrity hashes found"
47
+ return
48
+ end
49
+
50
+ if @options[:format] == "json"
51
+ require "json"
52
+ puts JSON.pretty_generate(deps.map { |d| format_dep_json(d) })
53
+ else
54
+ paginate { output_text(deps) }
55
+ end
56
+ end
57
+
58
+ def run_stateless(repo)
59
+ commit_sha = @options[:ref] || repo.head_sha
60
+ rugged_commit = repo.lookup(repo.rev_parse(commit_sha))
61
+ error "Could not resolve '#{commit_sha}'" unless rugged_commit
62
+
63
+ analyzer = Analyzer.new(repo)
64
+ analyzer.dependencies_at_commit(rugged_commit)
65
+ end
66
+
67
+ def run_with_database(repo)
68
+ Database.connect(repo.git_dir)
69
+
70
+ commit_sha = @options[:ref] || repo.head_sha
71
+ target_commit = Models::Commit.first(sha: commit_sha)
72
+ error "Commit not in database. Run 'git pkgs update' first." unless target_commit
73
+
74
+ compute_dependencies_at_commit(target_commit, repo)
75
+ end
76
+
77
+ def run_drift_detection(repo)
78
+ Database.connect(repo.git_dir)
79
+
80
+ # Get unique (purl, requirement, integrity) from snapshots
81
+ results = Database.db[:dependency_snapshots]
82
+ .exclude(integrity: nil)
83
+ .select(:purl, :requirement, :integrity)
84
+ .distinct
85
+ .all
86
+
87
+ # Build versioned purls and group
88
+ by_versioned_purl = {}
89
+ results.each do |r|
90
+ versioned_purl = "#{r[:purl]}@#{r[:requirement]}"
91
+ by_versioned_purl[versioned_purl] ||= { purl: r[:purl], version: r[:requirement], lockfile_integrities: [] }
92
+ by_versioned_purl[versioned_purl][:lockfile_integrities] << r[:integrity]
93
+ end
94
+
95
+ # Dedupe lockfile integrities
96
+ by_versioned_purl.each { |_, v| v[:lockfile_integrities].uniq! }
97
+
98
+ # Find internal drift (same version with different lockfile hashes)
99
+ internal_drifts = by_versioned_purl.select { |_, v| v[:lockfile_integrities].size > 1 }
100
+
101
+ # Fetch registry integrity for comparison
102
+ registry_mismatches = []
103
+ purls_to_check = by_versioned_purl.keys
104
+
105
+ if purls_to_check.any?
106
+ Spinner.with_spinner("Fetching registry integrity...") do
107
+ client = EcosystemsClient.new
108
+ purls_to_check.each do |versioned_purl|
109
+ data = by_versioned_purl[versioned_purl]
110
+ version_info = client.lookup_version(versioned_purl)
111
+ next unless version_info && version_info["integrity"]
112
+
113
+ registry_integrity = version_info["integrity"]
114
+ lockfile_integrity = data[:lockfile_integrities].first
115
+
116
+ unless integrity_match?(lockfile_integrity, registry_integrity)
117
+ registry_mismatches << {
118
+ purl: versioned_purl,
119
+ lockfile: lockfile_integrity,
120
+ registry: registry_integrity
121
+ }
122
+ end
123
+ end
124
+ end
125
+ end
126
+
127
+ if internal_drifts.empty? && registry_mismatches.empty?
128
+ info "No integrity drift detected"
129
+ return
130
+ end
131
+
132
+ if @options[:format] == "json"
133
+ require "json"
134
+ output = {
135
+ internal_drift: internal_drifts.map { |purl, v| { purl: purl, integrity_values: v[:lockfile_integrities] } },
136
+ registry_mismatch: registry_mismatches
137
+ }
138
+ puts JSON.pretty_generate(output)
139
+ else
140
+ paginate { output_drift_text(internal_drifts, registry_mismatches) }
141
+ end
142
+ end
143
+
144
+ def integrity_match?(lockfile, registry)
145
+ normalize_integrity(lockfile) == normalize_integrity(registry)
146
+ end
147
+
148
+ def normalize_integrity(integrity)
149
+ return nil unless integrity
150
+ # Normalize sha256= vs sha256- format
151
+ integrity.gsub(/^sha256[-=]/, "sha256:")
152
+ end
153
+
154
+ def output_text(deps)
155
+ grouped = deps.group_by { |d| d[:ecosystem] }
156
+
157
+ grouped.each do |ecosystem, ecosystem_deps|
158
+ puts "#{ecosystem}:"
159
+ ecosystem_deps.sort_by { |d| d[:name] }.each do |dep|
160
+ puts " #{dep[:name]} #{dep[:requirement]}"
161
+ puts " #{dep[:integrity]}"
162
+ end
163
+ puts
164
+ end
165
+ end
166
+
167
+ def output_drift_text(internal_drifts, registry_mismatches)
168
+ if internal_drifts.any?
169
+ puts Color.red("Internal drift (same version, different lockfile hashes):")
170
+ puts
171
+ internal_drifts.each do |purl, data|
172
+ puts " #{purl}"
173
+ data[:lockfile_integrities].each do |integrity|
174
+ puts " #{integrity}"
175
+ end
176
+ puts
177
+ end
178
+ end
179
+
180
+ if registry_mismatches.any?
181
+ puts Color.red("Registry mismatch (lockfile differs from registry):")
182
+ puts
183
+ registry_mismatches.each do |mismatch|
184
+ puts " #{mismatch[:purl]}"
185
+ puts " lockfile: #{mismatch[:lockfile]}"
186
+ puts " registry: #{mismatch[:registry]}"
187
+ puts
188
+ end
189
+ end
190
+
191
+ total = internal_drifts.size + registry_mismatches.size
192
+ puts "#{total} integrity issue(s) found"
193
+ end
194
+
195
+ def format_dep_json(dep)
196
+ {
197
+ name: dep[:name],
198
+ version: dep[:requirement],
199
+ ecosystem: dep[:ecosystem],
200
+ purl: dep[:purl],
201
+ integrity: dep[:integrity],
202
+ manifest: dep[:manifest_path]
203
+ }
204
+ end
205
+
206
+ def compute_dependencies_at_commit(target_commit, repo)
207
+ branch_name = @options[:branch] || repo.default_branch
208
+ branch = Models::Branch.first(name: branch_name)
209
+ return [] unless branch
210
+
211
+ snapshot_commit = branch.commits_dataset
212
+ .join(:dependency_snapshots, commit_id: :id)
213
+ .where { Sequel[:commits][:committed_at] <= target_commit.committed_at }
214
+ .order(Sequel.desc(Sequel[:commits][:committed_at]))
215
+ .distinct
216
+ .first
217
+
218
+ deps = {}
219
+ if snapshot_commit
220
+ snapshot_commit.dependency_snapshots.each do |s|
221
+ key = [s.manifest.path, s.name]
222
+ deps[key] = {
223
+ manifest_path: s.manifest.path,
224
+ name: s.name,
225
+ ecosystem: s.ecosystem,
226
+ kind: s.manifest.kind,
227
+ manifest_kind: s.manifest.kind,
228
+ purl: s.purl,
229
+ requirement: s.requirement,
230
+ dependency_type: s.dependency_type,
231
+ integrity: s.integrity
232
+ }
233
+ end
234
+ end
235
+
236
+ deps.values
237
+ end
238
+
239
+ def parse_options
240
+ options = {}
241
+
242
+ parser = OptionParser.new do |opts|
243
+ opts.banner = "Usage: git pkgs integrity [options]"
244
+ opts.separator ""
245
+ opts.separator "Show integrity hashes from lockfiles. Hashes come from lockfile checksums"
246
+ opts.separator "(Gemfile.lock CHECKSUMS, package-lock.json integrity fields, etc.)"
247
+
248
+ opts.on("-r", "--ref=REF", "Git ref to check (default: HEAD)") do |v|
249
+ options[:ref] = v
250
+ end
251
+
252
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
253
+ options[:ecosystem] = v
254
+ end
255
+
256
+ opts.on("-b", "--branch=NAME", "Branch context for database queries") do |v|
257
+ options[:branch] = v
258
+ end
259
+
260
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
261
+ options[:format] = v
262
+ end
263
+
264
+ opts.on("--drift", "Detect packages with different hashes for same version") do
265
+ options[:drift] = true
266
+ end
267
+
268
+ opts.on("--stateless", "Parse manifests directly without database") do
269
+ options[:stateless] = true
270
+ end
271
+
272
+ opts.on("--no-pager", "Do not pipe output into a pager") do
273
+ options[:no_pager] = true
274
+ end
275
+
276
+ opts.on("-h", "--help", "Show this help") do
277
+ puts opts
278
+ exit
279
+ end
280
+ end
281
+
282
+ parser.parse!(@args)
283
+ options
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
@@ -118,9 +118,11 @@ module Git
118
118
  end
119
119
 
120
120
  packages = deps.map do |dep|
121
- purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name]).to_s
121
+ versioned_purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name], version: dep[:requirement])
122
+ base_purl = PurlHelper.build_purl(ecosystem: dep[:ecosystem], name: dep[:name])
122
123
  {
123
- purl: purl,
124
+ purl: versioned_purl.to_s,
125
+ base_purl: base_purl.to_s,
124
126
  name: dep[:name],
125
127
  ecosystem: dep[:ecosystem],
126
128
  version: dep[:requirement],
@@ -128,11 +130,9 @@ module Git
128
130
  }
129
131
  end.uniq { |p| p[:purl] }
130
132
 
131
- enrich_packages(packages.map { |p| p[:purl] })
133
+ enrich_packages(packages)
132
134
 
133
135
  packages.each do |pkg|
134
- db_pkg = Models::Package.first(purl: pkg[:purl])
135
- pkg[:license] = db_pkg&.license
136
136
  pkg[:violation] = check_violation(pkg[:license])
137
137
  end
138
138
 
@@ -183,9 +183,14 @@ module Git
183
183
  license.downcase.include?(pattern.downcase)
184
184
  end
185
185
 
186
- def enrich_packages(purls)
186
+ def enrich_packages(packages)
187
+ client = EcosystemsClient.new
188
+
189
+ # Enrich package-level data (license, latest version)
190
+ base_purls = packages.map { |p| p[:base_purl] }.uniq
191
+
187
192
  packages_by_purl = {}
188
- purls.each do |purl|
193
+ base_purls.each do |purl|
189
194
  parsed = Purl::PackageURL.parse(purl)
190
195
  ecosystem = PurlHelper::ECOSYSTEM_TO_PURL_TYPE.invert[parsed.type] || parsed.type
191
196
  pkg = Models::Package.find_or_create_by_purl(
@@ -196,19 +201,52 @@ module Git
196
201
  packages_by_purl[purl] = pkg
197
202
  end
198
203
 
199
- stale_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
200
- return if stale_purls.empty?
204
+ stale_pkg_purls = packages_by_purl.select { |_, pkg| pkg.needs_enrichment? }.keys
201
205
 
202
- client = EcosystemsClient.new
203
- begin
204
- results = Spinner.with_spinner("Fetching package metadata...") do
205
- client.bulk_lookup(stale_purls)
206
+ if stale_pkg_purls.any?
207
+ begin
208
+ results = Spinner.with_spinner("Fetching package metadata...") do
209
+ client.bulk_lookup(stale_pkg_purls)
210
+ end
211
+ results.each do |purl, data|
212
+ packages_by_purl[purl]&.enrich_from_api(data)
213
+ end
214
+ rescue EcosystemsClient::ApiError => e
215
+ $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
206
216
  end
207
- results.each do |purl, data|
208
- packages_by_purl[purl]&.enrich_from_api(data)
217
+ end
218
+
219
+ # Enrich version-level data (license, integrity, published_at)
220
+ versions_by_purl = {}
221
+ packages.each do |pkg|
222
+ version = Models::Version.find_or_create_by_purl(
223
+ purl: pkg[:purl],
224
+ package_purl: pkg[:base_purl]
225
+ )
226
+ versions_by_purl[pkg[:purl]] = version
227
+ end
228
+
229
+ stale_version_purls = versions_by_purl.select { |_, v| v.needs_enrichment? }.keys
230
+
231
+ if stale_version_purls.any?
232
+ begin
233
+ Spinner.with_spinner("Fetching version metadata...") do
234
+ stale_version_purls.each do |purl|
235
+ data = client.lookup_version(purl)
236
+ versions_by_purl[purl]&.enrich_from_api(data) if data
237
+ end
238
+ end
239
+ rescue EcosystemsClient::ApiError => e
240
+ $stderr.puts "Warning: Could not fetch version data: #{e.message}" unless Git::Pkgs.quiet
209
241
  end
210
- rescue EcosystemsClient::ApiError => e
211
- $stderr.puts "Warning: Could not fetch package data: #{e.message}" unless Git::Pkgs.quiet
242
+ end
243
+
244
+ # Apply enriched data to packages - version license takes priority
245
+ packages.each do |pkg|
246
+ db_pkg = packages_by_purl[pkg[:base_purl]]
247
+ db_version = versions_by_purl[pkg[:purl]]
248
+
249
+ pkg[:license] = db_version&.license || db_pkg&.license
212
250
  end
213
251
  end
214
252
 
@@ -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
@@ -68,6 +68,8 @@ module Git
68
68
  error "No analysis found for branch '#{branch_name}'. Run 'git pkgs init' first."
69
69
  end
70
70
 
71
+ ensure_vulns_synced
72
+
71
73
  # Get all unique packages from dependency changes
72
74
  packages = Models::DependencyChange
73
75
  .select(:ecosystem, :name)
@@ -6,7 +6,7 @@ require "open3"
6
6
  module Git
7
7
  module Pkgs
8
8
  module Config
9
- # File patterns ignored by default (SBOM formats not supported, go.sum is checksums only)
9
+ # File patterns ignored by default (SBOM formats duplicate info from actual lockfiles)
10
10
  DEFAULT_IGNORED_FILES = %w[
11
11
  cyclonedx.xml
12
12
  cyclonedx.json
@@ -14,7 +14,6 @@ module Git
14
14
  *.cdx.json
15
15
  *.spdx
16
16
  *.spdx.json
17
- go.sum
18
17
  ].freeze
19
18
 
20
19
  def self.ignored_dirs
@@ -15,7 +15,7 @@ module Git
15
15
  module Pkgs
16
16
  class Database
17
17
  DB_FILE = "pkgs.sqlite3"
18
- SCHEMA_VERSION = 3
18
+ SCHEMA_VERSION = 5
19
19
 
20
20
  class << self
21
21
  attr_accessor :db
@@ -179,6 +179,7 @@ module Git
179
179
  String :purl
180
180
  String :requirement
181
181
  String :dependency_type
182
+ String :integrity, text: true
182
183
  DateTime :created_at
183
184
  DateTime :updated_at
184
185
  end
@@ -193,6 +194,8 @@ module Git
193
194
  String :description, text: true
194
195
  String :homepage
195
196
  String :repository_url
197
+ String :supplier_name
198
+ String :supplier_type
196
199
  String :source
197
200
  DateTime :enriched_at
198
201
  DateTime :vulns_synced_at
@@ -291,21 +294,12 @@ module Git
291
294
  def self.check_version!
292
295
  return unless needs_upgrade?
293
296
 
294
- migrate!
295
- end
296
-
297
- def self.migrate!
298
297
  stored = stored_version || 0
299
-
300
- # Migration from v1 to v2: add vuln tables
301
- if stored < 2
302
- migrate_to_v2!
303
- end
304
-
305
- set_version
306
- refresh_models
298
+ raise SchemaVersionError, "Database schema is v#{stored}, expected v#{SCHEMA_VERSION}. Run 'git pkgs upgrade' to rebuild."
307
299
  end
308
300
 
301
+ # Legacy migration kept for reference, no longer used.
302
+ # All schema changes now require full rebuild via 'git pkgs upgrade'.
309
303
  def self.migrate_to_v2!
310
304
  @db.create_table?(:packages) do
311
305
  primary_key :id
@@ -47,8 +47,54 @@ module Git
47
47
  results[purl]
48
48
  end
49
49
 
50
+ # Lookup a specific version by purl with version.
51
+ # Returns version-level data including integrity hash.
52
+ #
53
+ # @param purl [String] package URL with version (e.g., "pkg:gem/rake@13.3.1")
54
+ # @return [Hash, nil] version data or nil if not found
55
+ def lookup_version(purl)
56
+ parsed = Purl.parse(purl)
57
+ return nil unless parsed.version
58
+
59
+ url = parsed.ecosystems_version_api_url
60
+ return nil unless url
61
+
62
+ fetch_url(url)
63
+ rescue Purl::Error
64
+ nil
65
+ end
66
+
67
+ # Batch lookup versions by purl.
68
+ # Fetches each version individually (no batch API for versions).
69
+ #
70
+ # @param purls [Array<String>] array of versioned package URLs
71
+ # @return [Hash<String, Hash>] hash keyed by purl with version data
72
+ def bulk_lookup_versions(purls)
73
+ results = {}
74
+ purls.each do |purl|
75
+ data = lookup_version(purl)
76
+ results[purl] = data if data
77
+ end
78
+ results
79
+ end
80
+
50
81
  private
51
82
 
83
+ def fetch_url(url)
84
+ uri = URI(url)
85
+ request = Net::HTTP::Get.new(uri)
86
+ request["Accept"] = "application/json"
87
+ execute_request(uri, request)
88
+ end
89
+
90
+ def get(path)
91
+ uri = URI("#{API_BASE}#{path}")
92
+ request = Net::HTTP::Get.new(uri)
93
+ request["Accept"] = "application/json"
94
+
95
+ execute_request(uri, request)
96
+ end
97
+
52
98
  def post(path, payload)
53
99
  uri = URI("#{API_BASE}#{path}")
54
100
  request = Net::HTTP::Post.new(uri)
@@ -56,16 +56,39 @@ module Git
56
56
 
57
57
  # Update package with data from ecosyste.ms API
58
58
  def enrich_from_api(data)
59
+ supplier_name, supplier_type = extract_supplier(data)
60
+
59
61
  update(
60
62
  latest_version: data["latest_release_number"],
61
63
  license: (data["normalized_licenses"] || []).first,
62
64
  description: data["description"],
63
65
  homepage: data["homepage"],
64
66
  repository_url: data["repository_url"],
67
+ supplier_name: supplier_name,
68
+ supplier_type: supplier_type,
65
69
  enriched_at: Time.now
66
70
  )
67
71
  end
68
72
 
73
+ # Extract supplier info from API response
74
+ # Prefers owner_record (org), falls back to first maintainer
75
+ def extract_supplier(data)
76
+ owner = data["owner_record"]
77
+ if owner && owner["name"]
78
+ type = owner["kind"] == "organization" ? "organization" : "person"
79
+ return [owner["name"], type]
80
+ end
81
+
82
+ maintainers = data["maintainers"]
83
+ if maintainers&.any?
84
+ first = maintainers.first
85
+ name = first["name"] || first["login"]
86
+ return [name, "person"] if name
87
+ end
88
+
89
+ [nil, nil]
90
+ end
91
+
69
92
  def vulnerabilities
70
93
  osv_ecosystem = Ecosystems.to_osv(ecosystem)
71
94
  return [] unless osv_ecosystem
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Models
6
6
  class Version < Sequel::Model
7
+ STALE_THRESHOLD = 86400 # 24 hours
8
+
7
9
  many_to_one :package, key: :package_purl, primary_key: :purl
8
10
 
9
11
  def parsed_purl
@@ -21,6 +23,33 @@ module Git
21
23
  def enriched?
22
24
  !enriched_at.nil?
23
25
  end
26
+
27
+ def needs_enrichment?
28
+ enriched_at.nil? || enriched_at < Time.now - STALE_THRESHOLD
29
+ end
30
+
31
+ def enrich_from_api(data)
32
+ licenses = data["licenses"]
33
+ license = case licenses
34
+ when Array then licenses.first
35
+ when String then licenses
36
+ end
37
+ license ||= data["spdx_expression"]
38
+
39
+ update(
40
+ license: license,
41
+ integrity: data["integrity"],
42
+ published_at: data["published_at"] ? Time.parse(data["published_at"]) : nil,
43
+ enriched_at: Time.now
44
+ )
45
+ end
46
+
47
+ def self.find_or_create_by_purl(purl:, package_purl:)
48
+ existing = first(purl: purl)
49
+ return existing if existing
50
+
51
+ create(purl: purl, package_purl: package_purl)
52
+ end
24
53
  end
25
54
  end
26
55
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Git
4
4
  module Pkgs
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
data/lib/git/pkgs.rb CHANGED
@@ -49,12 +49,15 @@ require_relative "pkgs/commands/completions"
49
49
  require_relative "pkgs/commands/vulns"
50
50
  require_relative "pkgs/commands/outdated"
51
51
  require_relative "pkgs/commands/licenses"
52
+ require_relative "pkgs/commands/integrity"
53
+ require_relative "pkgs/commands/sbom"
52
54
 
53
55
  module Git
54
56
  module Pkgs
55
57
  class Error < StandardError; end
56
58
  class NotInitializedError < Error; end
57
59
  class NotInGitRepoError < Error; end
60
+ class SchemaVersionError < Error; end
58
61
 
59
62
  class << self
60
63
  attr_accessor :quiet, :git_dir, :work_tree, :db_path, :batch_size, :snapshot_interval, :threads
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-pkgs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '15.2'
60
+ version: '15.3'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '15.2'
67
+ version: '15.3'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: vers
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -86,6 +86,9 @@ dependencies:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
88
  version: '1.7'
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: 1.7.1
89
92
  type: :runtime
90
93
  prerelease: false
91
94
  version_requirements: !ruby/object:Gem::Requirement
@@ -93,6 +96,9 @@ dependencies:
93
96
  - - "~>"
94
97
  - !ruby/object:Gem::Version
95
98
  version: '1.7'
99
+ - - ">="
100
+ - !ruby/object:Gem::Version
101
+ version: 1.7.1
96
102
  - !ruby/object:Gem::Dependency
97
103
  name: sarif-ruby
98
104
  requirement: !ruby/object:Gem::Requirement
@@ -107,6 +113,34 @@ dependencies:
107
113
  - - ">="
108
114
  - !ruby/object:Gem::Version
109
115
  version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: sbom
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - "~>"
121
+ - !ruby/object:Gem::Version
122
+ version: '0.4'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - "~>"
128
+ - !ruby/object:Gem::Version
129
+ version: '0.4'
130
+ - !ruby/object:Gem::Dependency
131
+ name: base64
132
+ requirement: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ type: :runtime
138
+ prerelease: false
139
+ version_requirements: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
110
144
  description: A git subcommand for analyzing package/dependency usage in git repositories
111
145
  over time
112
146
  email:
@@ -137,10 +171,12 @@ files:
137
171
  - lib/git/pkgs/commands/hooks.rb
138
172
  - lib/git/pkgs/commands/info.rb
139
173
  - lib/git/pkgs/commands/init.rb
174
+ - lib/git/pkgs/commands/integrity.rb
140
175
  - lib/git/pkgs/commands/licenses.rb
141
176
  - lib/git/pkgs/commands/list.rb
142
177
  - lib/git/pkgs/commands/log.rb
143
178
  - lib/git/pkgs/commands/outdated.rb
179
+ - lib/git/pkgs/commands/sbom.rb
144
180
  - lib/git/pkgs/commands/schema.rb
145
181
  - lib/git/pkgs/commands/search.rb
146
182
  - lib/git/pkgs/commands/show.rb