git-pkgs 0.6.1 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +23 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +69 -5
  8. data/lib/git/pkgs/analyzer.rb +140 -8
  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 +181 -7
  12. data/lib/git/pkgs/commands/diff_driver.rb +25 -5
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/list.rb +68 -15
  15. data/lib/git/pkgs/commands/show.rb +126 -3
  16. data/lib/git/pkgs/commands/stale.rb +38 -4
  17. data/lib/git/pkgs/commands/tree.rb +44 -2
  18. data/lib/git/pkgs/commands/update.rb +3 -0
  19. data/lib/git/pkgs/commands/vulns/base.rb +354 -0
  20. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  21. data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
  22. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  23. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  24. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  25. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  26. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  27. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  28. data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
  29. data/lib/git/pkgs/commands/vulns.rb +50 -0
  30. data/lib/git/pkgs/commands/why.rb +40 -1
  31. data/lib/git/pkgs/config.rb +10 -2
  32. data/lib/git/pkgs/database.rb +135 -5
  33. data/lib/git/pkgs/ecosystems.rb +83 -0
  34. data/lib/git/pkgs/models/package.rb +54 -0
  35. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  36. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  37. data/lib/git/pkgs/osv_client.rb +151 -0
  38. data/lib/git/pkgs/output.rb +22 -0
  39. data/lib/git/pkgs/version.rb +1 -1
  40. data/lib/git/pkgs.rb +77 -0
  41. metadata +66 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da979135545dc93ed1b362560e7a5f9659097512603a522e6665bc2268b46466
4
- data.tar.gz: 7b1a2838c823ec205761288f9126f0015597e890e7ba768c3e071ecdd14efb4b
3
+ metadata.gz: 0f9ff177f3dd7cbb4f5591a0c0f2b936d5fb98629329294dae9f57380a69709e
4
+ data.tar.gz: e8760e948aea3b7252176fc6a0b4bb0d74432ad05e4fa20693468b9440b126bd
5
5
  SHA512:
6
- metadata.gz: 801260e17dabe686670fbcc10a5c0e760ef5f39df0273029c1f3ab9ef2502f0f7ea78d6a3cfc633fb547f6df604d2d895411c5921c345365d6ea85dd12578403
7
- data.tar.gz: 138cdd14d12798c7d1d2ea6c7c7cd920805c008f10ce4057c8f3c9ebf6db7b7191720d88cb228e53786e50b7c077c17c9014c73be037a1582592965ef46889f3
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,28 @@
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
+
18
+ ## [0.6.2] - 2026-01-06
19
+
20
+ - `--format=json` support for `diff`, `tree`, `stale`, and `why` commands
21
+ - Ignore go.sum (checksums only), treat go.mod as lockfile
22
+ - Update ecosystems-bibliothecary to ~> 15.1
23
+ - `--manifest` filter for `list` command to filter by manifest path
24
+ - Stateless parsing API for forge integration (`Git::Pkgs.parse_file`, `parse_files`, `diff_file`)
25
+
3
26
  ## [0.6.1] - 2026-01-05
4
27
 
5
28
  - Fix `stats` command crash on most changed dependencies query
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
@@ -95,6 +113,8 @@ Snapshot Coverage
95
113
  git pkgs list
96
114
  git pkgs list --commit=abc123
97
115
  git pkgs list --ecosystem=rubygems
116
+ git pkgs list --manifest=Gemfile
117
+ git pkgs list --stateless # parse manifests directly, no database needed
98
118
  ```
99
119
 
100
120
  Example output:
@@ -239,11 +259,24 @@ git pkgs outdated # alias for stale
239
259
 
240
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.
241
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
+
242
274
  ### Diff between commits
243
275
 
244
276
  ```bash
245
277
  git pkgs diff --from=abc123 --to=def456
246
278
  git pkgs diff --from=HEAD~10
