git-pkgs 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ab0df1352b215987b6611a997fc62aa1e2a3ea05abe597c134812f9ec17d9d7
4
- data.tar.gz: 5e929d2ebe9f2538d8d6b29ffc6ac89c7164f2d82afc898954159ae1a4a09ca9
3
+ metadata.gz: 7ff2285e54475944f6c8a9cb98ae9f5efcb78ee0aa74dbea6d46553bbf71caa2
4
+ data.tar.gz: 81aee90f3e0b9f573e1f9e442dc4c487f07957c320d70ca24a809929248279a3
5
5
  SHA512:
6
- metadata.gz: 8e8eb54a1557bd2fa965fae8dfc2d2cceca578bec650a95821abef16f19ba2d6d6ab7c88a092cddca87de0b111fa9dd526a76439555144a8638f8507201b552e
7
- data.tar.gz: 288792ce23dc723dcf61d37606cbe26cd89f33052687156d5ae032885c8e963647d972c7b772d4712da03aab024be61eb313992a213bff88d7c02c826937c5a9
6
+ metadata.gz: ffdcb5fe7cc217b105f10018ba24101131120fdfcc8305f3142fcc11f29e7f77114e95c0a2abbe661fcac3a74e4ea0662031dc6a997020225bdabcc9b6ab754b
7
+ data.tar.gz: 204dc587c61ccb128957910784ccab0cb84c667627dff1fb81d18a6b6779abc34cfff3a838f06146c954d1a50b9ac095a036a67c898d12699c530476b9b3d31c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-01-02
4
+
5
+ - `git pkgs show` command to display dependency changes in a single commit
6
+ - `git pkgs history` now supports `--author`, `--since`, and `--until` filters
7
+ - `git pkgs stats --by-author` shows who added the most dependencies
8
+ - `git pkgs stats --ecosystem=X` filters statistics by ecosystem
9
+
3
10
  ## [0.1.1] - 2026-01-01
4
11
 
5
12
  - `git pkgs history` now works without a package argument to show all dependency changes
data/README.md CHANGED
@@ -4,11 +4,9 @@ A git subcommand for tracking package dependencies across git history. Analyzes
4
4
 
5
5
  ## Why this exists
6
6
 
7
- Your lockfile shows what dependencies you have. It doesn't show how you got here. `git log Gemfile.lock` is useless noise.
7
+ 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
8
 
9
- git-pkgs indexes your dependency history into a queryable database. You can ask: when did we add this? who added it? what changed between these two releases? has anyone touched this in the last year?
10
-
11
- It works across ecosystems. Gemfile, package.json, Dockerfile, GitHub Actions workflows - one unified history instead of separate tools per ecosystem.
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.
12
10
 
13
11
  ## Installation
14
12
 
@@ -113,8 +111,11 @@ Gemfile (rubygems):
113
111
  ### View dependency history
114
112
 
115
113
  ```bash
116
- git pkgs history # all dependency changes
117
- git pkgs history rails # changes for a specific package
114
+ git pkgs history # all dependency changes
115
+ git pkgs history rails # changes for a specific package
116
+ git pkgs history --author=alice # filter by author
117
+ git pkgs history --since=2024-01-01 # changes after date
118
+ git pkgs history --ecosystem=rubygems # filter by ecosystem
118
119
  ```
119
120
 
120
121
  Shows when packages were added, updated, or removed:
@@ -166,6 +167,8 @@ Gemfile (rubygems):
166
167
 
167
168
  ```bash
168
169
  git pkgs stats
170
+ git pkgs stats --by-author # who added the most dependencies
171
+ git pkgs stats --ecosystem=npm # filter by ecosystem
169
172
  ```
170
173
 
171
174
  Example output:
@@ -211,7 +214,7 @@ Manifest Files
211
214
  git pkgs why rails
212
215
  ```
213
216
 
214
- Shows the commit that added the dependency with author and message.
217
+ This shows the commit that added the dependency along with the author and message.
215
218
 
216
219
  ### Dependency tree
217
220
 
@@ -220,7 +223,7 @@ git pkgs tree
220
223
  git pkgs tree --ecosystem=rubygems
221
224
  ```
222
225
 
223
- Shows dependencies grouped by type (runtime, development, etc).
226
+ This shows dependencies grouped by type (runtime, development, etc).
224
227
 
