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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class History
7
+ def initialize(args)
8
+ @args = args
9
+ @options = parse_options
10
+ end
11
+
12
+ def run
13
+ package_name = @args.shift
14
+
15
+ unless package_name
16
+ $stderr.puts "Usage: git pkgs history <package>"
17
+ exit 1
18
+ end
19
+
20
+ repo = Repository.new
21
+
22
+ unless Database.exists?(repo.git_dir)
23
+ $stderr.puts "Database not initialized. Run 'git pkgs init' first."
24
+ exit 1
25
+ end
26
+
27
+ Database.connect(repo.git_dir)
28
+
29
+ changes = Models::DependencyChange
30
+ .includes(:commit, :manifest)
31
+ .for_package(package_name)
32
+ .order("commits.committed_at ASC")
33
+
34
+ if @options[:ecosystem]
35
+ changes = changes.for_platform(@options[:ecosystem])
36
+ end
37
+
38
+ if changes.empty?
39
+ puts "No history found for '#{package_name}'"
40
+ return
41
+ end
42
+
43
+ if @options[:format] == "json"
44
+ output_json(changes)
45
+ else
46
+ output_text(changes, package_name)
47
+ end
48
+ end
49
+
50
+ def output_text(changes, package_name)
51
+ puts "History for #{package_name}:"
52
+ puts
53
+
54
+ changes.each do |change|
55
+ commit = change.commit
56
+ date = commit.committed_at.strftime("%Y-%m-%d")
57
+
58
+ case change.change_type
59
+ when "added"
60
+ action = "Added"
61
+ version_info = change.requirement
62
+ when "modified"
63
+ action = "Updated"
64
+ version_info = "#{change.previous_requirement} -> #{change.requirement}"
65
+ when "removed"
66
+ action = "Removed"
67
+ version_info = change.requirement
68
+ end
69
+
70
+ puts "#{date} #{action} #{version_info}"
71
+ puts " Commit: #{commit.short_sha} #{commit.message&.lines&.first&.strip}"
72
+ puts " Author: #{commit.author_name} <#{commit.author_email}>"
73
+ puts " Manifest: #{change.manifest.path}"
74
+ puts
75
+ end
76
+ end
77
+
78
+ def output_json(changes)
79
+ require "json"
80
+
81
+ data = changes.map do |change|
82
+ {
83
+ date: change.commit.committed_at.iso8601,
84
+ change_type: change.change_type,
85
+ requirement: change.requirement,
86
+ previous_requirement: change.previous_requirement,
87
+ manifest: change.manifest.path,
88
+ ecosystem: change.ecosystem,
89
+ commit: {
90
+ sha: change.commit.sha,
91
+ message: change.commit.message&.lines&.first&.strip,
92
+ author_name: change.commit.author_name,
93
+ author_email: change.commit.author_email
94
+ }
95
+ }
96
+ end
97
+
98
+ puts JSON.pretty_generate(data)
99
+ end
100
+
101
+ def parse_options
102
+ options = {}
103
+
104
+ parser = OptionParser.new do |opts|
105
+ opts.banner = "Usage: git pkgs history <package> [options]"
106
+
107
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
108
+ options[:ecosystem] = v
109
+ end
110
+
111
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
112
+ options[:format] = v
113
+ end
114
+
115
+ opts.on("-h", "--help", "Show this help") do
116
+ puts opts
117
+ exit
118
+ end
119
+ end
120
+
121
+ parser.parse!(@args)
122
+ options
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Hooks
7
+ HOOK_SCRIPT = <<~SCRIPT
8
+ #!/bin/sh
9
+ # git-pkgs auto-update hook
10
+ git pkgs update 2>/dev/null || true
11
+ SCRIPT
12
+
13
+ HOOKS = %w[post-commit post-merge].freeze
14
+
15
+ def initialize(args)
16
+ @args = args
17
+ @options = parse_options
18
+ end
19
+
20
+ def run
21
+ repo = Repository.new
22
+
23
+ if @options[:install]
24
+ install_hooks(repo)
25
+ elsif @options[:uninstall]
26
+ uninstall_hooks(repo)
27
+ else
28
+ show_status(repo)
29
+ end
30
+ end
31
+
32
+ def install_hooks(repo)
33
+ hooks_dir = File.join(repo.git_dir, "hooks")
34
+
35
+ HOOKS.each do |hook_name|
36
+ hook_path = File.join(hooks_dir, hook_name)
37
+
38
+ if File.exist?(hook_path)
39
+ content = File.read(hook_path)
40
+ if content.include?("git-pkgs")
41
+ puts "Hook #{hook_name} already contains git-pkgs"
42
+ next
43
+ end
44
+
45
+ File.open(hook_path, "a") do |f|
46
+ f.puts "\n# git-pkgs auto-update"
47
+ f.puts "git pkgs update 2>/dev/null || true"
48
+ end
49
+ puts "Appended git-pkgs to existing #{hook_name} hook"
50
+ else
51
+ File.write(hook_path, HOOK_SCRIPT)
52
+ File.chmod(0o755, hook_path)
53
+ puts "Created #{hook_name} hook"
54
+ end
55
+ end
56
+
57
+ puts "Hooks installed successfully"
58
+ end
59
+
60
+ def uninstall_hooks(repo)
61
+ hooks_dir = File.join(repo.git_dir, "hooks")
62
+
63
+ HOOKS.each do |hook_name|
64
+ hook_path = File.join(hooks_dir, hook_name)
65
+ next unless File.exist?(hook_path)
66
+
67
+ content = File.read(hook_path)
68
+
69
+ if content.strip == HOOK_SCRIPT.strip
70
+ File.delete(hook_path)
71
+ puts "Removed #{hook_name} hook"
72
+ elsif content.include?("git-pkgs")
73
+ new_content = content.lines.reject { |line|
74
+ line.include?("git-pkgs") || line.include?("git pkgs")
75
+ }.join
76
+ new_content = new_content.gsub(/\n# git-pkgs auto-update\n/, "\n")
77
+
78
+ if new_content.strip.empty? || new_content.strip == "#!/bin/sh"
79
+ File.delete(hook_path)
80
+ puts "Removed #{hook_name} hook"
81
+ else
82
+ File.write(hook_path, new_content)
83
+ puts "Removed git-pkgs from #{hook_name} hook"
84
+ end
85
+ end
86
+ end
87
+
88
+ puts "Hooks uninstalled successfully"
89
+ end
90
+
91
+ def show_status(repo)
92
+ hooks_dir = File.join(repo.git_dir, "hooks")
93
+
94
+ puts "Hook status:"
95
+ HOOKS.each do |hook_name|
96
+ hook_path = File.join(hooks_dir, hook_name)
97
+ if File.exist?(hook_path) && File.read(hook_path).include?("git-pkgs")
98
+ puts " #{hook_name}: installed"
99
+ else
100
+ puts " #{hook_name}: not installed"
101
+ end
102
+ end
103
+ end
104
+
105
+ def parse_options
106
+ options = {}
107
+
108
+ parser = OptionParser.new do |opts|
109
+ opts.banner = "Usage: git pkgs hooks [options]"
110
+
111
+ opts.on("-i", "--install", "Install git hooks for auto-updating") do
112
+ options[:install] = true
113
+ end
114
+
115
+ opts.on("-u", "--uninstall", "Remove git hooks") do
116
+ options[:uninstall] = true
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
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Info
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
+ db_path = Database.path(repo.git_dir)
21
+ Database.connect(repo.git_dir)
22
+
23
+ puts "Database Info"
24
+ puts "=" * 40
25
+ puts
26
+
27
+ # File info
28
+ db_size = File.size(db_path)
29
+ puts "Location: #{db_path}"
30
+ puts "Size: #{format_size(db_size)}"
31
+ puts
32
+
33
+ # Row counts
34
+ puts "Row Counts"
35
+ puts "-" * 40
36
+ counts = {
37
+ "Branches" => Models::Branch.count,
38
+ "Commits" => Models::Commit.count,
39
+ "Branch-Commits" => Models::BranchCommit.count,
40
+ "Manifests" => Models::Manifest.count,
41
+ "Dependency Changes" => Models::DependencyChange.count,
42
+ "Dependency Snapshots" => Models::DependencySnapshot.count
43
+ }
44
+
45
+ counts.each do |name, count|
46
+ puts " #{name.ljust(22)} #{count.to_s.rjust(10)}"
47
+ end
48
+ puts " #{'-' * 34}"
49
+ puts " #{'Total'.ljust(22)} #{counts.values.sum.to_s.rjust(10)}"
50
+ puts
51
+
52
+ # Branch info
53
+ puts "Branches"
54
+ puts "-" * 40
55
+ Models::Branch.all.each do |branch|
56
+ commit_count = branch.commits.count
57
+ last_sha = branch.last_analyzed_sha&.slice(0, 7) || "none"
58
+ puts " #{branch.name}: #{commit_count} commits (last: #{last_sha})"
59
+ end
60
+ puts
61
+
62
+ # Snapshot coverage
63
+ puts "Snapshot Coverage"
64
+ puts "-" * 40
65
+ total_dep_commits = Models::Commit.where(has_dependency_changes: true).count
66
+ snapshot_commits = Models::Commit
67
+ .joins(:dependency_snapshots)
68
+ .distinct
69
+ .count
70
+ puts " Commits with dependency changes: #{total_dep_commits}"
71
+ puts " Commits with snapshots: #{snapshot_commits}"
72
+ if total_dep_commits > 0
73
+ ratio = (snapshot_commits.to_f / total_dep_commits * 100).round(1)
74
+ puts " Coverage: #{ratio}% (1 snapshot per ~#{(total_dep_commits.to_f / snapshot_commits).round(0)} changes)"
75
+ end
76
+ end
77
+
78
+ def format_size(bytes)
79
+ units = %w[B KB MB GB]
80
+ unit_index = 0
81
+ size = bytes.to_f
82
+
83
+ while size >= 1024 && unit_index < units.length - 1
84
+ size /= 1024
85
+ unit_index += 1
86
+ end
87
+
88
+ "#{size.round(1)} #{units[unit_index]}"
89
+ end
90
+
91
+ def parse_options
92
+ options = {}
93
+
94
+ parser = OptionParser.new do |opts|
95
+ opts.banner = "Usage: git pkgs info"
96
+
97
+ opts.on("-h", "--help", "Show this help") do
98
+ puts opts
99
+ exit
100
+ end
101
+ end
102
+
103
+ parser.parse!(@args)
104
+ options
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Init
7
+ BATCH_SIZE = 100
8
+ SNAPSHOT_INTERVAL = 20 # Store snapshot every N dependency-changing commits
9
+
10
+ def initialize(args)
11
+ @args = args
12
+ @options = parse_options
13
+ end
14
+
15
+ def run
16
+ repo = Repository.new
17
+
18
+ if Database.exists?(repo.git_dir) && !@options[:force]
19
+ puts "Database already exists. Use --force to rebuild."
20
+ return
21
+ end
22
+
23
+ Database.drop if @options[:force]
24
+ Database.connect(repo.git_dir)
25
+ Database.create_schema(with_indexes: false)
26
+ Database.optimize_for_bulk_writes
27
+
28
+ branch_name = @options[:branch] || repo.default_branch
29
+ unless repo.branch_exists?(branch_name)
30
+ $stderr.puts "Branch '#{branch_name}' not found"
31
+ exit 1
32
+ end
33
+
34
+ branch = Models::Branch.find_or_create(branch_name)
35
+ analyzer = Analyzer.new(repo)
36
+
37
+ puts "Analyzing branch: #{branch_name}"
38
+
39
+ walker = repo.walk(branch_name, @options[:since])
40
+ commits = walker.to_a
41
+ total = commits.size
42
+
43
+ stats = bulk_process_commits(commits, branch, analyzer, total)
44
+
45
+ branch.update(last_analyzed_sha: repo.branch_target(branch_name))
46
+
47
+ print "\rCreating indexes..."
48
+ Database.create_bulk_indexes
49
+ Database.optimize_for_reads
50
+
51
+ cache_stats = analyzer.cache_stats
52
+
53
+ puts "\rDone!#{' ' * 20}"
54
+ puts "Analyzed #{total} commits"
55
+ puts "Found #{stats[:dependency_commits]} commits with dependency changes"
56
+ puts "Stored #{stats[:snapshots_stored]} snapshots (every #{SNAPSHOT_INTERVAL} changes)"
57
+ puts "Blob cache: #{cache_stats[:cached_blobs]} unique blobs, #{cache_stats[:blobs_with_hits]} had cache hits"
58
+
59
+ if @options[:hooks]
60
+ Commands::Hooks.new(["--install"]).run
61
+ end
62
+ end
63
+
64
+ def bulk_process_commits(commits, branch, analyzer, total)
65
+ now = Time.now
66
+ snapshot = {}
67
+ manifests_cache = {}
68
+
69
+ pending_commits = []
70
+ pending_branch_commits = []
71
+ pending_changes = []
72
+ pending_snapshots = []
73
+
74
+ dependency_commit_count = 0
75
+ snapshots_stored = 0
76
+ processed = 0
77
+
78
+ flush = lambda do
79
+ return if pending_commits.empty?
80
+
81
+ ActiveRecord::Base.transaction do
82
+ Models::Commit.insert_all(pending_commits) if pending_commits.any?
83
+
84
+ commit_ids = Models::Commit
85
+ .where(sha: pending_commits.map { |c| c[:sha] })
86
+ .pluck(:sha, :id).to_h
87
+
88
+ if pending_branch_commits.any?
89
+ branch_commit_records = pending_branch_commits.map do |bc|
90
+ { branch_id: bc[:branch_id], commit_id: commit_ids[bc[:sha]], position: bc[:position] }
91
+ end
92
+ Models::BranchCommit.insert_all(branch_commit_records)
93
+ end
94
+
95
+ if pending_changes.any?
96
+ manifest_ids = Models::Manifest.pluck(:path, :id).to_h
97
+ change_records = pending_changes.map do |c|
98
+ {
99
+ commit_id: commit_ids[c[:sha]],
100
+ manifest_id: manifest_ids[c[:manifest_path]],
101
+ name: c[:name],
102
+ ecosystem: c[:ecosystem],
103
+ change_type: c[:change_type],
104
+ requirement: c[:requirement],
105
+ previous_requirement: c[:previous_requirement],
106
+ dependency_type: c[:dependency_type],
107
+ created_at: now,
108
+ updated_at: now
109
+ }
110
+ end
111
+ Models::DependencyChange.insert_all(change_records)
112
+ end
113
+
114
+ if pending_snapshots.any?
115
+ manifest_ids ||= Models::Manifest.pluck(:path, :id).to_h
116
+ snapshot_records = pending_snapshots.map do |s|
117
+ {
118
+ commit_id: commit_ids[s[:sha]],
119
+ manifest_id: manifest_ids[s[:manifest_path]],
120
+ name: s[:name],
121
+ ecosystem: s[:ecosystem],
122
+ requirement: s[:requirement],
123
+ dependency_type: s[:dependency_type],
124
+ created_at: now,
125
+ updated_at: now
126
+ }
127
+ end
128
+ Models::DependencySnapshot.insert_all(snapshot_records)
129
+ end
130
+ end
131
+
132
+ pending_commits.clear
133
+ pending_branch_commits.clear
134
+ pending_changes.clear
135
+ pending_snapshots.clear
136
+ end
137
+
138
+ commits.each do |rugged_commit|
139
+ processed += 1
140
+ print "\rProcessing commit #{processed}/#{total}..." if processed % 50 == 0 || processed == total
141
+
142
+ next if rugged_commit.parents.length > 1 # skip merge commits
143
+
144
+ result = analyzer.analyze_commit(rugged_commit, snapshot)
145
+ has_changes = result && result[:changes].any?
146
+
147
+ pending_commits << {
148
+ sha: rugged_commit.oid,
149
+ message: rugged_commit.message,
150
+ author_name: rugged_commit.author[:name],
151
+ author_email: rugged_commit.author[:email],
152
+ committed_at: rugged_commit.time,
153
+ has_dependency_changes: has_changes,
154
+ created_at: now,
155
+ updated_at: now
156
+ }
157
+
158
+ pending_branch_commits << {
159
+ branch_id: branch.id,
160
+ sha: rugged_commit.oid,
161
+ position: processed
162
+ }
163
+
164
+ if has_changes
165
+ dependency_commit_count += 1
166
+
167
+ result[:changes].each do |change|
168
+ manifest_key = change[:manifest_path]
169
+ unless manifests_cache[manifest_key]
170
+ manifests_cache[manifest_key] = Models::Manifest.find_or_create(
171
+ path: change[:manifest_path],
172
+ ecosystem: change[:ecosystem],
173
+ kind: change[:kind]
174
+ )
175
+ end
176
+
177
+ pending_changes << {
178
+ sha: rugged_commit.oid,
179
+ manifest_path: manifest_key,
180
+ name: change[:name],
181
+ ecosystem: change[:ecosystem],
182
+ change_type: change[:change_type],
183
+ requirement: change[:requirement],
184
+ previous_requirement: change[:previous_requirement],
185
+ dependency_type: change[:dependency_type]
186
+ }
187
+ end
188
+
189
+ snapshot = result[:snapshot]
190
+
191
+ # Store snapshot at intervals
192
+ if dependency_commit_count % SNAPSHOT_INTERVAL == 0
193
+ snapshot.each do |(manifest_path, name), dep_info|
194
+ pending_snapshots << {
195
+ sha: rugged_commit.oid,
196
+ manifest_path: manifest_path,
197
+ name: name,
198
+ ecosystem: dep_info[:ecosystem],
199
+ requirement: dep_info[:requirement],
200
+ dependency_type: dep_info[:dependency_type]
201
+ }
202
+ end
203
+ snapshots_stored += snapshot.size
204
+ end
205
+ end
206
+
207
+ flush.call if pending_commits.size >= BATCH_SIZE
208
+ end
209
+
210
+ # Always store final snapshot for HEAD
211
+ if snapshot.any?
212
+ last_sha = commits.last&.oid
213
+ if last_sha && !pending_snapshots.any? { |s| s[:sha] == last_sha }
214
+ snapshot.each do |(manifest_path, name), dep_info|
215
+ pending_snapshots << {
216
+ sha: last_sha,
217
+ manifest_path: manifest_path,
218
+ name: name,
219
+ ecosystem: dep_info[:ecosystem],
220
+ requirement: dep_info[:requirement],
221
+ dependency_type: dep_info[:dependency_type]
222
+ }
223
+ end
224
+ snapshots_stored += snapshot.size
225
+ end
226
+ end
227
+
228
+ flush.call
229
+
230
+ { dependency_commits: dependency_commit_count, snapshots_stored: snapshots_stored }
231
+ end
232
+
233
+ def parse_options
234
+ options = {}
235
+
236
+ parser = OptionParser.new do |opts|
237
+ opts.banner = "Usage: git pkgs init [options]"
238
+
239
+ opts.on("-b", "--branch=NAME", "Branch to analyze (default: default branch)") do |v|
240
+ options[:branch] = v
241
+ end
242
+
243
+ opts.on("-s", "--since=SHA", "Start from specific commit") do |v|
244
+ options[:since] = v
245
+ end
246
+
247
+ opts.on("-f", "--force", "Rebuild database from scratch") do
248
+ options[:force] = true
249
+ end
250
+
251
+ opts.on("--hooks", "Install git hooks for auto-updating") do
252
+ options[:hooks] = true
253
+ end
254
+
255
+ opts.on("-h", "--help", "Show this help") do
256
+ puts opts
257
+ exit
258
+ end
259
+ end
260
+
261
+ parser.parse!(@args)
262
+ options
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end