git-pkgs 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +15 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +36 -4
  8. data/lib/git/pkgs/analyzer.rb +141 -9
  9. data/lib/git/pkgs/cli.rb +16 -6
  10. data/lib/git/pkgs/commands/blame.rb +0 -18
  11. data/lib/git/pkgs/commands/diff.rb +122 -5
  12. data/lib/git/pkgs/commands/diff_driver.rb +24 -4
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/list.rb +60 -15
  15. data/lib/git/pkgs/commands/show.rb +126 -3
  16. data/lib/git/pkgs/commands/stale.rb +6 -2
  17. data/lib/git/pkgs/commands/update.rb +3 -0
  18. data/lib/git/pkgs/commands/vulns/base.rb +354 -0
  19. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  20. data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
  21. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  22. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  23. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  24. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  25. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  26. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  27. data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
  28. data/lib/git/pkgs/commands/vulns.rb +50 -0
  29. data/lib/git/pkgs/config.rb +8 -1
  30. data/lib/git/pkgs/database.rb +135 -5
  31. data/lib/git/pkgs/ecosystems.rb +83 -0
  32. data/lib/git/pkgs/models/package.rb +54 -0
  33. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  34. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  35. data/lib/git/pkgs/osv_client.rb +151 -0
  36. data/lib/git/pkgs/output.rb +22 -0
  37. data/lib/git/pkgs/version.rb +1 -1
  38. data/lib/git/pkgs.rb +6 -0
  39. metadata +66 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ec8cddb19542e0519e69be68a3f0b65424c940718ba3bd5304ed93f457078ec7
4
- data.tar.gz: bce2b1006ad63c0f2269d9321948acfa9cde600e8f8e41b3e606768c8f8b6178
3
+ metadata.gz: 0f9ff177f3dd7cbb4f5591a0c0f2b936d5fb98629329294dae9f57380a69709e
4
+ data.tar.gz: e8760e948aea3b7252176fc6a0b4bb0d74432ad05e4fa20693468b9440b126bd
5
5
  SHA512:
6
- metadata.gz: 1488be447b21af773fc7aa6309d8981bb78b45134a755f56bff38f0cac3c92d861f867fb62c70201c1eec9c1f4b40f042a6804f416f64c952bc008678c756831
7
- data.tar.gz: 998ca9734325cef27dcd3e09a1c7e68acf212a6974f0bd61adb3257322d92881e1c1cb8995be3935b32253f02a94b15f3e531bc40bf4cd1111933b24cc863275
6
+ metadata.gz: a670a1b046b7d0953aefe8bd57a6a03fa69624eb3bdb4e8867a647521a88c936e559973b6470989cca45714e79de19c0f6d2a2c85fa7340c3b677e07923cb97a
7
+ data.tar.gz: dcbb08683dfe053e70801ae36fe74f3d1f33ce0a6f74a7df3b6cb1a6d5df70d5a7c5db9ca9c7fc8efb6ee94463fbc96a68ef7e09f20808fd6b41b1c2d2e6b873
data/.gitattributes ADDED
@@ -0,0 +1,28 @@
1
+ # git-pkgs textconv for lockfiles
2
+ Brewfile.lock.json diff=pkgs
3
+ Cargo.lock diff=pkgs
4
+ Cartfile.resolved diff=pkgs
5
+ Gemfile.lock diff=pkgs
6
+ Gopkg.lock diff=pkgs
7
+ Package.resolved diff=pkgs
8
+ Pipfile.lock diff=pkgs
9
+ Podfile.lock diff=pkgs
10
+ Project.lock.json diff=pkgs
11
+ bun.lock diff=pkgs
12
+ composer.lock diff=pkgs
13
+ gems.locked diff=pkgs
14
+ glide.lock diff=pkgs
15
+ go.mod diff=pkgs
16
+ mix.lock diff=pkgs
17
+ npm-shrinkwrap.json diff=pkgs
18
+ package-lock.json diff=pkgs
19
+ packages.lock.json diff=pkgs
20
+ paket.lock diff=pkgs
21
+ pnpm-lock.yaml diff=pkgs
22
+ poetry.lock diff=pkgs
23
+ project.assets.json diff=pkgs
24
+ pubspec.lock diff=pkgs
25
+ pylock.toml diff=pkgs
26
+ shard.lock diff=pkgs
27
+ uv.lock diff=pkgs
28
+ yarn.lock diff=pkgs
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 4.0.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-01-09
4
+
5
+ - `git pkgs vulns` subcommand for vulnerability scanning via OSV API
6
+ - `git pkgs vulns scan` to scan dependencies for known vulnerabilities
7
+ - `git pkgs vulns show` to display details for a specific vulnerability
8
+ - `git pkgs vulns sync` to prefetch vulnerability data for all packages
9
+ - `git pkgs vulns exposure` to analyze vulnerability exposure over time
10
+ - `git pkgs vulns praise` to show resolved vulnerabilities with attribution
11
+ - SARIF output format for CI integration (`--format=sarif`)
12
+ - Docker container support for running git-pkgs without local Ruby installation
13
+ - `list` command now shows locked versions and manifest kind
14
+ - `--stateless` flag for `list`, `show`, and `diff` commands (auto-enabled when no database exists)
15
+ - Update ecosystems-bibliothecary to ~> 15.2
16
+ - Fix `-f` flag conflict in `diff` command (was defined for both `--from` and `--format`)
17
+
3
18
  ## [0.6.2] - 2026-01-06