225
228
  ### Diff between commits
226
229
 
@@ -229,20 +232,57 @@ git pkgs diff --from=abc123 --to=def456
229
232
  git pkgs diff --from=HEAD~10
230
233
  ```
231
234
 
232
- Shows added, removed, and modified packages with version info.
235
+ This shows added, removed, and modified packages with version info.
236
+
237
+ ### Show changes in a commit
238
+
239
+ ```bash
240
+ git pkgs show # show dependency changes in HEAD
241
+ git pkgs show abc123 # specific commit
242
+ git pkgs show HEAD~5 # relative ref
243
+ ```
244
+
245
+ Like `git show` but for dependencies. Shows what was added, modified, or removed in a single commit.
233
246
 
234
247
  ### Keep database updated
235
248
 
249
+ After the initial analysis, you can incrementally update the database with new commits:
250
+
236
251
  ```bash
237
252
  git pkgs update
238
253
  ```
239
254
 
240
- Or install git hooks to update automatically after commits and merges:
255
+ You can also install git hooks to update automatically after commits and merges:
241
256
 
242
257
  ```bash
243
258
  git pkgs hooks --install
244
259
  ```
245
260
 
261
+ ### CI usage
262
+
263
+ You can run git-pkgs in CI to show dependency changes in pull requests:
264
+
265
+ ```yaml
266
+ # .github/workflows/deps.yml
267
+ name: Dependencies
268
+
269
+ on: pull_request
270
+
271
+ jobs:
272
+ diff:
273
+ runs-on: ubuntu-latest
274
+ steps:
275
+ - uses: actions/checkout@v4
276
+ with:
277
+ fetch-depth: 0
278
+ - uses: ruby/setup-ruby@v1
279
+ with:
280
+ ruby-version: '3.3'
281
+ - run: gem install git-pkgs
282
+ - run: git pkgs init
283
+ - run: git pkgs diff --from=origin/${{ github.base_ref }} --to=HEAD
284
+ ```
285
+
246
286
  ## Performance
247
287
 
248
288
  Benchmarked on a 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.
@@ -271,6 +311,21 @@ The database schema stores:
271
311
 
272
312
  See [docs/schema.md](docs/schema.md) for full schema documentation.
273
313
 
314
+ Since the database is just SQLite, you can query it directly for ad-hoc analysis:
315
+
316
+ ```bash
317
+ sqlite3 .git/pkgs.sqlite3 "
318
+ -- who added the most dependencies?
319
+ SELECT c.author_name, COUNT(*) as deps_added
320
+ FROM dependency_changes dc
321
+ JOIN commits c ON dc.commit_id = c.id
322
+ WHERE dc.change_type = 'added'
323
+ GROUP BY c.author_name
324
+ ORDER BY deps_added DESC
325
+ LIMIT 10;
326
+ "
327
+ ```
328
+
274
329
  ## Development
275
330
 
276
331
  ```bash
data/lib/git/pkgs/cli.rb CHANGED
@@ -5,7 +5,7 @@ require "optparse"
5
5
  module Git
6
6
  module Pkgs
7
7
  class CLI
8
- COMMANDS = %w[init update hooks info list tree history search why blame outdated stats diff branch].freeze
8
+ COMMANDS = %w[init update hooks info list tree history search why blame outdated stats diff branch show].freeze
9
9
 
10
10
  def self.run(args)
11
11
  new(args).run
@@ -60,6 +60,7 @@ module Git
60
60
  outdated Show dependencies that haven't been updated
61
61
  stats Show dependency statistics
62
62
  diff Show dependency changes between commits
63
+ show Show dependency changes in a commit
63
64
 
64
65
  Options:
65
66
  -h, --help Show this help message
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Git
4
6
  module Pkgs
5
7
  module Commands
@@ -31,6 +33,24 @@ module Git
31
33
  changes = changes.for_platform(@options[:ecosystem])
32
34
  end
33
35
 
