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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f9ff177f3dd7cbb4f5591a0c0f2b936d5fb98629329294dae9f57380a69709e
4
- data.tar.gz: e8760e948aea3b7252176fc6a0b4bb0d74432ad05e4fa20693468b9440b126bd
3
+ metadata.gz: 3bd1b968e2bc7ed2d3ecbc8a750925afdea362ca1d0a01f6d69a6bf5aef58b02
4
+ data.tar.gz: 1deed6e6742a7977585c97db65fe13e649e4b5e3b6a0f9674f5352d977dc8fcf
5
5
  SHA512:
6
- metadata.gz: a670a1b046b7d0953aefe8bd57a6a03fa69624eb3bdb4e8867a647521a88c936e559973b6470989cca45714e79de19c0f6d2a2c85fa7340c3b677e07923cb97a
7
- data.tar.gz: dcbb08683dfe053e70801ae36fe74f3d1f33ce0a6f74a7df3b6cb1a6d5df70d5a7c5db9ca9c7fc8efb6ee94463fbc96a68ef7e09f20808fd6b41b1c2d2e6b873
6
+ metadata.gz: 5d7ed904f5e7b3fc0b7a7054d596dfe59d1918f30d493ac5613cea125c41f9b38ab22daab2c84a5536b55030ca407334b01e6d0bd79bc828a371abfdccf5a16d
7
+ data.tar.gz: e1170c4c9eeb345730e9ab614536c6431f1c56ee9bafc8bdff111e7df3bf8f577149ba1f94a77787ace083ee401b2980978148f76e85d16c5a3a1101902a2a08
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
18
+ ## [0.8.0] - 2026-01-14
19
+
20
+ - `git pkgs outdated` command to find dependencies with newer versions available in registries
21
+ - `git pkgs licenses` command to check dependency licenses with compliance options (--permissive, --allow, --deny)
22
+ - ecosyste.ms client for fetching package metadata (latest versions, licenses)
23
+ - Package and Version models for storing enrichment data
24
+ - Spinner utility for progress feedback during network operations
25
+ - PURL helper for standardized package URLs
26
+ - `outdated` is no longer an alias for `stale` (now a separate command)
27
+
3
28
  ## [0.7.0] - 2026-01-09
4
29
 
5
30
  - `git pkgs vulns` subcommand for vulnerability scanning via OSV API
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.6.2.tar.gz"
5
- sha256 "ccd7a8a5b9cb21c52cc488923ed1318387a9fefa4baff2057bd96b27591577aa"
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
@@ -10,7 +10,9 @@ Your lockfile shows what dependencies you have, but it doesn't show how you got
10
10
 
11
11
  For best results, commit your lockfiles. Manifests show version ranges but lockfiles show what actually got installed, including transitive dependencies.
12
12
 
