git-pkgs 0.1.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.
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Git
6
+ module Pkgs
7
+ class CLI
8
+ COMMANDS = %w[init update hooks info list tree history search why blame outdated stats diff branch].freeze
9
+
10
+ def self.run(args)
11
+ new(args).run
12
+ end
13
+
14
+ def initialize(args)
15
+ @args = args.dup
16
+ @options = {}
17
+ end
18
+
19
+ def run
20
+ command = @args.shift
21
+
22
+ case command
23
+ when nil, "-h", "--help", "help"
24
+ print_help
25
+ when "-v", "--version", "version"
26
+ puts "git-pkgs #{Git::Pkgs::VERSION}"
27
+ when *COMMANDS
28
+ run_command(command)
29
+ else
30
+ $stderr.puts "Unknown command: #{command}"
31
+ $stderr.puts "Run 'git pkgs help' for usage"
32
+ exit 1
33
+ end
34
+ end
35
+
36
+ def run_command(command)
37
+ command_class = Commands.const_get(command.capitalize.gsub(/_([a-z])/) { $1.upcase })
38
+ command_class.new(@args).run
39
+ rescue NameError
40
+ $stderr.puts "Command '#{command}' not yet implemented"
41
+ exit 1
42
+ end
43
+
44
+ def print_help
45
+ puts <<~HELP
46
+ Usage: git pkgs <command> [options]
47
+
48
+ Commands:
49
+ init Initialize the package database for this repository
50
+ update Update the database with new commits
51
+ hooks Manage git hooks for auto-updating
52
+ info Show database size and row counts
53
+ branch Manage tracked branches
54
+ list List dependencies at a commit
55
+ tree Show dependency tree grouped by type
56
+ history Show the history of a package
57
+ search Find a dependency across all history
58
+ why Explain why a dependency exists
59
+ blame Show who added each dependency
60
+ outdated Show dependencies that haven't been updated
61
+ stats Show dependency statistics
62
+ diff Show dependency changes between commits
63
+
64
+ Options:
65
+ -h, --help Show this help message
66
+ -v, --version Show version
67
+
68
+ Run 'git pkgs <command> --help' for command-specific options.
69
+ HELP
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Blame
7
+ def initialize(args)
8
+ @args = args
9
+ @options = parse_options
10
+ end
11
+
12
+ def run
13
+ repo = Repository.new
14
+
15
+ unless Database.exists?(repo.git_dir)
16
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
17
+ exit 1
18
+ end
19
+
20
+ Database.connect(repo.git_dir)
21
+
22
+ # Get current dependencies at the last analyzed commit
23
+ branch_name = @options[:branch] || repo.default_branch
24
+ branch = Models::Branch.find_by(name: branch_name)
25
+
26
+ unless branch&.last_analyzed_sha
27
+ $stderr.puts "No analysis found for branch '#{branch_name}'"
28
+ exit 1
29
+ end
30
+
31
+ current_commit = Models::Commit.find_by(sha: branch.last_analyzed_sha)
32
+ snapshots = current_commit&.dependency_snapshots&.includes(:manifest) || []
33
+
34
+ if @options[:ecosystem]
35
+ snapshots = snapshots.where(ecosystem: @options[:ecosystem])
36
+ end
37
+
38
+ if snapshots.empty?
39
+ puts "No dependencies found"
40
+ return
41
+ end
42
+
43
+ # For each current dependency, find who added it
44
+ blame_data = []
45
+
46
+ snapshots.each do |snapshot|
47
+ added_change = Models::DependencyChange
48
+ .includes(:commit)
49
+ .where(name: snapshot.name, manifest: snapshot.manifest)
50
+ .added
51
+ .order("commits.committed_at ASC")
52
+ .first
53
+
54
+ next unless added_change
55
+
56
+ commit = added_change.commit
57
+ author = best_author(commit)
58
+
59
+ blame_data << {
60
+ name: snapshot.name,
61
+ ecosystem: snapshot.ecosystem,
62
+ requirement: snapshot.requirement,
63
+ manifest: snapshot.manifest.path,
64
+ author: author,
65
+ date: commit.committed_at,
66
+ sha: commit.short_sha
67
+ }
68
+ end
69
+
70
+ if @options[:format] == "json"
71
+ require "json"
72
+ json_data = blame_data.map do |d|
73
+ d.merge(date: d[:date].iso8601)
74
+ end
75
+ puts JSON.pretty_generate(json_data)
76
+ else
77
+ grouped = blame_data.group_by { |d| [d[:manifest], d[:ecosystem]] }
78
+
79
+ grouped.each do |(manifest, ecosystem), deps|
80
+ puts "#{manifest} (#{ecosystem}):"
81
+
82
+ max_name_len = deps.map { |d| d[:name].length }.max
83
+ max_author_len = deps.map { |d| d[:author].length }.max
84
+
85
+ deps.sort_by { |d| d[:name] }.each do |dep|
86
+ date = dep[:date].strftime("%Y-%m-%d")
87
+ puts " #{dep[:name].ljust(max_name_len)} #{dep[:author].ljust(max_author_len)} #{date} #{dep[:sha]}"
88
+ end
89
+ puts
90
+ end
91
+ end
92
+ end
93
+
94
+ def best_author(commit)
95
+ authors = [commit.author_name] + parse_coauthors(commit.message)
96
+
97
+ # Prefer human authors over bots
98
+ human = authors.find { |a| !bot_author?(a) }
99
+ human || authors.first
100
+ end
101
+
102
+ def parse_coauthors(message)
103
+ return [] unless message
104
+
105
+ message.scan(/^Co-authored-by:\s*(.+?)\s*<[^>]+>/i).flatten
106
+ end
107
+
108
+ def bot_author?(name)
109
+ name =~ /\[bot\]$|^dependabot|^renovate|^github-actions/i
110
+ end
111
+
112
+ def parse_options
113
+ options = {}
114
+
115
+ parser = OptionParser.new do |opts|
116
+ opts.banner = "Usage: git pkgs blame [options]"
117
+
118
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
119
+ options[:ecosystem] = v
120
+ end
121
+
122
+ opts.on("-b", "--branch=NAME", "Branch to analyze") do |v|
123
+ options[:branch] = v
124
+ end
125
+
126
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
127
+ options[:format] = v
128
+ end
129
+
130
+ opts.on("-h", "--help", "Show this help") do
131
+ puts opts
132
+ exit
133
+ end
134
+ end
135
+
136
+ parser.parse!(@args)
137
+ options
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Branch
7
+ BATCH_SIZE = 100
8
+ SNAPSHOT_INTERVAL = 20
9
+
10
+ def initialize(args)
11
+ @args = args
12
+ @options = parse_options
13
+ end
14
+
15
+ def run
16
+ subcommand = @args.shift
17
+
18
+ case subcommand
19
+ when "add"
20
+ add_branch
21
+ when "list"
22
+ list_branches
23
+ when "remove", "rm"
24
+ remove_branch
25
+ when nil, "-h", "--help"
26
+ print_help
27
+ else
28
+ $stderr.puts "Unknown subcommand: #{subcommand}"
29
+ $stderr.puts "Run 'git pkgs branch --help' for usage"
30
+ exit 1
31
+ end
32
+ end
33
+
34
+ def add_branch
35
+ branch_name = @args.shift
36
+ unless branch_name
37
+ $stderr.puts "Usage: git pkgs branch add <name>"
38
+ exit 1
39
+ end
40
+
41
+ repo = Repository.new
42
+
43
+ unless Database.exists?(repo.git_dir)
44
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
45
+ exit 1
46
+ end
47
+
48
+ Database.connect(repo.git_dir)
49
+
50
+ unless repo.branch_exists?(branch_name)
51
+ $stderr.puts "Branch '#{branch_name}' not found"
52
+ exit 1
53
+ end
54
+
55
+ existing = Models::Branch.find_by(name: branch_name)
56
+ if existing
57
+ puts "Branch '#{branch_name}' already tracked (#{existing.commits.count} commits)"
58
+ puts "Use 'git pkgs update' to refresh"
59
+ return
60
+ end
61
+
62
+ Database.optimize_for_bulk_writes
63
+
64
+ branch = Models::Branch.create!(name: branch_name)
65
+ analyzer = Analyzer.new(repo)
66
+
67
+ puts "Analyzing branch: #{branch_name}"
68
+
69
+ walker = repo.walk(branch_name)
70
+ commits = walker.to_a
71
+ total = commits.size
72
+
73
+ stats = bulk_process_commits(commits, branch, analyzer, total, repo)
74
+
75
+ branch.update(last_analyzed_sha: repo.branch_target(branch_name))
76
+
77
+ Database.optimize_for_reads
78
+
79
+ puts "\rDone!#{' ' * 20}"
80
+ puts "Analyzed #{total} commits"
81
+ puts "Found #{stats[:dependency_commits]} commits with dependency changes"
82
+ puts "Stored #{stats[:snapshots_stored]} snapshots"
83
+ end
84
+
85
+ def list_branches
86
+ repo = Repository.new
87
+
88
+ unless Database.exists?(repo.git_dir)
89
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
90
+ exit 1
91
+ end
92
+
93
+ Database.connect(repo.git_dir)
94
+
95
+ branches = Models::Branch.all
96
+
97
+ if branches.empty?
98
+ puts "No branches tracked"
99
+ return
100
+ end
101
+
102
+ puts "Tracked branches:"
103
+ branches.each do |branch|
104
+ commit_count = branch.commits.count
105
+ dep_commits = branch.commits.where(has_dependency_changes: true).count
106
+ last_sha = branch.last_analyzed_sha&.slice(0, 7) || "none"
107
+ puts " #{branch.name}: #{commit_count} commits (#{dep_commits} with deps), last: #{last_sha}"
108
+ end
109
+ end
110
+
111
+ def remove_branch
112
+ branch_name = @args.shift
113
+ unless branch_name
114
+ $stderr.puts "Usage: git pkgs branch remove <name>"
115
+ exit 1
116
+ end
117
+
118
+ repo = Repository.new
119
+
120
+ unless Database.exists?(repo.git_dir)
121
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
122
+ exit 1
123
+ end
124
+
125
+ Database.connect(repo.git_dir)
126
+
127
+ branch = Models::Branch.find_by(name: branch_name)
128
+ unless branch
129
+ $stderr.puts "Branch '#{branch_name}' not tracked"
130
+ exit 1
131
+ end
132
+
133
+ # Only delete branch_commits, keep shared commits
134
+ count = branch.branch_commits.count
135
+ branch.branch_commits.delete_all
136
+ branch.destroy
137
+
138
+ puts "Removed branch '#{branch_name}' (#{count} branch-commit links)"
139
+ end
140
+
141
+ def bulk_process_commits(commits, branch, analyzer, total, repo)
142
+ now = Time.now
143
+ snapshot = {}
144
+ manifests_cache = {}
145
+
146
+ pending_commits = []
147
+ pending_branch_commits = []
148
+ pending_changes = []
149
+ pending_snapshots = []
150
+
151
+ dependency_commit_count = 0
152
+ snapshots_stored = 0
153
+ processed = 0
154
+
155
+ flush = lambda do
156
+ return if pending_commits.empty?
157
+
158
+ ActiveRecord::Base.transaction do
159
+ # Use upsert for commits since they may already exist from other branches
160
+ if pending_commits.any?
161
+ Models::Commit.upsert_all(
162
+ pending_commits,
163
+ unique_by: :sha
164
+ )
165
+ end
166
+
167
+ commit_ids = Models::Commit
168
+ .where(sha: pending_commits.map { |c| c[:sha] })
169
+ .pluck(:sha, :id).to_h
170
+
171
+ if pending_branch_commits.any?
172
+ branch_commit_records = pending_branch_commits.map do |bc|
173
+ { branch_id: bc[:branch_id], commit_id: commit_ids[bc[:sha]], position: bc[:position] }
174
+ end
175
+ Models::BranchCommit.insert_all(branch_commit_records)
176
+ end
177
+
178
+ if pending_changes.any?
179
+ manifest_ids = Models::Manifest.pluck(:path, :id).to_h
180
+ change_records = pending_changes.map do |c|
181
+ {
182
+ commit_id: commit_ids[c[:sha]],
183
+ manifest_id: manifest_ids[c[:manifest_path]],
184
+ name: c[:name],
185
+ ecosystem: c[:ecosystem],
186
+ change_type: c[:change_type],
187
+ requirement: c[:requirement],
188
+ previous_requirement: c[:previous_requirement],
189
+ dependency_type: c[:dependency_type],
190
+ created_at: now,
191
+ updated_at: now
192
+ }
193
+ end
194
+ Models::DependencyChange.insert_all(change_records)
195
+ end
196
+
197
+ if pending_snapshots.any?
198
+ manifest_ids ||= Models::Manifest.pluck(:path, :id).to_h
199
+ snapshot_records = pending_snapshots.map do |s|
200
+ {
201
+ commit_id: commit_ids[s[:sha]],
202
+ manifest_id: manifest_ids[s[:manifest_path]],
203
+ name: s[:name],
204
+ ecosystem: s[:ecosystem],
205
+ requirement: s[:requirement],
206
+ dependency_type: s[:dependency_type],
207
+ created_at: now,
208
+ updated_at: now
209
+ }
210
+ end
211
+ Models::DependencySnapshot.insert_all(snapshot_records)
212
+ end
213
+ end
214
+
215
+ pending_commits.clear
216
+ pending_branch_commits.clear
217
+ pending_changes.clear
218
+ pending_snapshots.clear
219
+ end
220
+
221
+ commits.each do |rugged_commit|
222
+ processed += 1
223
+ print "\rProcessing commit #{processed}/#{total}..." if processed % 50 == 0 || processed == total
224
+
225
+ next if rugged_commit.parents.length > 1
226
+
227
+ result = analyzer.analyze_commit(rugged_commit, snapshot)
228
+ has_changes = result && result[:changes].any?
229
+
230
+ pending_commits << {
231
+ sha: rugged_commit.oid,
232
+ message: rugged_commit.message,
233
+ author_name: rugged_commit.author[:name],
234
+ author_email: rugged_commit.author[:email],
235
+ committed_at: rugged_commit.time,
236
+ has_dependency_changes: has_changes,
237
+ created_at: now,
238
+ updated_at: now
239
+ }
240
+
241
+ pending_branch_commits << {
242
+ branch_id: branch.id,
243
+ sha: rugged_commit.oid,
244
+ position: processed
245
+ }
246
+
247
+ if has_changes
248
+ dependency_commit_count += 1
249
+
250
+ result[:changes].each do |change|
251
+ manifest_key = change[:manifest_path]
252
+ unless manifests_cache[manifest_key]
253
+ manifests_cache[manifest_key] = Models::Manifest.find_or_create(
254
+ path: change[:manifest_path],
255
+ ecosystem: change[:ecosystem],
256
+ kind: change[:kind]
257
+ )
258
+ end
259
+
260
+ pending_changes << {
261
+ sha: rugged_commit.oid,
262
+ manifest_path: manifest_key,
263
+ name: change[:name],
264
+ ecosystem: change[:ecosystem],
265
+ change_type: change[:change_type],
266
+ requirement: change[:requirement],
267
+ previous_requirement: change[:previous_requirement],
268
+ dependency_type: change[:dependency_type]
269
+ }
270
+ end
271
+
272
+ snapshot = result[:snapshot]
273
+
274
+ if dependency_commit_count % SNAPSHOT_INTERVAL == 0
275
+ snapshot.each do |(manifest_path, name), dep_info|
276
+ pending_snapshots << {
277
+ sha: rugged_commit.oid,
278
+ manifest_path: manifest_path,
279
+ name: name,
280
+ ecosystem: dep_info[:ecosystem],
281
+ requirement: dep_info[:requirement],
282
+ dependency_type: dep_info[:dependency_type]
283
+ }
284
+ end
285
+ snapshots_stored += snapshot.size
286
+ end
287
+ end
288
+
289
+ flush.call if pending_commits.size >= BATCH_SIZE
290
+ end
291
+
292
+ if snapshot.any?
293
+ last_sha = commits.last&.oid
294
+ if last_sha && !pending_snapshots.any? { |s| s[:sha] == last_sha }
295
+ snapshot.each do |(manifest_path, name), dep_info|
296
+ pending_snapshots << {
297
+ sha: last_sha,
298
+ manifest_path: manifest_path,
299
+ name: name,
300
+ ecosystem: dep_info[:ecosystem],
301
+ requirement: dep_info[:requirement],
302
+ dependency_type: dep_info[:dependency_type]
303
+ }
304
+ end
305
+ snapshots_stored += snapshot.size
306
+ end
307
+ end
308
+
309
+ flush.call
310
+
311
+ { dependency_commits: dependency_commit_count, snapshots_stored: snapshots_stored }
312
+ end
313
+
314
+ def print_help
315
+ puts <<~HELP
316
+ Usage: git pkgs branch <subcommand> [options]
317
+
318
+ Subcommands:
319
+ add <name> Analyze and track a branch
320
+ list List tracked branches
321
+ remove <name> Stop tracking a branch
322
+
323
+ Examples:
324
+ git pkgs branch add feature-x
325
+ git pkgs branch list
326
+ git pkgs branch remove feature-x
327
+ HELP
328
+ end
329
+
330
+ def parse_options
331
+ options = {}
332
+ options
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Diff
7
+ def initialize(args)
8
+ @args = args
9
+ @options = parse_options
10
+ end
11
+
12
+ def run
13
+ repo = Repository.new
14
+
15
+ unless Database.exists?(repo.git_dir)
16
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
17
+ exit 1
18
+ end
19
+
20
+ Database.connect(repo.git_dir)
21
+
22
+ from_sha = @options[:from]
23
+ to_sha = @options[:to] || repo.head_sha
24
+
25
+ unless from_sha
26
+ $stderr.puts "Usage: git pkgs diff --from=SHA [--to=SHA]"
27
+ exit 1
28
+ end
29
+
30
+ from_commit = Models::Commit.find_by(sha: from_sha) ||
31
+ Models::Commit.where("sha LIKE ?", "#{from_sha}%").first
32
+ to_commit = Models::Commit.find_by(sha: to_sha) ||
33
+ Models::Commit.where("sha LIKE ?", "#{to_sha}%").first
34
+
35
+ unless from_commit
36
+ $stderr.puts "Commit '#{from_sha}' not found in database"
37
+ exit 1
38
+ end
39
+
40
+ unless to_commit
41
+ $stderr.puts "Commit '#{to_sha}' not found in database"
42
+ exit 1
43
+ end
44
+
45
+ # Get all changes between the two commits
46
+ changes = Models::DependencyChange
47
+ .includes(:commit, :manifest)
48
+ .joins(:commit)
49
+ .where("commits.committed_at > ? AND commits.committed_at <= ?",
50
+ from_commit.committed_at, to_commit.committed_at)
51
+ .order("commits.committed_at ASC")
52
+
53
+ if @options[:ecosystem]
54
+ changes = changes.where(ecosystem: @options[:ecosystem])
55
+ end
56
+
57
+ if changes.empty?
58
+ puts "No dependency changes between #{from_commit.short_sha} and #{to_commit.short_sha}"
59
+ return
60
+ end
61
+
62
+ puts "Dependency changes from #{from_commit.short_sha} to #{to_commit.short_sha}:"
63
+ puts
64
+
65
+ added = changes.select { |c| c.change_type == "added" }
66
+ modified = changes.select { |c| c.change_type == "modified" }
67
+ removed = changes.select { |c| c.change_type == "removed" }
68
+
69
+ if added.any?
70
+ puts "Added:"
71
+ added.group_by(&:name).each do |name, pkg_changes|
72
+ latest = pkg_changes.last
73
+ puts " + #{name} #{latest.requirement} (#{latest.manifest.path})"
74
+ end
75
+ puts
76
+ end
77
+
78
+ if modified.any?
79
+ puts "Modified:"
80
+ modified.group_by(&:name).each do |name, pkg_changes|
81
+ first = pkg_changes.first
82
+ latest = pkg_changes.last
83
+ puts " ~ #{name} #{first.previous_requirement} -> #{latest.requirement}"
84
+ end
85
+ puts
86
+ end
87
+
88
+ if removed.any?
89
+ puts "Removed:"
90
+ removed.group_by(&:name).each do |name, pkg_changes|
91
+ latest = pkg_changes.last
92
+ puts " - #{name} (was #{latest.requirement})"
93
+ end
94
+ puts
95
+ end
96
+
97
+ # Summary
98
+ puts "Summary: +#{added.map(&:name).uniq.count} -#{removed.map(&:name).uniq.count} ~#{modified.map(&:name).uniq.count}"
99
+ end
100
+
101
+ def parse_options
102
+ options = {}
103
+
104
+ parser = OptionParser.new do |opts|
105
+ opts.banner = "Usage: git pkgs diff --from=SHA [--to=SHA] [options]"
106
+
107
+ opts.on("-f", "--from=SHA", "Start commit (required)") do |v|
108
+ options[:from] = v
109
+ end
110
+
111
+ opts.on("-t", "--to=SHA", "End commit (default: HEAD)") do |v|
112
+ options[:to] = v
113
+ end
114
+
115
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
116
+ options[:ecosystem] = v
117
+ end
118
+
119
+ opts.on("-h", "--help", "Show this help") do
120
+ puts opts
121
+ exit
122
+ end
123
+ end
124
+
125
+ parser.parse!(@args)
126
+ options
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end