36
+ if @options[:author]
37
+ author = @options[:author]
38
+ changes = changes.joins(:commit).where(
39
+ "commits.author_name LIKE ? OR commits.author_email LIKE ?",
40
+ "%#{author}%", "%#{author}%"
41
+ )
42
+ end
43
+
44
+ if @options[:since]
45
+ since_time = parse_time(@options[:since])
46
+ changes = changes.joins(:commit).where("commits.committed_at >= ?", since_time)
47
+ end
48
+
49
+ if @options[:until]
50
+ until_time = parse_time(@options[:until])
51
+ changes = changes.joins(:commit).where("commits.committed_at <= ?", until_time)
52
+ end
53
+
34
54
  if changes.empty?
35
55
  if package_name
36
56
  puts "No history found for '#{package_name}'"
@@ -118,6 +138,18 @@ module Git
118
138
  options[:format] = v
119
139
  end
120
140
 
141
+ opts.on("--author=NAME", "Filter by author name or email") do |v|
142
+ options[:author] = v
143
+ end
144
+
145
+ opts.on("--since=DATE", "Show changes after date (YYYY-MM-DD)") do |v|
146
+ options[:since] = v
147
+ end
148
+
149
+ opts.on("--until=DATE", "Show changes before date (YYYY-MM-DD)") do |v|
150
+ options[:until] = v
151
+ end
152
+
121
153
  opts.on("-h", "--help", "Show this help") do
122
154
  puts opts
123
155
  exit
@@ -127,6 +159,13 @@ module Git
127
159
  parser.parse!(@args)
128
160
  options
129
161
  end
162
+
163
+ def parse_time(str)
164
+ Time.parse(str)
165
+ rescue ArgumentError
166
+ $stderr.puts "Invalid date format: #{str}"
167
+ exit 1
168
+ end
130
169
  end
131
170
  end
132
171
  end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Show
7
+ def initialize(args)
8
+ @args = args
9
+ @options = parse_options
10
+ end
11
+
12
+ def run
13
+ ref = @args.shift || "HEAD"
14
+
15
+ repo = Repository.new
16
+
17
+ unless Database.exists?(repo.git_dir)
18
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
19
+ exit 1
20
+ end
21
+
22
+ Database.connect(repo.git_dir)
23
+
24
+ sha = repo.rev_parse(ref)
25
+
26
+ unless sha
27
+ $stderr.puts "Could not resolve '#{ref}'"
28
+ exit 1
29
+ end
30
+
31
+ commit = find_or_create_commit(repo, sha)
32
+
33
+ unless commit
34
+ $stderr.puts "Commit '#{sha[0..7]}' not found"
35
+ exit 1
36
+ end
37
+
38
+ changes = Models::DependencyChange
39
+ .includes(:commit, :manifest)
40
+ .where(commit_id: commit.id)
41
+
42
+ if @options[:ecosystem]
43
+ changes = changes.where(ecosystem: @options[:ecosystem])
44
+ end
45
+
46
+ if changes.empty?
47
+ puts "No dependency changes in #{commit.short_sha}"
48
+ return
49
+ end
50
+
51
+ if @options[:format] == "json"
52
+ output_json(commit, changes)
53
+ else
54
+ output_text(commit, changes)
55
+ end
56
+ end
57
+
58
+ def output_text(commit, changes)
59
+ puts "Commit: #{commit.short_sha} #{commit.message&.lines&.first&.strip}"
60
+ puts "Author: #{commit.author_name} <#{commit.author_email}>"
61
+ puts "Date: #{commit.committed_at.strftime("%Y-%m-%d")}"
62
+ puts
63
+
64
+ added = changes.select { |c| c.change_type == "added" }
65
+ modified = changes.select { |c| c.change_type == "modified" }
66
+ removed = changes.select { |c| c.change_type == "removed" }
67
+
68
+ if added.any?
69
+ puts "Added:"
70
+ added.each do |change|
71
+ puts " #{change.name} #{change.requirement} (#{change.ecosystem}, #{change.manifest.path})"
72
+ end
73
+ puts
74
+ end
75
+
76
+ if modified.any?
77
+ puts "Modified:"
78
+ modified.each do |change|
79
+ puts " #{change.name} #{change.previous_requirement} -> #{change.requirement} (#{change.ecosystem}, #{change.manifest.path})"
80
+ end
81
+ puts
82
+ end
83
+
84
+ if removed.any?
85
+ puts "Removed:"
86
+ removed.each do |change|
87
+ puts " #{change.name} #{change.requirement} (#{change.ecosystem}, #{change.manifest.path})"
88
+ end
89
+ puts
90
+ end
91
+ end
92
+
93
+ def output_json(commit, changes)
94
+ require "json"
95
+
96
+ data = {
97
+ commit: {
98
+ sha: commit.sha,
99
+ short_sha: commit.short_sha,
100
+ message: commit.message&.lines&.first&.strip,
101
+ author_name: commit.author_name,
102
+ author_email: commit.author_email,
103
+ date: commit.committed_at.iso8601
104
+ },
105
+ changes: changes.map do |change|
106
+ {
107
+ name: change.name,
108
+ change_type: change.change_type,
109
+ requirement: change.requirement,
110
+ previous_requirement: change.previous_requirement,
111
+ ecosystem: change.ecosystem,
112
+ manifest: change.manifest.path
113
+ }
114
+ end
115
+ }
116
+
117
+ puts JSON.pretty_generate(data)
118
+ end
119
+
120
+ def find_or_create_commit(repo, sha)
121
+ commit = Models::Commit.find_by(sha: sha) ||
122
+ Models::Commit.where("sha LIKE ?", "#{sha}%").first
123
+ return commit if commit
124
+
125
+ rugged_commit = repo.lookup(sha)
126
+ return nil unless rugged_commit
127
+
128
+ Models::Commit.create!(
129
+ sha: rugged_commit.oid,
130
+ message: rugged_commit.message,
131
+ author_name: rugged_commit.author[:name],
132
+ author_email: rugged_commit.author[:email],
133
+ committed_at: rugged_commit.time,
134
+ has_dependency_changes: false
135
+ )
136
+ rescue Rugged::OdbError
137
+ nil
138
+ end
139
+
140
+ def parse_options
141
+ options = {}
142
+
143
+ parser = OptionParser.new do |opts|
144
+ opts.banner = "Usage: git pkgs show [commit] [options]"
145
+
146
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
147
+ options[:ecosystem] = v
148
+ end
149
+
150
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
151
+ options[:format] = v
152
+ end
153
+
154
+ opts.on("-h", "--help", "Show this help") do
155
+ puts opts
156
+ exit
157
+ end
158
+ end
159
+
160
+ parser.parse!(@args)
161
+ options
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -22,19 +22,26 @@ module Git
22
22
  branch_name = @options[:branch] || repo.default_branch
