git-pkgs 0.6.2 → 0.8.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +25 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +90 -6
  8. data/lib/git/pkgs/analyzer.rb +142 -10
  9. data/lib/git/pkgs/cli.rb +20 -8
  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 +30 -4
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/licenses.rb +378 -0
  15. data/lib/git/pkgs/commands/list.rb +60 -15
  16. data/lib/git/pkgs/commands/outdated.rb +312 -0
  17. data/lib/git/pkgs/commands/show.rb +126 -3
  18. data/lib/git/pkgs/commands/stale.rb +6 -2
  19. data/lib/git/pkgs/commands/update.rb +3 -0
  20. data/lib/git/pkgs/commands/vulns/base.rb +358 -0
  21. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  22. data/lib/git/pkgs/commands/vulns/diff.rb +173 -0
  23. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  24. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  25. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  26. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  27. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  28. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  29. data/lib/git/pkgs/commands/vulns/sync.rb +110 -0
  30. data/lib/git/pkgs/commands/vulns.rb +50 -0
  31. data/lib/git/pkgs/config.rb +8 -1
  32. data/lib/git/pkgs/database.rb +151 -5
  33. data/lib/git/pkgs/ecosystems.rb +83 -0
  34. data/lib/git/pkgs/ecosystems_client.rb +96 -0
  35. data/lib/git/pkgs/models/dependency_change.rb +8 -0
  36. data/lib/git/pkgs/models/dependency_snapshot.rb +8 -0
  37. data/lib/git/pkgs/models/package.rb +92 -0
  38. data/lib/git/pkgs/models/version.rb +27 -0
  39. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  40. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  41. data/lib/git/pkgs/osv_client.rb +151 -0
  42. data/lib/git/pkgs/output.rb +22 -0
  43. data/lib/git/pkgs/purl_helper.rb +56 -0
  44. data/lib/git/pkgs/spinner.rb +46 -0
  45. data/lib/git/pkgs/version.rb +1 -1
  46. data/lib/git/pkgs.rb +12 -0
  47. metadata +72 -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: e23d09227a873e67670fa403a6c81c4f93a301cfda036f62d8b02d4d7f1e0ce5
4
+ data.tar.gz: ae4c878fa0cca631cb5d0485ab4a5caa999300ba1afc564c6d23461527a487cf
5
5
  SHA512:
6
- metadata.gz: 1488be447b21af773fc7aa6309d8981bb78b45134a755f56bff38f0cac3c92d861f867fb62c70201c1eec9c1f4b40f042a6804f416f64c952bc008678c756831
7
- data.tar.gz: 998ca9734325cef27dcd3e09a1c7e68acf212a6974f0bd61adb3257322d92881e1c1cb8995be3935b32253f02a94b15f3e531bc40bf4cd1111933b24cc863275
6
+ metadata.gz: 5a675b67669d345dc39419219adfe51d2c36b1167be446b7f1b937eab42b92c0d935ebd8102e534585760c5944de6059ac722818558090c504758fc4ca7d9388
7
+ data.tar.gz: 7cb84ae314e29ed3cbf0c360ddcd79f43174ce153fca35af476693a6f37a98c84e297b8b3bf3543150e546df2459e69fbcae371959cd4dac04d2aa7d139f1969
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,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-01-14
4
+
5
+ - `git pkgs outdated` command to find dependencies with newer versions available in registries
6
+ - `git pkgs licenses` command to check dependency licenses with compliance options (--permissive, --allow, --deny)
7
+ - ecosyste.ms client for fetching package metadata (latest versions, licenses)
8
+ - Package and Version models for storing enrichment data
9
+ - Spinner utility for progress feedback during network operations
10
+ - PURL helper for standardized package URLs
11
+ - `outdated` is no longer an alias for `stale` (now a separate command)
12
+
13
+ ## [0.7.0] - 2026-01-09
14
+
15
+ - `git pkgs vulns` subcommand for vulnerability scanning via OSV API
16
+ - `git pkgs vulns scan` to scan dependencies for known vulnerabilities
17
+ - `git pkgs vulns show` to display details for a specific vulnerability
18
+ - `git pkgs vulns sync` to prefetch vulnerability data for all packages
19
+ - `git pkgs vulns exposure` to analyze vulnerability exposure over time
20
+ - `git pkgs vulns praise` to show resolved vulnerabilities with attribution
21
+ - SARIF output format for CI integration (`--format=sarif`)
22
+ - Docker container support for running git-pkgs without local Ruby installation
23
+ - `list` command now shows locked versions and manifest kind
24
+ - `--stateless` flag for `list`, `show`, and `diff` commands (auto-enabled when no database exists)
25
+ - Update ecosystems-bibliothecary to ~> 15.2
26
+ - Fix `-f` flag conflict in `diff` command (was defined for both `--from` and `--format`)
27
+
3
28
  ## [0.6.2] - 2026-01-06
4
29
 
5
30
  - `--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.7.0.tar.gz"
5
+ sha256 "5c5aebf75e9570945b324777e5fa33cd5e35d31f6172c2415a4bd91db02477cc"
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,38 @@
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
 
9
- 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.
11
+ For best results, commit your lockfiles. Manifests show version ranges but lockfiles show what actually got installed, including transitive dependencies.
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. 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.
10
16
 
11
17
  ## Installation
12
18
 
19
+ ```bash
20
+ brew tap andrew/git-pkgs https://github.com/andrew/git-pkgs
21
+ brew install git-pkgs
22
+ ```
23
+
24
+ Or with RubyGems:
25
+
13
26
  ```bash
14
27
  gem install git-pkgs