13
- It works across many ecosystems (Gemfile, package.json, Dockerfile, GitHub Actions workflows) giving you one unified history instead of separate tools per ecosystem. Everything runs locally and offline with no external services or network calls, and the database lives in your `.git` directory where you can use it in CI to catch dependency changes in pull requests.
13
+ It works across many ecosystems (Gemfile, package.json, Dockerfile, GitHub Actions workflows) giving you one unified history instead of separate tools per ecosystem. The database lives in your `.git` directory where you can use it in CI to catch dependency changes in pull requests.
14
+
15
+ The core commands (`list`, `history`, `blame`, `diff`, `stale`, etc.) work entirely from your git history with no network access. Additional commands fetch external data: `vulns` checks [OSV](https://osv.dev) for known CVEs, while `outdated` and `licenses` query [ecosyste.ms](https://packages.ecosyste.ms/) for registry metadata. See [docs/enrichment.md](docs/enrichment.md) for details on external data.
14
16
 
15
17
  ## Installation
16
18
 
@@ -45,6 +47,8 @@ git pkgs history rails # track a specific package
45
47
  git pkgs why rails # why was this added?
46
48
  git pkgs diff --from=HEAD~10 # what changed recently?
47
49
  git pkgs diff --from=main --to=feature # compare branches
50
+ git pkgs vulns # scan for known CVEs
51
+ git pkgs vulns blame # who introduced each vulnerability
48
52
  ```
49
53
 
50
54
  ## Commands
@@ -254,22 +258,96 @@ This shows dependencies grouped by type (runtime, development, etc).
254
258
  git pkgs stale # list deps by how long since last touched
255
259
  git pkgs stale --days=365 # only show deps untouched for a year
256
260
  git pkgs stale --ecosystem=npm # filter by ecosystem
257
- git pkgs outdated # alias for stale
258
261
  ```
259
262
 
260
263
  Shows dependencies sorted by how long since they were last changed in your repo. Useful for finding packages that may have been forgotten or need review.
261
264
 
265
+ ### Find outdated dependencies
266
+
267
+ ```bash
268
+ git pkgs outdated # show packages with newer versions available
269
+ git pkgs outdated --major # only major version updates
270
+ git pkgs outdated --minor # minor and major updates (skip patch)
271
+ git pkgs outdated --stateless # no database needed
272
+ ```
273
+
274
+ Checks package registries (via [ecosyste.ms](https://packages.ecosyste.ms/)) to find dependencies with newer versions available. Major updates are shown in red, minor in yellow, patch in cyan.
275
+
276
+ ### Check licenses
277
+
278
+ ```bash
279
+ git pkgs licenses # show license for each dependency
280
+ git pkgs licenses --permissive # flag copyleft licenses
281
+ git pkgs licenses --allow=MIT,Apache-2.0 # explicit allow list
282
+ git pkgs licenses --group # group output by license
283
+ git pkgs licenses --stateless # no database needed
284
+ ```
285
+
286
+ Fetches license information from package registries. Exits with code 1 if violations are found, making it suitable for CI. See [docs/enrichment.md](docs/enrichment.md) for all options.
287
+
262
288
  ### Vulnerability scanning
263
289
 
290
+ Scan dependencies for known CVEs using the [OSV database](https://osv.dev). Because git-pkgs tracks the full history of every dependency change, it provides context that static scanners can't: who introduced a vulnerability, when it was fixed, and how long you were exposed.
291
+
264
292
  ```bash
265
- git pkgs vulns # scan current dependencies for known CVEs
293
+ git pkgs vulns # scan current dependencies
294
+ git pkgs vulns v1.0.0 # scan at a tag, branch, or commit
266
295
  git pkgs vulns -s high # only critical and high severity
296
+ git pkgs vulns -e npm # filter by ecosystem
297
+ git pkgs vulns -f sarif # output for GitHub code scanning
298
+ ```
299
+
300
+ Subcommands for historical analysis:
301
+
302
+ ```bash
267
303
  git pkgs vulns blame # who introduced each vulnerability
304
+ git pkgs vulns blame --all-time # include fixed vulnerabilities
268
305
  git pkgs vulns praise # who fixed vulnerabilities
269
- git pkgs vulns exposure --all-time --summary # remediation metrics
306
+ git pkgs vulns praise --summary # author leaderboard
307
+ git pkgs vulns exposure # remediation metrics (CRA compliance)
308
+ git pkgs vulns diff main feature # compare vulnerability state between refs
309
+ git pkgs vulns log # commits that introduced or fixed vulns
310
+ git pkgs vulns history lodash # vulnerability timeline for a package
311
+ git pkgs vulns show CVE-2024-1234 # details about a specific CVE
270
312
  ```
271
313
 
272
- Uses the [OSV database](https://osv.dev) to check your dependencies against known security advisories. Because git-pkgs tracks the full history, it can show who introduced and fixed each vulnerability. See [docs/vulns.md](docs/vulns.md) for full documentation.
314
+ Output formats: `text` (default), `json`, and `sarif`. SARIF integrates with GitHub Advanced Security:
315
+
316
+ ```yaml
317
+ - run: git pkgs vulns --stateless -f sarif > results.sarif
318
+ - uses: github/codeql-action/upload-sarif@v3
319
+ with:
320
+ sarif_file: results.sarif
321
+ ```
322
+
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
+
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.
273
351
 
274
352
  ### Diff between commits
275
353
 
@@ -497,6 +575,7 @@ Git::Pkgs::Database.connect(repo_git_dir)
497
575
  Git::Pkgs::Models::DependencyChange.where(name: "rails").all
498
576
  ```
499
577
 
578
+
500
579
  ## Contributing
501
580
 
502
581
  Bug reports, feature requests, and pull requests are welcome. If you're unsure about a change, open an issue first to discuss it.
@@ -33,7 +33,7 @@ module Git
33
33
  REQUIRE Project.toml Manifest.toml
34
34
  shard.yml shard.lock
35
35
  elm-package.json elm_dependencies.json elm-stuff/exact-dependencies.json
36
- haxelib.json
36
+ haxelib.json stack.yaml stack.yaml.lock
37
37
  action.yml action.yaml .github/workflows/*.yml .github/workflows/*.yaml
38
38
  Dockerfile docker-compose*.yml docker-compose*.yaml
39
39
  dvc.yaml vcpkg.json _generated-vcpkg-list.json
@@ -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
@@ -33,7 +33,11 @@ module Git
33
33
  },
34
34
  "Analysis" => {
35
35
  "stats" => "Show dependency statistics",
36
- "stale" => "Show dependencies that haven't been updated"
36
+ "stale" => "Show dependencies that haven't been updated",
37
+ "outdated" => "Show packages with newer versions available",
38
+ "licenses" => "Show licenses for dependencies",
39
+ "integrity" => "Show and verify lockfile integrity hashes",
40
+ "sbom" => "Export dependencies as SBOM (SPDX or CycloneDX)"
37
41
  },
38
42
  "Security" => {
39
43
  "vulns" => "Scan for known vulnerabilities"
@@ -42,7 +46,7 @@ module Git
42
46
 
43
47
  COMMANDS = COMMAND_GROUPS.values.flat_map(&:keys).freeze
44
48
  COMMAND_DESCRIPTIONS = COMMAND_GROUPS.values.reduce({}, :merge).freeze
45
- ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze
49
+ ALIASES = { "praise" => "blame" }.freeze
46
50
 
47
51
  def self.run(args)
48
52
  new(args).run
@@ -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
@@ -24,18 +24,24 @@ module Git
24
24
  gems.locked
25
25
  glide.lock
26
26
  go.mod
27
+ go.sum
28
+ gradle.lockfile
27
29
  mix.lock
28
30
  npm-shrinkwrap.json
29
31
  package-lock.json
30
32
  packages.lock.json
31
33
  paket.lock
34
+ pdm.lock
32
35
  pnpm-lock.yaml
33
36
  poetry.lock
34
37
  project.assets.json
35
38
  pubspec.lock
36
39
  pylock.toml
40
+ renv.lock
37
41
  shard.lock
42
+ stack.yaml.lock
38
43
  uv.lock
44
+ verification-metadata.xml
39
45
  yarn.lock
40
46
  ].freeze
41
47
 
@@ -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