git-pkgs 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da979135545dc93ed1b362560e7a5f9659097512603a522e6665bc2268b46466
4
- data.tar.gz: 7b1a2838c823ec205761288f9126f0015597e890e7ba768c3e071ecdd14efb4b
3
+ metadata.gz: ec8cddb19542e0519e69be68a3f0b65424c940718ba3bd5304ed93f457078ec7
4
+ data.tar.gz: bce2b1006ad63c0f2269d9321948acfa9cde600e8f8e41b3e606768c8f8b6178
5
5
  SHA512:
6
- metadata.gz: 801260e17dabe686670fbcc10a5c0e760ef5f39df0273029c1f3ab9ef2502f0f7ea78d6a3cfc633fb547f6df604d2d895411c5921c345365d6ea85dd12578403
7
- data.tar.gz: 138cdd14d12798c7d1d2ea6c7c7cd920805c008f10ce4057c8f3c9ebf6db7b7191720d88cb228e53786e50b7c077c17c9014c73be037a1582592965ef46889f3
6
+ metadata.gz: 1488be447b21af773fc7aa6309d8981bb78b45134a755f56bff38f0cac3c92d861f867fb62c70201c1eec9c1f4b40f042a6804f416f64c952bc008678c756831
7
+ data.tar.gz: 998ca9734325cef27dcd3e09a1c7e68acf212a6974f0bd61adb3257322d92881e1c1cb8995be3935b32253f02a94b15f3e531bc40bf4cd1111933b24cc863275
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.2] - 2026-01-06
4
+
5
+ - `--format=json` support for `diff`, `tree`, `stale`, and `why` commands
6
+ - Ignore go.sum (checksums only), treat go.mod as lockfile
7
+ - Update ecosystems-bibliothecary to ~> 15.1
8
+ - `--manifest` filter for `list` command to filter by manifest path
9
+ - Stateless parsing API for forge integration (`Git::Pkgs.parse_file`, `parse_files`, `diff_file`)
10
+
3
11
  ## [0.6.1] - 2026-01-05
4
12
 
5
13
  - Fix `stats` command crash on most changed dependencies query
data/README.md CHANGED
@@ -95,6 +95,7 @@ Snapshot Coverage
95
95
  git pkgs list
96
96
  git pkgs list --commit=abc123
97
97
  git pkgs list --ecosystem=rubygems