279
+ git pkgs diff main..feature --stateless # no database needed
247
280
  ```
248
281
 
249
282
  This shows added, removed, and modified packages with version info.
@@ -254,6 +287,7 @@ This shows added, removed, and modified packages with version info.
254
287
  git pkgs show # show dependency changes in HEAD
255
288
  git pkgs show abc123 # specific commit
256
289
  git pkgs show HEAD~5 # relative ref
290
+ git pkgs show --stateless # no database needed
257
291
  ```
258
292
 
259
293
  Like `git show` but for dependencies. Shows what was added, modified, or removed in a single commit.
@@ -324,7 +358,7 @@ Useful for understanding the [database structure](docs/schema.md) or generating
324
358
 
325
359
  ### CI usage
326
360
 
327
- 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:
328
362
 
329
363
  ```yaml
330
364
  # .github/workflows/deps.yml
@@ -343,8 +377,7 @@ jobs:
343
377
  with:
344
378
  ruby-version: '3.3'
345
379
  - run: gem install git-pkgs
346
- - run: git pkgs init
347
- - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD
380
+ - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD --stateless
348
381
  ```
349
382
 
350
383
  ### Diff driver
@@ -416,7 +449,7 @@ git config --add pkgs.ignoredFiles test/fixtures/package.json
416
449
 
417
450
  ## Performance
418
451
 
419
- Benchmarked on an M1 MacBook Pro analyzing [octobox](https://github.com/octobox/octobox) (5191 commits, 8 years of history): init takes about 18 seconds at roughly 300 commits/sec, producing an 8.3 MB database. About half the commits (2531) had dependency changes.
452
+ Benchmarked on an M1 MacBook Pro analyzing [octobox](https://github.com/octobox/octobox) (5193 commits, 8 years of history): init takes about 5 seconds at roughly 1000 commits/sec, producing a 4.8 MB database. About half the commits (2439) had dependency changes.
420
453
 
421
454
  Optimizations:
422
455
  - Bulk inserts with transaction batching (500 commits per transaction)
@@ -429,10 +462,41 @@ Optimizations:
429
462
 
430
463
  git-pkgs uses [ecosystems-bibliothecary](https://github.com/ecosyste-ms/bibliothecary) for parsing, supporting:
431
464
 
432
- 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
433
466
 
434
467
  SBOM formats (CycloneDX, SPDX) are not supported as they duplicate information from the actual lockfiles.
435
468
 
469
+ ## Ruby API
470
+
471
+ For embedding in other tools (like forges), git-pkgs provides a stateless parsing API that doesn't require initializing a database:
472
+
473
+ ```ruby
474
+ require "git/pkgs"
475
+
476
+ # Parse a single manifest file
477
+ result = Git::Pkgs.parse_file("Gemfile", content)
478
+ # => { platform: "rubygems", kind: "manifest", dependencies: [...] }
479
+
480
+ # Parse multiple files at once
481
+ results = Git::Pkgs.parse_files({
482
+ "Gemfile" => gemfile_content,
483
+ "package.json" => package_json_content
484
+ })
485
+
486
+ # Diff two versions of a manifest
487
+ diff = Git::Pkgs.diff_file("Gemfile", old_content, new_content)
488
+ # => { path: "Gemfile", platform: "rubygems", added: [...], modified: [...], removed: [...] }
489
+ ```
490
+
491
+ The diff_file method returns modified dependencies with a `previous_requirement` field showing the old version.
492
+
493
+ For database queries, connect to an existing database and use the Sequel models directly:
494
+
495
+ ```ruby
496
+ Git::Pkgs::Database.connect(repo_git_dir)
497
+ Git::Pkgs::Models::DependencyChange.where(name: "rails").all
498
+ ```
499
+
436
500
  ## Contributing
437
501
 
438
502
  Bug reports, feature requests, and pull requests are welcome. If you're unsure about a change, open an issue first to discuss it.
@@ -12,10 +12,10 @@ 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
21
  go.mod go.sum glide.yaml glide.lock Godeps Godeps/Godeps.json
@@ -23,22 +23,32 @@ module Git
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