15
28
  ```
16
29
 
30
+ Or using Docker:
31
+
32
+ ```bash
33
+ docker build -t git-pkgs .
34
+ docker run -it --rm -v $(pwd):/mnt git-pkgs <subcommand>
35
+ ```
36
+
17
37
  ## Quick start
18
38
 
19
39
  ```bash
@@ -27,6 +47,8 @@ git pkgs history rails # track a specific package
27
47
  git pkgs why rails # why was this added?
28
48
  git pkgs diff --from=HEAD~10 # what changed recently?
29
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
30
52
  ```
31
53
 
32
54
  ## Commands
@@ -96,6 +118,7 @@ git pkgs list
96
118
  git pkgs list --commit=abc123
97
119
  git pkgs list --ecosystem=rubygems
98
120
  git pkgs list --manifest=Gemfile
121
+ git pkgs list --stateless # parse manifests directly, no database needed
99
122
  ```
100
123
 
101
124
  Example output:
@@ -235,16 +258,76 @@ This shows dependencies grouped by type (runtime, development, etc).
235
258
  git pkgs stale # list deps by how long since last touched
236
259
  git pkgs stale --days=365 # only show deps untouched for a year
237
260
  git pkgs stale --ecosystem=npm # filter by ecosystem
238
- git pkgs outdated # alias for stale
239
261
  ```
240
262
 
241
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.
242
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
+
288
+ ### Vulnerability scanning
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
+
292
+ ```bash
293
+ git pkgs vulns # scan current dependencies
294
+ git pkgs vulns v1.0.0 # scan at a tag, branch, or commit
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
303
+ git pkgs vulns blame # who introduced each vulnerability
304
+ git pkgs vulns blame --all-time # include fixed vulnerabilities
305
+ git pkgs vulns praise # who fixed vulnerabilities
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
312
+ ```
313
+
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
+
243
325
  ### Diff between commits
244
326
 
245
327
  ```bash
246
328
  git pkgs diff --from=abc123 --to=def456
247
329
  git pkgs diff --from=HEAD~10
330
+ git pkgs diff main..feature --stateless # no database needed
248
331
  ```
249
332
 
250
333
  This shows added, removed, and modified packages with version info.
@@ -255,6 +338,7 @@ This shows added, removed, and modified packages with version info.
255
338
  git pkgs show # show dependency changes in HEAD
256
339
  git pkgs show abc123 # specific commit
257
340
  git pkgs show HEAD~5 # relative ref
341
+ git pkgs show --stateless # no database needed
258
342
  ```
259
343
 
260
344
  Like `git show` but for dependencies. Shows what was added, modified, or removed in a single commit.
@@ -325,7 +409,7 @@ Useful for understanding the [database structure](docs/schema.md) or generating
325
409
 
326
410
  ### CI usage
327
411
 
328
- You can run git-pkgs in CI to show dependency changes in pull requests:
412
+ You can run git-pkgs in CI to show dependency changes in pull requests. Use `--stateless` to skip database initialization for faster runs:
329
413
 
330
414
  ```yaml
331
415
  # .github/workflows/deps.yml
@@ -344,8 +428,7 @@ jobs:
344
428
  with:
345
429
  ruby-version: '3.3'
346
430
  - run: gem install git-pkgs
347
- - run: git pkgs init
348
- - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD
431
+ - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD --stateless
349
432
  ```
350
433
 
351
434
  ### Diff driver
@@ -430,7 +513,7 @@ Optimizations:
430
513
 
431
514
  git-pkgs uses [ecosystems-bibliothecary](https://github.com/ecosyste-ms/bibliothecary) for parsing, supporting:
432
515
 
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
516
+ 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
517
 
435
518
  SBOM formats (CycloneDX, SPDX) are not supported as they duplicate information from the actual lockfiles.
436
519
 
@@ -465,6 +548,7 @@ Git::Pkgs::Database.connect(repo_git_dir)
465
548
  Git::Pkgs::Models::DependencyChange.where(name: "rails").all
466
549
  ```
467
550
 
551
+
468
552
  ## Contributing
469
553
 
470
554
  Bug reports, feature requests, and pull requests are welcome. If you're unsure about a change, open an issue first to discuss it.
@@ -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
- 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
- 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
@@ -33,13 +33,18 @@ 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
+ },
40
+ "Security" => {
41
+ "vulns" => "Scan for known vulnerabilities"
37
42
  }
38
43
  }.freeze
39
44
 
40
45
  COMMANDS = COMMAND_GROUPS.values.flat_map(&:keys).freeze
41
46
  COMMAND_DESCRIPTIONS = COMMAND_GROUPS.values.reduce({}, :merge).freeze
42
- ALIASES = { "praise" => "blame", "outdated" => "stale" }.freeze
47
+ ALIASES = { "praise" => "blame" }.freeze
43
48
 
44
49
  def self.run(args)
45
50
  new(args).run
@@ -99,13 +104,20 @@ module Git
99
104
  command = ALIASES.fetch(command, command)
100
105
  # Convert kebab-case or snake_case to PascalCase
101
106
  class_name = command.split(/[-_]/).map(&:capitalize).join
102
- command_class = Commands.const_get(class_name)
107
+
108
+ # Try with Command suffix first (e.g., VulnsCommand), then bare name
109
+ command_class = begin
110
+ Commands.const_get("#{class_name}Command")
111
+ rescue NameError
112
+ begin
113
+ Commands.const_get(class_name)
114
+ rescue NameError
115
+ $stderr.puts "Command '#{command}' not yet implemented"
116
+ exit 1
117
+ end
118
+ end
119
+
103
120
  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
121
  end
110
122
 
111
123
  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