4
19
 
5
20
  - `--format=json` support for `diff`, `tree`, `stale`, and `why` commands
data/Dockerfile ADDED
@@ -0,0 +1,18 @@
1
+ FROM ruby:4.0-alpine
2
+
3
+ RUN apk add --no-cache \
4
+ build-base \
5
+ git \
6
+ libgit2-dev \
7
+ cmake
8
+
9
+ RUN gem install git-pkgs
10
+
11
+ # The git repository (the mounted directory) has different ownership that the root user of the container.
12
+ # Due to an update in git, a different user cannot perform git operations on the mounted directory.
13
+ # This command allows the any user to perform git operations on the mounted directory.
14
+ RUN git config --system --add safe.directory /mnt
15
+
16
+ WORKDIR /mnt
17
+
18
+ ENTRYPOINT ["git", "pkgs"]
@@ -0,0 +1,28 @@
1
+ class GitPkgs < Formula
2
+ desc "Track package dependencies across git history"
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"
6
+ license "AGPL-3.0"
7
+
8
+ depends_on "cmake" => :build
9
+ depends_on "pkg-config" => :build
10
+ depends_on "libgit2"
11
+ depends_on "ruby"
12
+
13
+ def install
14
+ ENV["GEM_HOME"] = libexec
15
+
16
+ system "git", "init"
17
+ system "git", "add", "."
18
+
19
+ system "gem", "build", "git-pkgs.gemspec"
20
+ system "gem", "install", "--no-document", "git-pkgs-#{version}.gem"
21
+ bin.install libexec/"bin/git-pkgs"
22
+ bin.env_script_all_files(libexec/"bin", GEM_HOME: ENV.fetch("GEM_HOME", nil))
23
+ end
24
+
25
+ test do
26
+ system bin/"git-pkgs", "--version"
27
+ end
28
+ end
data/README.md CHANGED
@@ -2,18 +2,36 @@
2
2
 
3
3
  A git subcommand for tracking package dependencies across git history. Analyzes your repository to show when dependencies were added, modified, or removed, who made those changes, and why.
4
4
 