98
+ git pkgs list --manifest=Gemfile
98
99
  ```
99
100
 
100
101
  Example output:
@@ -416,7 +417,7 @@ git config --add pkgs.ignoredFiles test/fixtures/package.json
416
417
 
417
418
  ## Performance
418
419
 
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.
420
+ 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
421
 
421
422
  Optimizations:
422
423
  - Bulk inserts with transaction batching (500 commits per transaction)
@@ -433,6 +434,37 @@ Actions, BentoML, Bower, Cargo, Carthage, Clojars, CocoaPods, Cog, Conda, CPAN,
433
434
 
434
435
  SBOM formats (CycloneDX, SPDX) are not supported as they duplicate information from the actual lockfiles.
435
436
 
437
+ ## Ruby API
438
+
439
+ For embedding in other tools (like forges), git-pkgs provides a stateless parsing API that doesn't require initializing a database:
440
+
441
+ ```ruby
442
+ require "git/pkgs"
443
+
444
+ # Parse a single manifest file
445
+ result = Git::Pkgs.parse_file("Gemfile", content)
446
+ # => { platform: "rubygems", kind: "manifest", dependencies: [...] }
447
+
448
+ # Parse multiple files at once
449
+ results = Git::Pkgs.parse_files({
450
+ "Gemfile" => gemfile_content,
451
+ "package.json" => package_json_content
452
+ })
453
+
454
+ # Diff two versions of a manifest
455
+ diff = Git::Pkgs.diff_file("Gemfile", old_content, new_content)
456
+ # => { path: "Gemfile", platform: "rubygems", added: [...], modified: [...], removed: [...] }
457
+ ```
458
+
459
+ The diff_file method returns modified dependencies with a `previous_requirement` field showing the old version.
460
+
461
+ For database queries, connect to an existing database and use the Sequel models directly:
462
+
463
+ ```ruby
464
+ Git::Pkgs::Database.connect(repo_git_dir)
465
+ Git::Pkgs::Models::DependencyChange.where(name: "rails").all
466
+ ```
467
+
436
468
  ## Contributing
437
469
 
438
470
  Bug reports, feature requests, and pull requests are welcome. If you're unsure about a change, open an issue first to discuss it.
@@ -18,7 +18,7 @@ module Git
18
18
  pom.xml ivy.xml build.gradle build.gradle.kts gradle-dependencies-q.txt
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 go.sum glide.yaml glide.lock Godeps Godeps/Godeps.json
21
+ go.mod 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
@@ -51,11 +51,20 @@ module Git
51
51
  changes_list = changes.all
52
52
 
53
53
  if changes_list.empty?
54
- empty_result "No dependency changes between #{from_commit.short_sha} and #{to_commit.short_sha}"
54
+ if @options[:format] == "json"
55
+ require "json"
56
+ puts JSON.pretty_generate({ from: from_commit.short_sha, to: to_commit.short_sha, added: [], modified: [], removed: [] })
57
+ else
58
+ empty_result "No dependency changes between #{from_commit.short_sha} and #{to_commit.short_sha}"
59
+ end
55
60
  return
56
61
  end
57
62
 
58
- paginate { output_text(from_commit, to_commit, changes_list) }
63
+ if @options[:format] == "json"
64
+ output_json(from_commit, to_commit, changes_list)
65
+ else
66
+ paginate { output_text(from_commit, to_commit, changes_list) }
67
+ end
59
68
  end
60
69
 
61
70
  def output_text(from_commit, to_commit, changes)
@@ -101,6 +110,50 @@ module Git
101
110
  puts "Summary: #{added_count} #{removed_count} #{modified_count}"
102
111
  end
103
112
 
113
+ def output_json(from_commit, to_commit, changes)
114
+ require "json"
115
+
116
+ added = changes.select { |c| c.change_type == "added" }
117
+ modified = changes.select { |c| c.change_type == "modified" }
118
+ removed = changes.select { |c| c.change_type == "removed" }
119
+
120
+ format_change = lambda do |change|
121
+ {
122
+ name: change.name,
123
+ ecosystem: change.ecosystem,
124
+ requirement: change.requirement,
125
+ manifest: change.manifest.path,
126
+ commit: change.commit.short_sha,
127
+ date: change.commit.committed_at.iso8601
128
+ }
129
+ end
130
+
131
+ format_modified = lambda do |first, latest|
132
+ {
133
+ name: first.name,
134
+ ecosystem: first.ecosystem,
135
+ previous_requirement: first.previous_requirement,
136
+ requirement: latest.requirement,
137
+ manifest: latest.manifest.path
138
+ }
139
+ end
140
+
141
+ data = {
142
+ from: from_commit.short_sha,
143
+ to: to_commit.short_sha,
144
+ added: added.group_by(&:name).map { |_name, pkg_changes| format_change.call(pkg_changes.last) },
145
+ modified: modified.group_by(&:name).map { |_name, pkg_changes| format_modified.call(pkg_changes.first, pkg_changes.last) },
146
+ removed: removed.group_by(&:name).map { |_name, pkg_changes| format_change.call(pkg_changes.last) },
147
+ summary: {
148
+ added: added.map(&:name).uniq.count,
149
+ modified: modified.map(&:name).uniq.count,
150
+ removed: removed.map(&:name).uniq.count
151
+ }
152
+ }
153
+
154
+ puts JSON.pretty_generate(data)
155
+ end
156
+
104
157
  def parse_range_argument
105
158
  return [nil, nil] if @args.empty?
106
159
 
@@ -142,6 +195,10 @@ module Git
142
195
  options[:ecosystem] = v
143
196
  end
144
197
 
198
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
199
+ options[:format] = v
200
+ end
201
+
145
202
  opts.on("--no-pager", "Do not pipe output into a pager") do
146
203
  options[:no_pager] = true
147
204
  end
@@ -23,7 +23,7 @@ module Git
23
23
  composer.lock
24
24
  gems.locked
25
25
  glide.lock
26
- go.sum
26
+ go.mod
27
27
  mix.lock
28
28
  npm-shrinkwrap.json
29
29
  package-lock.json
@@ -25,6 +25,10 @@ module Git
25
25
  deps = compute_dependencies_at_commit(target_commit, repo)
26
26
 
27
27
  # Apply filters
28
+ if @options[:manifest]
29
+ deps = deps.select { |d| d[:manifest_path] == @options[:manifest] }
30
+ end
31
+
28
32
  if @options[:ecosystem]
29
33
  deps = deps.select { |d| d[:ecosystem] == @options[:ecosystem] }
30
34
  end
@@ -133,6 +137,10 @@ module Git
133
137
  options[:ecosystem] = v
134
138
  end
135
139
 
140
+ opts.on("-m", "--manifest=PATH", "Filter by manifest path") do |v|
141
+ options[:manifest] = v
142
+ end
143
+
136
144
  opts.on("-t", "--type=TYPE", "Filter by dependency type") do |v|
137
145
  options[:type] = v
138
146
  end
@@ -91,11 +91,20 @@ module Git
91
91
  end
92
92
 
93
93
  if outdated_data.empty?
94
- empty_result "All dependencies have been updated recently"
94
+ if @options[:format] == "json"
95
+ require "json"
96
+ puts JSON.pretty_generate([])
97
+ else
98
+ empty_result "All dependencies have been updated recently"
99
+ end
95
100
  return
96
101
  end
97
102
 
98
- paginate { output_text(outdated_data) }
103
+ if @options[:format] == "json"
104
+ output_json(outdated_data)
105
+ else
106
+ paginate { output_text(outdated_data) }
107
+ end
99
108
  end
100
109
 
101
110
  def output_text(outdated_data)
@@ -112,6 +121,23 @@ module Git
112
121
  end
113
122
  end
114
123
 
124
+ def output_json(outdated_data)
125
+ require "json"
126
+
127
+ data = outdated_data.map do |dep|
128
+ {
129
+ name: dep[:name],
130
+ ecosystem: dep[:ecosystem],
131
+ requirement: dep[:requirement],
132
+ manifest: dep[:manifest],
133
+ last_updated: dep[:last_updated].iso8601,
134
+ days_ago: dep[:days_ago]
135
+ }
136
+ end
137
+
138
+ puts JSON.pretty_generate(data)
139
+ end
140
+
115
141
  def parse_options
116
142
  options = {}
117
143
 
@@ -130,6 +156,10 @@ module Git
130
156
  options[:days] = v
131
157
  end
132
158
 
159
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
160
+ options[:format] = v
161
+ end
162
+
133
163
  opts.on("--no-pager", "Do not pipe output into a pager") do
134
164
  options[:no_pager] = true
135
165
  end
@@ -33,14 +33,23 @@ module Git
33
33
  snapshots_list = snapshots.all
34
34
 
35
35
  if snapshots_list.empty?
36
- empty_result "No dependencies found"
36
+ if @options[:format] == "json"
37
+ require "json"
38
+ puts JSON.pretty_generate({ manifests: [], total: 0 })
39
+ else
40
+ empty_result "No dependencies found"
41
+ end
37
42
  return
38
43
  end
39
44
 
40
45
  # Group by manifest and build tree
41
46
  grouped = snapshots_list.group_by { |s| s.manifest }
42
47
 
43
- paginate { output_text(grouped, snapshots_list) }
48
+ if @options[:format] == "json"
49
+ output_json(grouped, snapshots_list)
50
+ else
51
+ paginate { output_text(grouped, snapshots_list) }
52
+ end
44
53
  end
45
54
 
46
55
  def output_text(grouped, snapshots)
@@ -64,6 +73,35 @@ module Git
64
73
  puts "Total: #{snapshots.count} dependencies across #{grouped.keys.count} manifest(s)"
65
74
  end
66
75
 
76
+ def output_json(grouped, snapshots)
77
+ require "json"
78
+
79
+ manifests = grouped.map do |manifest, deps|
80
+ by_type = deps.group_by { |d| d.dependency_type || "runtime" }
81
+
82
+ {
83
+ path: manifest.path,
84
+ ecosystem: manifest.ecosystem,
85
+ dependencies: by_type.transform_values do |type_deps|
86
+ type_deps.sort_by(&:name).map do |dep|
87
+ {
88
+ name: dep.name,
89
+ requirement: dep.requirement || "*"
90
+ }
91
+ end
92
+ end
93
+ }
94
+ end
95
+
96
+ data = {
97
+ manifests: manifests,
98
+ total: snapshots.count,
99
+ manifest_count: grouped.keys.count
100
+ }
101
+
102
+ puts JSON.pretty_generate(data)
103
+ end
104
+
67
105
  def print_dependency(dep, indent)
68
106
  prefix = " " * indent
69
107
  version = dep.requirement || "*"
@@ -111,6 +149,10 @@ module Git
111
149
  options[:branch] = v
112
150
  end
113
151
 
152
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
153
+ options[:format] = v
154
+ end
155
+
114
156
  opts.on("--no-pager", "Do not pipe output into a pager") do
115
157
  options[:no_pager] = true
116
158
  end
@@ -35,12 +35,25 @@ module Git
35
35
  added_change = added_change.first
36
36
 
37
37
  unless added_change
38
- empty_result "Package '#{package_name}' not found in dependency history"
38
+ if @options[:format] == "json"
39
+ require "json"
40
+ puts JSON.pretty_generate({ found: false, package: package_name })
41
+ else
42
+ empty_result "Package '#{package_name}' not found in dependency history"
43
+ end
39
44
  return
40
45
  end
41
46
 
42
47
  commit = added_change.commit
43
48
 
49
+ if @options[:format] == "json"
50
+ output_json(package_name, added_change, commit)
51
+ else
52
+ output_text(package_name, added_change, commit)
53
+ end
54
+ end
55
+
56
+ def output_text(package_name, added_change, commit)
44
57
  puts "#{package_name} was added in commit #{commit.short_sha}"
45
58
  puts
46
59
  puts "Date: #{commit.committed_at.strftime("%Y-%m-%d %H:%M")}"
@@ -52,6 +65,28 @@ module Git
52
65
  puts commit.message.to_s.lines.map { |l| " #{l}" }.join
53
66
  end
54
67
 
68
+ def output_json(package_name, added_change, commit)
69
+ require "json"
70
+
71
+ data = {
72
+ found: true,
73
+ package: package_name,
74
+ ecosystem: added_change.ecosystem,
75
+ requirement: added_change.requirement,
76
+ manifest: added_change.manifest.path,
77
+ commit: {
78
+ sha: commit.sha,
79
+ short_sha: commit.short_sha,
80
+ message: commit.message,
81
+ author_name: commit.author_name,
82
+ author_email: commit.author_email,
83
+ date: commit.committed_at.iso8601
84
+ }
85
+ }
86
+
87
+ puts JSON.pretty_generate(data)
88
+ end
89
+
55
90
  def parse_options
56
91
  options = {}
57
92
 
@@ -62,6 +97,10 @@ module Git
62
97
  options[:ecosystem] = v
63
98
  end
64
99
 
100
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
101
+ options[:format] = v
102
+ end
103
+
65
104
  opts.on("-h", "--help", "Show this help") do
66
105
  puts opts
67
106
  exit
@@ -5,7 +5,7 @@ require "bibliothecary"
5
5
  module Git
6
6
  module Pkgs
7
7
  module Config
8
- # File patterns ignored by default (SBOM formats not supported)
8
+ # File patterns ignored by default (SBOM formats not supported, go.sum is checksums only)
9
9
  DEFAULT_IGNORED_FILES = %w[
10
10
  cyclonedx.xml
11
11
  cyclonedx.json
@@ -13,6 +13,7 @@ module Git
13
13
  *.cdx.json
14
14
  *.spdx
15
15
  *.spdx.json
16
+ go.sum
16
17
  ].freeze
17
18
 
18
19
  def self.ignored_dirs
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Git
4
4
  module Pkgs
5
- VERSION = "0.6.1"
5
+ VERSION = "0.6.2"
6
6
  end
7
7
  end
data/lib/git/pkgs.rb CHANGED
@@ -47,6 +47,77 @@ module Git
47
47
  class << self
48
48
  attr_accessor :quiet, :git_dir, :work_tree, :db_path, :batch_size, :snapshot_interval, :threads
49
49
 
50
+ # Parse dependencies from a single manifest or lockfile.
51
+ # Returns nil if the file is not recognized as a manifest.
52
+ #
53
+ # @param path [String] file path (used for format detection)
54
+ # @param content [String] file contents
55
+ # @return [Hash, nil] parsed manifest with :platform, :path, :kind, :dependencies keys
56
+ def parse_file(path, content)
57
+ Config.configure_bibliothecary
58
+ result = Bibliothecary.analyse_file(path, content).first
59
+ return nil unless result
60
+ return nil if Config.filter_ecosystem?(result[:platform])
61
+
62
+ result
63
+ end
64
+
65
+ # Parse dependencies from multiple files.
66
+ # Returns only files that are recognized as manifests.
67
+ #
68
+ # @param files [Hash<String, String>] hash of path => content
69
+ # @return [Array<Hash>] array of parsed manifests
70
+ def parse_files(files)
71
+ Config.configure_bibliothecary
72
+ files.filter_map do |path, content|
73
+ result = Bibliothecary.analyse_file(path, content).first
74
+ next unless result
75
+ next if Config.filter_ecosystem?(result[:platform])
76
+
77
+ result
78
+ end
79
+ end
80
+
81
+ # Diff dependencies between two versions of a manifest file.
82
+ # Returns added, modified, and removed dependencies.
83
+ #
84
+ # @param path [String] file path (used for format detection)
85
+ # @param old_content [String] previous file contents (empty string for new files)
86
+ # @param new_content [String] current file contents (empty string for deleted files)
87
+ # @return [Hash] with :added, :modified, :removed arrays and :platform, :path keys
88
+ def diff_file(path, old_content, new_content)
89
+ Config.configure_bibliothecary
90
+
91
+ old_result = old_content.empty? ? nil : Bibliothecary.analyse_file(path, old_content).first
92
+ new_result = new_content.empty? ? nil : Bibliothecary.analyse_file(path, new_content).first
93
+
94
+ platform = new_result&.dig(:platform) || old_result&.dig(:platform)
95
+ return nil unless platform
96
+ return nil if Config.filter_ecosystem?(platform)
97
+
98
+ old_deps = (old_result&.dig(:dependencies) || []).map { |d| [d[:name], d] }.to_h
99
+ new_deps = (new_result&.dig(:dependencies) || []).map { |d| [d[:name], d] }.to_h
100
+
101
+ added = (new_deps.keys - old_deps.keys).map { |n| new_deps[n] }
102
+ removed = (old_deps.keys - new_deps.keys).map { |n| old_deps[n] }
103
+ modified = (old_deps.keys & new_deps.keys).filter_map do |name|
104
+ old_dep = old_deps[name]
105
+ new_dep = new_deps[name]
106
+ next if old_dep[:requirement] == new_dep[:requirement] && old_dep[:type] == new_dep[:type]
107
+
108
+ new_dep.to_h.merge(previous_requirement: old_dep[:requirement])
109
+ end
110
+
111
+ {
112
+ path: path,
113
+ platform: platform,
114
+ kind: new_result&.dig(:kind) || old_result&.dig(:kind),
115
+ added: added,
116
+ modified: modified,
117
+ removed: removed
118
+ }
119
+ end
120
+
50
121
  def configure_from_env
51
122
  @git_dir ||= presence(ENV["GIT_DIR"])
52
123
  @work_tree ||= presence(ENV["GIT_WORK_TREE"])
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: git-pkgs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '15.0'
60
+ version: '15.1'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '15.0'
67
+ version: '15.1'
68
68
  description: A git subcommand for analyzing package/dependency usage in git repositories
69
69
  over time
70
70
  email: