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.
- checksums.yaml +4 -4
- data/.gitattributes +28 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +15 -0
- data/Dockerfile +18 -0
- data/Formula/git-pkgs.rb +28 -0
- data/README.md +36 -4
- data/lib/git/pkgs/analyzer.rb +141 -9
- data/lib/git/pkgs/cli.rb +16 -6
- data/lib/git/pkgs/commands/blame.rb +0 -18
- data/lib/git/pkgs/commands/diff.rb +122 -5
- data/lib/git/pkgs/commands/diff_driver.rb +24 -4
- data/lib/git/pkgs/commands/init.rb +5 -0
- data/lib/git/pkgs/commands/list.rb +60 -15
- data/lib/git/pkgs/commands/show.rb +126 -3
- data/lib/git/pkgs/commands/stale.rb +6 -2
- data/lib/git/pkgs/commands/update.rb +3 -0
- data/lib/git/pkgs/commands/vulns/base.rb +354 -0
- data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
- data/lib/git/pkgs/commands/vulns/diff.rb +172 -0
- data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
- data/lib/git/pkgs/commands/vulns/history.rb +345 -0
- data/lib/git/pkgs/commands/vulns/log.rb +218 -0
- data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
- data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
- data/lib/git/pkgs/commands/vulns/show.rb +216 -0
- data/lib/git/pkgs/commands/vulns/sync.rb +108 -0
- data/lib/git/pkgs/commands/vulns.rb +50 -0
- data/lib/git/pkgs/config.rb +8 -1
- data/lib/git/pkgs/database.rb +135 -5
- data/lib/git/pkgs/ecosystems.rb +83 -0
- data/lib/git/pkgs/models/package.rb +54 -0
- data/lib/git/pkgs/models/vulnerability.rb +300 -0
- data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
- data/lib/git/pkgs/osv_client.rb +151 -0
- data/lib/git/pkgs/output.rb +22 -0
- data/lib/git/pkgs/version.rb +1 -1
- data/lib/git/pkgs.rb +6 -0
- metadata +66 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0f9ff177f3dd7cbb4f5591a0c0f2b936d5fb98629329294dae9f57380a69709e
|
|
4
|
+
data.tar.gz: e8760e948aea3b7252176fc6a0b4bb0d74432ad05e4fa20693468b9440b126bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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"]
|
data/Formula/git-pkgs.rb
ADDED
|
@@ -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
|
|
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
|
|
data/lib/git/pkgs/analyzer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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("
|
|
299
|
+
opts.on("--from=REF", "Start commit") do |v|
|
|
187
300
|
options[:from] = v
|
|
188
301
|
end
|
|
189
302
|
|
|
190
|
-
opts.on("
|
|
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
|