5
+ [Installation](#installation) · [Quick start](#quick-start) · [Commands](#commands) · [Configuration](#configuration) · [Ruby API](#ruby-api) · [Contributing](#contributing)
6
+
5
7
  ## Why this exists
6
8
 
7
9
  Your lockfile shows what dependencies you have, but it doesn't show how you got here, and `git log Gemfile.lock` is useless noise. git-pkgs indexes your dependency history into a queryable database so you can ask questions like: when did we add this? who added it? what changed between these two releases? has anyone touched this in the last year?
8
10
 
11
+ For best results, commit your lockfiles. Manifests show version ranges but lockfiles show what actually got installed, including transitive dependencies.
12
+
9
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.
10
14
 
11
15
  ## Installation
12
16
 
17
+ ```bash
18
+ brew tap andrew/git-pkgs https://github.com/andrew/git-pkgs
19
+ brew install git-pkgs
20
+ ```
21
+
22
+ Or with RubyGems:
23
+
13
24
  ```bash
14
25
  gem install git-pkgs
15
26
  ```
16
27
 
28
+ Or using Docker:
29
+
30
+ ```bash
31
+ docker build -t git-pkgs .
32
+ docker run -it --rm -v $(pwd):/mnt git-pkgs <subcommand>
33
+ ```
34
+
17
35
  ## Quick start
18
36
 
19
37
  ```bash
@@ -96,6 +114,7 @@ git pkgs list
96
114
  git pkgs list --commit=abc123
97
115
  git pkgs list --ecosystem=rubygems
98
116
  git pkgs list --manifest=Gemfile
117
+ git pkgs list --stateless # parse manifests directly, no database needed
99
118
  ```
100
119
 
101
120
  Example output:
@@ -240,11 +259,24 @@ git pkgs outdated # alias for stale
240
259
 
241
260
  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.
242
261
 
262
+ ### Vulnerability scanning
263
+
264
+ ```bash
265
+ git pkgs vulns # scan current dependencies for known CVEs
266
+ git pkgs vulns -s high # only critical and high severity
267
+ git pkgs vulns blame # who introduced each vulnerability
268
+ git pkgs vulns praise # who fixed vulnerabilities
269
+ git pkgs vulns exposure --all-time --summary # remediation metrics
270
+ ```
271
+
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.
273
+
243
274
  ### Diff between commits
244
275
 
245
276
  ```bash
246
277
  git pkgs diff --from=abc123 --to=def456
247
278
  git pkgs diff --from=HEAD~10
279
+ git pkgs diff main..feature --stateless # no database needed
248
280
  ```
249
281
 
250
282
  This shows added, removed, and modified packages with version info.
@@ -255,6 +287,7 @@ This shows added, removed, and modified packages with version info.
255
287
  git pkgs show # show dependency changes in HEAD
256
288
  git pkgs show abc123 # specific commit
257
289
  git pkgs show HEAD~5 # relative ref
290
+ git pkgs show --stateless # no database needed
258
291
  ```
259
292
 
260
293
  Like `git show` but for dependencies. Shows what was added, modified, or removed in a single commit.
@@ -325,7 +358,7 @@ Useful for understanding the [database structure](docs/schema.md) or generating
325
358
 
326
359
  ### CI usage
327
360
 
328
- You can run git-pkgs in CI to show dependency changes in pull requests:
361
+ You can run git-pkgs in CI to show dependency changes in pull requests. Use `--stateless` to skip database initialization for faster runs:
329
362
 
330
363
  ```yaml
331
364
  # .github/workflows/deps.yml
@@ -344,8 +377,7 @@ jobs:
344
377
  with:
345
378
  ruby-version: '3.3'
346
379
  - run: gem install git-pkgs
347
- - run: git pkgs init
348
- - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD
380
+ - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD --stateless
349
381
  ```
350
382
 
351
383
  ### Diff driver
@@ -430,7 +462,7 @@ Optimizations:
430
462
 
431
463
  git-pkgs uses [ecosystems-bibliothecary](https://github.com/ecosyste-ms/bibliothecary) for parsing, supporting:
432
464
 
433
- Actions, BentoML, Bower, Cargo, Carthage, Clojars, CocoaPods, Cog, Conda, CPAN, CRAN, Docker, Dub, DVC, Elm, Go, Hackage, Haxelib, Hex, Homebrew, Julia, Maven, Meteor, MLflow, npm, NuGet, Ollama, Packagist, Pub, PyPI, RubyGems, Shards, SwiftPM, Vcpkg
465
+ Actions, BentoML, Bower, Cargo, Carthage, Clojars, CocoaPods, Cog, Conan, Conda, CPAN, CRAN, Deno, Docker, Dub, DVC, Elm, Go, Hackage, Haxelib, Hex, Homebrew, Julia, LuaRocks, Maven, Meteor, MLflow, Nimble, Nix, npm, NuGet, Ollama, Packagist, Pub, PyPI, RubyGems, Shards, SwiftPM, Vcpkg
434
466
 
435
467
  SBOM formats (CycloneDX, SPDX) are not supported as they duplicate information from the actual lockfiles.
436
468
 
@@ -12,33 +12,43 @@ module Git
12
12
  QUICK_MANIFEST_PATTERNS = %w[
13
13
  Gemfile Gemfile.lock gems.rb gems.locked *.gemspec
14
14
  package.json package-lock.json yarn.lock npm-shrinkwrap.json pnpm-lock.yaml bun.lock npm-ls.json
15
- setup.py req*.txt req*.pip requirements/*.txt requirements/*.pip requirements.frozen
16
- Pipfile Pipfile.lock pyproject.toml poetry.lock uv.lock pylock.toml
15
+ setup.py req*.txt req*.pip requirements/*.txt requirements/*.pip requirements*.in requirements.frozen
16
+ Pipfile Pipfile.lock pyproject.toml poetry.lock uv.lock pylock.toml pdm.lock
17
17
  pip-resolved-dependencies.txt pip-dependency-graph.json
18
- pom.xml ivy.xml build.gradle build.gradle.kts gradle-dependencies-q.txt
18
+ pom.xml ivy.xml build.gradle build.gradle.kts gradle-dependencies-q.txt gradle.lockfile verification-metadata.xml
19
19
  maven-resolved-dependencies.txt sbt-update-full.txt maven-dependency-tree.txt maven-dependency-tree.dot
20
20
  Cargo.toml Cargo.lock
21
- go.mod glide.yaml glide.lock Godeps Godeps/Godeps.json
21
+ go.mod go.sum glide.yaml glide.lock Godeps Godeps/Godeps.json
22
22
  vendor/manifest vendor/vendor.json Gopkg.toml Gopkg.lock go-resolved-dependencies.json
23
23
  composer.json composer.lock
24
24
  Podfile Podfile.lock *.podspec *.podspec.json
25
25
  packages.config packages.lock.json Project.json Project.lock.json
26
- *.nuspec paket.lock *.csproj project.assets.json
26
+ *.nuspec paket.lock *.csproj project.assets.json *.deps.json
27
27
  bower.json bentofile.yaml
28
- META.json META.yml
28
+ META.json META.yml cpanfile cpanfile.snapshot Makefile.PL Build.PL
29
29
  environment.yml environment.yaml
30
- cog.yaml versions.json MLmodel DESCRIPTION
30
+ cog.yaml versions.json MLmodel DESCRIPTION renv.lock
31
31
  pubspec.yaml pubspec.lock
32
32
  dub.json dub.sdl
33
- REQUIRE
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
36
  haxelib.json
37
37
  action.yml action.yaml .github/workflows/*.yml .github/workflows/*.yaml
38
38
  Dockerfile docker-compose*.yml docker-compose*.yaml
39
- dvc.yaml vcpkg.json
39
+ dvc.yaml vcpkg.json _generated-vcpkg-list.json
40
40
  Brewfile Brewfile.lock.json
41
41
  Modelfile
42
+ deno.json deno.jsonc deno.lock
43
+ conanfile.py conanfile.txt conan.lock
44
+ *.rockspec
45
+ *.nimble
46
+ *.cabal *cabal.config stack.yaml.lock cabal.project.freeze
47
+ Cartfile Cartfile.private Cartfile.resolved
48
+ project.clj
49
+ Package.swift Package.resolved
50
+ mix.exs mix.lock gleam.toml manifest.toml rebar.lock
51
+ flake.nix flake.lock nix/sources.json npins/sources.json
42
52
  ].freeze
43
53
 
44
54
  QUICK_MANIFEST_REGEX = Regexp.union(
@@ -58,6 +68,10 @@ module Git
58
68
  Config.configure_bibliothecary
59
69
  end
60
70
 
71
+ def generate_purl(ecosystem, name)
72
+ Ecosystems.generate_purl(ecosystem, name)
73
+ end
74
+
61
75
  # Quick check if any paths might be manifests (fast regex check)
62
76
  def might_have_manifests?(paths)
63
77
  paths.any? { |p| p.match?(QUICK_MANIFEST_REGEX) }
@@ -124,6 +138,7 @@ module Git
124
138
  ecosystem: result[:platform],
125
139
  kind: result[:kind],
126
140
  name: dep[:name],
141
+ purl: generate_purl(result[:platform], dep[:name]),
127
142
  change_type: "added",
128
143
  requirement: dep[:requirement],
129
144
  dependency_type: dep[:type]
@@ -133,6 +148,7 @@ module Git
133
148
  new_snapshot[key] = {
134
149
  ecosystem: result[:platform],
135
150
  kind: result[:kind],
151
+ purl: generate_purl(result[:platform], dep[:name]),
136
152
  requirement: dep[:requirement],
137
153
  dependency_type: dep[:type]
138
154
  }
@@ -160,6 +176,7 @@ module Git
160
176
  ecosystem: after_result[:platform],
161
177
  kind: after_result[:kind],
162
178
  name: name,
179
+ purl: generate_purl(after_result[:platform], name),
163
180
  change_type: "added",
164
181
  requirement: dep[:requirement],
165
182
  dependency_type: dep[:type]
@@ -169,6 +186,7 @@ module Git
169
186
  new_snapshot[key] = {
170
187
  ecosystem: after_result[:platform],
171
188
  kind: after_result[:kind],
189
+ purl: generate_purl(after_result[:platform], name),
172
190
  requirement: dep[:requirement],
173
191
  dependency_type: dep[:type]
174
192
  }
@@ -181,6 +199,7 @@ module Git
181
199
  ecosystem: before_result[:platform],
182
200
  kind: before_result[:kind],
183
201
  name: name,
202
+ purl: generate_purl(before_result[:platform], name),
184
203
  change_type: "removed",
185
204
  requirement: dep[:requirement],
186
205
  dependency_type: dep[:type]
@@ -200,6 +219,7 @@ module Git
200
219
  ecosystem: after_result[:platform],
201
220
  kind: after_result[:kind],
202
221
  name: name,
222
+ purl: generate_purl(after_result[:platform], name),
203
223
  change_type: "modified",
204
224
  requirement: after_dep[:requirement],
205
225
  previous_requirement: before_dep[:requirement],
@@ -210,6 +230,7 @@ module Git
210
230
  new_snapshot[key] = {
211
231
  ecosystem: after_result[:platform],
212
232
  kind: after_result[:kind],
233
+ purl: generate_purl(after_result[:platform], name),
213
234
  requirement: after_dep[:requirement],
214
235
  dependency_type: after_dep[:type]
215
236
  }
@@ -228,6 +249,7 @@ module Git
228
249
  ecosystem: result[:platform],
229
250
  kind: result[:kind],
230
251
  name: dep[:name],
252
+ purl: generate_purl(result[:platform], dep[:name]),
231
253
  change_type: "removed",
232
254
  requirement: dep[:requirement],
233
255
  dependency_type: dep[:type]
@@ -288,6 +310,116 @@ module Git
288
310
  @blob_cache[cache_key] = { result: result, hits: 0 }
289
311
  result
290
312
  end
313
+
314
+ # Parse all manifest files at a given commit (stateless)
315
+ def dependencies_at_commit(rugged_commit)
316
+ deps = []
317
+ manifest_paths = find_manifest_paths_in_tree(rugged_commit.tree)
318
+
319
+ manifest_paths.each do |path|
320
+ result = parse_manifest_at_commit(rugged_commit, path)
321
+ next unless result && result[:dependencies]
322
+
323
+ result[:dependencies].each do |dep|
324
+ deps << {
325
+ manifest_path: path,
326
+ manifest_kind: result[:kind],
327
+ name: dep[:name],
328
+ ecosystem: result[:platform],
329
+ kind: result[:kind],
330
+ purl: generate_purl(result[:platform], dep[:name]),
331
+ requirement: dep[:requirement],
332
+ dependency_type: dep[:type]
333
+ }
334
+ end
335
+ end
336
+
337
+ deps
338
+ end
339
+
340
+ # Compute changes between two commits (stateless)
341
+ def diff_commits(from_commit, to_commit)
342
+ from_deps = dependencies_at_commit(from_commit).group_by { |d| [d[:manifest_path], d[:name]] }
343
+ to_deps = dependencies_at_commit(to_commit).group_by { |d| [d[:manifest_path], d[:name]] }
344
+
345
+ added = []
346
+ modified = []
347
+ removed = []
348
+
349
+ # Find added and modified
350
+ to_deps.each do |key, to_list|
351
+ to_dep = to_list.first
352
+ if from_deps[key]
353
+ from_dep = from_deps[key].first
354
+ if from_dep[:requirement] != to_dep[:requirement]
355
+ modified << to_dep.merge(previous_requirement: from_dep[:requirement])
356
+ end
357
+ else
358
+ added << to_dep
359
+ end
360
+ end
361
+
362
+ # Find removed
363
+ from_deps.each do |key, from_list|
364
+ removed << from_list.first unless to_deps[key]
365
+ end
366
+
367
+ { added: added, modified: modified, removed: removed }
368
+ end
369
+
370
+ def find_manifest_paths_in_tree(tree, prefix = "")
371
+ paths = []
372
+
373
+ tree.each do |entry|
374
+ full_path = prefix.empty? ? entry[:name] : "#{prefix}/#{entry[:name]}"
375
+
376
+ if entry[:type] == :tree
377
+ subtree = repository.lookup(entry[:oid])
378
+ paths.concat(find_manifest_paths_in_tree(subtree, full_path))
379
+ elsif entry[:type] == :blob && full_path.match?(QUICK_MANIFEST_REGEX)
380
+ paths << full_path
381
+ end
382
+ end
383
+
384
+ identify_manifests_cached(paths)
385
+ end
386
+
387
+ def lookup(oid)
388
+ repository.lookup(oid)
389
+ end
390
+
391
+ # Pair manifest dependencies with their corresponding lockfile versions.
392
+ # Groups by directory + ecosystem + name, preferring lockfile over manifest.
393
+ # Can be called as instance method or class method.
394
+ def pair_manifests_with_lockfiles(deps)
395
+ self.class.pair_manifests_with_lockfiles(deps)
396
+ end
397
+
398
+ def self.pair_manifests_with_lockfiles(deps)
399
+ # Group by (directory, ecosystem, name)
400
+ groups = {}
401
+ deps.each do |dep|
402
+ dir = File.dirname(dep[:manifest_path])
403
+ dir = "" if dir == "."
404
+ key = [dir, dep[:ecosystem], dep[:name]]
405
+ groups[key] ||= []
406
+ groups[key] << dep
407
+ end
408
+
409
+ # For each group, pick the best entry (lockfile preferred)
410
+ groups.values.map do |group_deps|
411
+ lockfile_dep = group_deps.find { |d| d[:manifest_kind] == "lockfile" }
412
+ manifest_dep = group_deps.find { |d| d[:manifest_kind] == "manifest" }
413
+
414
+ # Prefer lockfile version, fall back to manifest
415
+ lockfile_dep || manifest_dep || group_deps.first
416
+ end.compact
417
+ end
418
+
419
+ # Filter to only lockfile dependencies
420
+ def self.lockfile_dependencies(deps)
421
+ deps.select { |d| d[:manifest_kind] == "lockfile" }
422
+ end
291
423
  end
292
424
  end
293
425
  end
data/lib/git/pkgs/cli.rb CHANGED
@@ -34,6 +34,9 @@ module Git
34
34
  "Analysis" => {
35
35
  "stats" => "Show dependency statistics",
36
36
  "stale" => "Show dependencies that haven't been updated"
37
+ },
38
+ "Security" => {
39
+ "vulns" => "Scan for known vulnerabilities"
37
40
  }
38
41
  }.freeze
39
42
 
@@ -99,13 +102,20 @@ module Git
99
102
  command = ALIASES.fetch(command, command)
100
103
  # Convert kebab-case or snake_case to PascalCase
101
104
  class_name = command.split(/[-_]/).map(&:capitalize).join
102
- command_class = Commands.const_get(class_name)
105
+
106
+ # Try with Command suffix first (e.g., VulnsCommand), then bare name
107
+ command_class = begin
108
+ Commands.const_get("#{class_name}Command")
109
+ rescue NameError
110
+ begin
111
+ Commands.const_get(class_name)
112
+ rescue NameError
113
+ $stderr.puts "Command '#{command}' not yet implemented"
114
+ exit 1
115
+ end
116
+ end
117
+
103
118
  command_class.new(@args).run
104
- rescue NameError => e
105
- # Only catch NameError for missing command class, not NoMethodError
106
- raise unless e.is_a?(NameError) && !e.is_a?(NoMethodError)
107
- $stderr.puts "Command '#{command}' not yet implemented"
108
- exit 1
109
119
  end
110
120
 
111
121
  def print_help
@@ -113,24 +113,6 @@ module Git
113
113
  end
114
114
  end
115
115
 
116
- def best_author(commit)
117
- authors = [commit.author_name] + parse_coauthors(commit.message)
118
-
119
- # Prefer human authors over bots
120
- human = authors.find { |a| !bot_author?(a) }
121
- human || authors.first
122
- end
123
-
124
- def parse_coauthors(message)
125
- return [] unless message
126
-
127
- message.scan(/^Co-authored-by:([^<]+)<[^>]+>/i).flatten.map(&:strip)
128
- end
129
-
130
- def bot_author?(name)
131
- name =~ /\[bot\]$|^dependabot|^renovate|^github-actions/i
132
- end
133
-
134
116
  def parse_options
135
117
  options = {}
136
118
 
@@ -13,9 +13,7 @@ module Git
13
13
 
14
14
  def run
15
15
  repo = Repository.new
16
- require_database(repo)
17
-
18
- Database.connect(repo.git_dir)
16
+ use_stateless = @options[:stateless] || !Database.exists?(repo.git_dir)
19
17
 
20
18
  from_ref, to_ref = parse_range_argument
21
19
  from_ref ||= @options[:from]
@@ -30,6 +28,46 @@ module Git
30
28
  error "Could not resolve '#{from_ref}'. Check that the ref exists." unless from_sha
31
29
  error "Could not resolve '#{to_ref}'. Check that the ref exists." unless to_sha
32
30
 
31
+ if use_stateless
32
+ run_stateless(repo, from_sha, to_sha)
33
+ else
34
+ run_with_database(repo, from_sha, to_sha)
35
+ end
36
+ end
37
+
38
+ def run_stateless(repo, from_sha, to_sha)
39
+ from_commit = repo.lookup(from_sha)
40
+ to_commit = repo.lookup(to_sha)
41
+
42
+ analyzer = Analyzer.new(repo)
43
+ diff = analyzer.diff_commits(from_commit, to_commit)
44
+
45
+ if @options[:ecosystem]
46
+ diff[:added] = diff[:added].select { |d| d[:ecosystem] == @options[:ecosystem] }
47
+ diff[:modified] = diff[:modified].select { |d| d[:ecosystem] == @options[:ecosystem] }
48
+ diff[:removed] = diff[:removed].select { |d| d[:ecosystem] == @options[:ecosystem] }
49
+ end
50
+
51
+ if diff[:added].empty? && diff[:modified].empty? && diff[:removed].empty?
52
+ if @options[:format] == "json"
53
+ require "json"
54
+ puts JSON.pretty_generate({ from: from_sha[0..7], to: to_sha[0..7], added: [], modified: [], removed: [] })
55
+ else
56
+ empty_result "No dependency changes between #{from_sha[0..7]} and #{to_sha[0..7]}"
57
+ end
58
+ return
59
+ end
60
+
61
+ if @options[:format] == "json"
62
+ output_json_stateless(from_sha, to_sha, diff)
63
+ else
64
+ paginate { output_text_stateless(from_sha, to_sha, diff) }
65
+ end
66
+ end
67
+
68
+ def run_with_database(repo, from_sha, to_sha)
69
+ Database.connect(repo.git_dir)
70
+
33
71
  from_commit = Models::Commit.find_or_create_from_repo(repo, from_sha)
34
72
  to_commit = Models::Commit.find_or_create_from_repo(repo, to_sha)
35
73
 
@@ -154,6 +192,81 @@ module Git
154
192
  puts JSON.pretty_generate(data)
155
193
  end
156
194
 
195
+ def output_text_stateless(from_sha, to_sha, diff)
196
+ puts "Dependency changes from #{from_sha[0..7]} to #{to_sha[0..7]}:"
197
+ puts
198
+
199
+ if diff[:added].any?
200
+ puts Color.green("Added:")
201
+ diff[:added].group_by { |d| d[:name] }.each do |name, pkg_changes|
202
+ latest = pkg_changes.last
203
+ puts Color.green(" + #{name} #{latest[:requirement]} (#{latest[:manifest_path]})")
204
+ end
205
+ puts
206
+ end
207
+
208
+ if diff[:modified].any?
209
+ puts Color.yellow("Modified:")
210
+ diff[:modified].group_by { |d| d[:name] }.each do |name, pkg_changes|
211
+ latest = pkg_changes.last
212
+ puts Color.yellow(" ~ #{name} #{latest[:previous_requirement]} -> #{latest[:requirement]}")
213
+ end
214
+ puts
215
+ end
216
+
217
+ if diff[:removed].any?
218
+ puts Color.red("Removed:")
219
+ diff[:removed].group_by { |d| d[:name] }.each do |name, pkg_changes|
220
+ latest = pkg_changes.last
221
+ puts Color.red(" - #{name} (was #{latest[:requirement]})")
222
+ end
223
+ puts
224
+ end
225
+
226
+ added_count = Color.green("+#{diff[:added].map { |d| d[:name] }.uniq.count}")
227
+ removed_count = Color.red("-#{diff[:removed].map { |d| d[:name] }.uniq.count}")
228
+ modified_count = Color.yellow("~#{diff[:modified].map { |d| d[:name] }.uniq.count}")
229
+ puts "Summary: #{added_count} #{removed_count} #{modified_count}"
230
+ end
231
+
232
+ def output_json_stateless(from_sha, to_sha, diff)
233
+ require "json"
234
+
235
+ format_change = lambda do |change|
236
+ {
237
+ name: change[:name],
238
+ ecosystem: change[:ecosystem],
239
+ requirement: change[:requirement],
240
+ manifest: change[:manifest_path]
241
+ }
242
+ end
243
+
244
+ format_modified = lambda do |change|
245
+ {
246
+ name: change[:name],
247
+ ecosystem: change[:ecosystem],
248
+ previous_requirement: change[:previous_requirement],
249
+ requirement: change[:requirement],
250
+ manifest: change[:manifest_path]
251
+ }
252
+ end
253
+
254
+ data = {
255
+ from: from_sha[0..7],
256
+ to: to_sha[0..7],
257
+ added: diff[:added].map { |c| format_change.call(c) },
258
+ modified: diff[:modified].map { |c| format_modified.call(c) },
259
+ removed: diff[:removed].map { |c| format_change.call(c) },
260
+ summary: {
261
+ added: diff[:added].map { |d| d[:name] }.uniq.count,
262
+ modified: diff[:modified].map { |d| d[:name] }.uniq.count,
263
+ removed: diff[:removed].map { |d| d[:name] }.uniq.count
264
+ }
265
+ }
266
+
267
+ puts JSON.pretty_generate(data)
268
+ end
269
+
157
270
  def parse_range_argument
158
271
  return [nil, nil] if @args.empty?
159
272
 
@@ -183,11 +296,11 @@ module Git
183
296
  opts.separator " git pkgs diff --from=v1.0 --to=v2.0"
184
297
  opts.separator ""
185
298
 
186
- opts.on("-f", "--from=REF", "Start commit") do |v|
299
+ opts.on("--from=REF", "Start commit") do |v|
187
300
  options[:from] = v
188
301
  end
189
302
 
190
- opts.on("-t", "--to=REF", "End commit (default: HEAD)") do |v|
303
+ opts.on("--to=REF", "End commit (default: HEAD)") do |v|
191
304
  options[:to] = v
192
305
  end
193
306
 
@@ -203,6 +316,10 @@ module Git
203
316
  options[:no_pager] = true
204
317
  end
205
318
 
319
+ opts.on("--stateless", "Parse manifests directly without database") do
320
+ options[:stateless] = true
321
+ end
322
+
206
323
  opts.on("-h", "--help", "Show this help") do
207
324
  puts opts
208
325
  exit