23
23
  branch = Models::Branch.find_by(name: branch_name)
24
24
 
25
- data = collect_stats(branch, branch_name)
26
-
27
- if @options[:format] == "json"
28
- require "json"
29
- puts JSON.pretty_generate(data)
25
+ if @options[:by_author]
26
+ output_by_author
30
27
  else
31
- output_text(data)
28
+ data = collect_stats(branch, branch_name)
29
+
30
+ if @options[:format] == "json"
31
+ require "json"
32
+ puts JSON.pretty_generate(data)
33
+ else
34
+ output_text(data)
35
+ end
32
36
  end
33
37
  end
34
38
 
35
39
  def collect_stats(branch, branch_name)
40
+ ecosystem = @options[:ecosystem]
41
+
36
42
  data = {
37
43
  branch: branch_name,
44
+ ecosystem: ecosystem,
38
45
  commits_analyzed: branch&.commits&.count || 0,
39
46
  commits_with_changes: branch&.commits&.where(has_dependency_changes: true)&.count || 0,
40
47
  current_dependencies: {},
@@ -46,6 +53,7 @@ module Git
46
53
  if branch&.last_analyzed_sha
47
54
  current_commit = Models::Commit.find_by(sha: branch.last_analyzed_sha)
48
55
  snapshots = current_commit&.dependency_snapshots || []
56
+ snapshots = snapshots.where(ecosystem: ecosystem) if ecosystem
49
57
 
50
58
  data[:current_dependencies] = {
51
59
  total: snapshots.count,
@@ -54,22 +62,28 @@ module Git
54
62
  }
55
63
  end
56
64
 
65
+ changes = Models::DependencyChange.all
66
+ changes = changes.where(ecosystem: ecosystem) if ecosystem
67
+
57
68
  data[:changes] = {
58
- total: Models::DependencyChange.count,
59
- by_type: Models::DependencyChange.group(:change_type).count
69
+ total: changes.count,
70
+ by_type: changes.group(:change_type).count
60
71
  }
61
72
 
62
- most_changed = Models::DependencyChange
73
+ most_changed = changes
63
74
  .group(:name, :ecosystem)
64
75
  .order("count_all DESC")
65
76
  .limit(10)
66
77
  .count
67
78
 
68
- data[:most_changed] = most_changed.map do |(name, ecosystem), count|
69
- { name: name, ecosystem: ecosystem, changes: count }
79
+ data[:most_changed] = most_changed.map do |(name, eco), count|
80
+ { name: name, ecosystem: eco, changes: count }
70
81
  end
71
82
 
72
- data[:manifests] = Models::Manifest.all.map do |manifest|
83
+ manifests = Models::Manifest.all
84
+ manifests = manifests.where(ecosystem: ecosystem) if ecosystem
85
+
86
+ data[:manifests] = manifests.map do |manifest|
73
87
  { path: manifest.path, ecosystem: manifest.ecosystem, changes: manifest.dependency_changes.count }
74
88
  end
75
89
 
@@ -82,6 +96,7 @@ module Git
82
96
  puts
83
97
 
84
98
  puts "Branch: #{data[:branch]}"
99
+ puts "Ecosystem: #{data[:ecosystem]}" if data[:ecosystem]
85
100
  puts "Commits analyzed: #{data[:commits_analyzed]}"
86
101
  puts "Commits with changes: #{data[:commits_with_changes]}"
87
102
  puts
@@ -128,6 +143,38 @@ module Git
128
143
  end
129
144
  end
130
145
 
146
+ def output_by_author
147
+ changes = Models::DependencyChange
148
+ .joins(:commit)
149
+ .where(change_type: "added")
150
+
151
+ changes = changes.where(ecosystem: @options[:ecosystem]) if @options[:ecosystem]
152
+
153
+ counts = changes
154
+ .group("commits.author_name")
155
+ .order("count_all DESC")
156
+ .limit(@options[:limit] || 20)
157
+ .count
158
+
159
+ if counts.empty?
160
+ puts "No dependency additions found"
161
+ return
162
+ end
163
+
164
+ if @options[:format] == "json"
165
+ require "json"
166
+ data = counts.map { |name, count| { author: name, added: count } }
167
+ puts JSON.pretty_generate(data)
168
+ else
169
+ puts "Dependencies Added by Author"
170
+ puts "=" * 40
171
+ puts
172
+ counts.each do |name, count|
173
+ puts " #{count.to_s.rjust(4)} #{name}"
174
+ end
175
+ end
176
+ end
177
+
131
178
  def parse_options
132
179
  options = {}
133
180
 
@@ -138,10 +185,22 @@ module Git
138
185
  options[:branch] = v
139
186
  end
140
187
 
188
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
189
+ options[:ecosystem] = v
190
+ end
191
+
141
192
  opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
142
193
  options[:format] = v
143
194
  end
144
195
 
196
+ opts.on("--by-author", "Show dependencies added by author") do
197
+ options[:by_author] = true
198
+ end
199
+
200
+ opts.on("-n", "--limit=N", Integer, "Limit results (default: 20)") do |v|
201
+ options[:limit] = v
202
+ end
203
+
145
204
  opts.on("-h", "--help", "Show this help") do
146
205
  puts opts
147
206
  exit
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Git
4
4
  module Pkgs
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/git/pkgs.rb CHANGED
@@ -27,6 +27,7 @@ require_relative "pkgs/commands/diff"
27
27
  require_relative "pkgs/commands/tree"
28
28
  require_relative "pkgs/commands/branch"
29
29
  require_relative "pkgs/commands/search"
30
+ require_relative "pkgs/commands/show"
30
31
 
31
32
  module Git
32
33
  module Pkgs
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.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -98,6 +98,7 @@ files:
98
98
  - lib/git/pkgs/commands/list.rb
99
99
  - lib/git/pkgs/commands/outdated.rb
100
100
  - lib/git/pkgs/commands/search.rb
101
+ - lib/git/pkgs/commands/show.rb
101
102
  - lib/git/pkgs/commands/stats.rb
102
103
  - lib/git/pkgs/commands/tree.rb
103
104
  - lib/git/pkgs/commands/update.rb