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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class List
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
+ commit_sha = @options[:commit] || repo.head_sha
23
+ target_commit = Models::Commit.find_by(sha: commit_sha)
24
+
25
+ unless target_commit
26
+ $stderr.puts "Commit #{commit_sha[0, 7]} not found in database"
27
+ exit 1
28
+ end
29
+
30
+ deps = compute_dependencies_at_commit(target_commit, repo)
31
+
32
+ if deps.empty?
33
+ puts "No dependencies found"
34
+ return
35
+ end
36
+
37
+ # Apply filters
38
+ if @options[:ecosystem]
39
+ deps = deps.select { |d| d[:ecosystem] == @options[:ecosystem] }
40
+ end
41
+
42
+ if @options[:type]
43
+ deps = deps.select { |d| d[:dependency_type] == @options[:type] }
44
+ end
45
+
46
+ if @options[:format] == "json"
47
+ require "json"
48
+ puts JSON.pretty_generate(deps)
49
+ else
50
+ grouped = deps.group_by { |d| [d[:manifest_path], d[:ecosystem]] }
51
+
52
+ grouped.each do |(path, platform), manifest_deps|
53
+ puts "#{path} (#{platform}):"
54
+ manifest_deps.sort_by { |d| d[:name] }.each do |dep|
55
+ type_suffix = dep[:dependency_type] ? " [#{dep[:dependency_type]}]" : ""
56
+ puts " #{dep[:name]} #{dep[:requirement]}#{type_suffix}"
57
+ end
58
+ puts
59
+ end
60
+ end
61
+ end
62
+
63
+ def compute_dependencies_at_commit(target_commit, repo)
64
+ branch_name = @options[:branch] || repo.default_branch
65
+ branch = Models::Branch.find_by(name: branch_name)
66
+ return [] unless branch
67
+
68
+ # Find the nearest snapshot commit before or at target
69
+ snapshot_commit = branch.commits
70
+ .joins(:dependency_snapshots)
71
+ .where("commits.committed_at <= ?", target_commit.committed_at)
72
+ .order(committed_at: :desc)
73
+ .distinct
74
+ .first
75
+
76
+ # Build initial state from snapshot
77
+ deps = {}
78
+ if snapshot_commit
79
+ snapshot_commit.dependency_snapshots.includes(:manifest).each do |s|
80
+ key = [s.manifest.path, s.name]
81
+ deps[key] = {
82
+ manifest_path: s.manifest.path,
83
+ name: s.name,
84
+ ecosystem: s.ecosystem,
85
+ requirement: s.requirement,
86
+ dependency_type: s.dependency_type
87
+ }
88
+ end
89
+ end
90
+
91
+ # Replay changes from snapshot to target
92
+ if snapshot_commit && snapshot_commit.id != target_commit.id
93
+ changes = Models::DependencyChange
94
+ .joins(:commit, :manifest)
95
+ .where(commits: { id: branch.commit_ids })
96
+ .where("commits.committed_at > ? AND commits.committed_at <= ?",
97
+ snapshot_commit.committed_at, target_commit.committed_at)
98
+ .order("commits.committed_at ASC")
99
+ .includes(:manifest)
100
+
101
+ changes.each do |change|
102
+ key = [change.manifest.path, change.name]
103
+ case change.change_type
104
+ when "added", "modified"
105
+ deps[key] = {
106
+ manifest_path: change.manifest.path,
107
+ name: change.name,
108
+ ecosystem: change.ecosystem,
109
+ requirement: change.requirement,
110
+ dependency_type: change.dependency_type
111
+ }
112
+ when "removed"
113
+ deps.delete(key)
114
+ end
115
+ end
116
+ end
117
+
118
+ deps.values
119
+ end
120
+
121
+ def parse_options
122
+ options = {}
123
+
124
+ parser = OptionParser.new do |opts|
125
+ opts.banner = "Usage: git pkgs list [options]"
126
+
127
+ opts.on("-c", "--commit=SHA", "Show dependencies at specific commit") do |v|
128
+ options[:commit] = v
129
+ end
130
+
131
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem (npm, rubygems, etc.)") do |v|
132
+ options[:ecosystem] = v
133
+ end
134
+
135
+ opts.on("-t", "--type=TYPE", "Filter by dependency type") do |v|
136
+ options[:type] = v
137
+ end
138
+
139
+ opts.on("-b", "--branch=NAME", "Branch context for finding snapshots") do |v|
140
+ options[:branch] = v
141
+ end
142
+
143
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
144
+ options[:format] = v
145
+ end
146
+
147
+ opts.on("-h", "--help", "Show this help") do
148
+ puts opts
149
+ exit
150
+ end
151
+ end
152
+
153
+ parser.parse!(@args)
154
+ options
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Outdated
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
+ branch_name = @options[:branch] || repo.default_branch
23
+ branch = Models::Branch.find_by(name: branch_name)
24
+
25
+ unless branch&.last_analyzed_sha
26
+ $stderr.puts "No analysis found for branch '#{branch_name}'"
27
+ exit 1
28
+ end
29
+
30
+ current_commit = Models::Commit.find_by(sha: branch.last_analyzed_sha)
31
+ snapshots = current_commit&.dependency_snapshots&.includes(:manifest) || []
32
+
33
+ if @options[:ecosystem]
34
+ snapshots = snapshots.where(ecosystem: @options[:ecosystem])
35
+ end
36
+
37
+ if snapshots.empty?
38
+ puts "No dependencies found"
39
+ return
40
+ end
41
+
42
+ # Find last update for each dependency
43
+ outdated_data = []
44
+
45
+ snapshots.each do |snapshot|
46
+ last_change = Models::DependencyChange
47
+ .includes(:commit)
48
+ .where(name: snapshot.name, manifest: snapshot.manifest)
49
+ .order("commits.committed_at DESC")
50
+ .first
51
+
52
+ next unless last_change
53
+
54
+ days_since_update = ((Time.now - last_change.commit.committed_at) / 86400).to_i
55
+
56
+ outdated_data << {
57
+ name: snapshot.name,
58
+ ecosystem: snapshot.ecosystem,
59
+ requirement: snapshot.requirement,
60
+ manifest: snapshot.manifest.path,
61
+ last_updated: last_change.commit.committed_at,
62
+ days_ago: days_since_update,
63
+ change_type: last_change.change_type
64
+ }
65
+ end
66
+
67
+ # Sort by days since last update (oldest first)
68
+ outdated_data.sort_by! { |d| -d[:days_ago] }
69
+
70
+ if @options[:days]
71
+ outdated_data = outdated_data.select { |d| d[:days_ago] >= @options[:days] }
72
+ end
73
+
74
+ if outdated_data.empty?
75
+ puts "All dependencies have been updated recently"
76
+ return
77
+ end
78
+
79
+ puts "Dependencies by last update:"
80
+ puts
81
+
82
+ max_name_len = outdated_data.map { |d| d[:name].length }.max
83
+ max_version_len = outdated_data.map { |d| d[:requirement].to_s.length }.max
84
+
85
+ outdated_data.each do |dep|
86
+ date = dep[:last_updated].strftime("%Y-%m-%d")
87
+ days = "#{dep[:days_ago]} days ago"
88
+ puts "#{dep[:name].ljust(max_name_len)} #{dep[:requirement].to_s.ljust(max_version_len)} #{date} (#{days})"
89
+ end
90
+ end
91
+
92
+ def parse_options
93
+ options = {}
94
+
95
+ parser = OptionParser.new do |opts|
96
+ opts.banner = "Usage: git pkgs outdated [options]"
97
+
98
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
99
+ options[:ecosystem] = v
100
+ end
101
+
102
+ opts.on("-b", "--branch=NAME", "Branch to analyze") do |v|
103
+ options[:branch] = v
104
+ end
105
+
106
+ opts.on("-d", "--days=N", Integer, "Only show deps not updated in N days") do |v|
107
+ options[:days] = v
108
+ end
109
+
110
+ opts.on("-h", "--help", "Show this help") do
111
+ puts opts
112
+ exit
113
+ end
114
+ end
115
+
116
+ parser.parse!(@args)
117
+ options
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Search
7
+ def initialize(args)
8
+ @args = args
9
+ @options = parse_options
10
+ end
11
+
12
+ def run
13
+ pattern = @args.first
14
+
15
+ unless pattern
16
+ $stderr.puts "Usage: git pkgs search <pattern>"
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
+ # Search for dependencies matching the pattern
30
+ query = Models::DependencyChange
31
+ .joins(:manifest)
32
+ .where("dependency_changes.name LIKE ?", "%#{pattern}%")
33
+
34
+ if @options[:ecosystem]
35
+ query = query.where(ecosystem: @options[:ecosystem])
36
+ end
37
+
38
+ if @options[:direct]
39
+ query = query.where(manifests: { kind: "manifest" })
40
+ end
41
+
42
+ # Get unique dependency names
43
+ matches = query.distinct.pluck(:name, :ecosystem)
44
+
45
+ if matches.empty?
46
+ puts "No dependencies found matching '#{pattern}'"
47
+ return
48
+ end
49
+
50
+ if @options[:format] == "json"
51
+ output_json(matches, pattern)
52
+ else
53
+ output_text(matches, pattern)
54
+ end
55
+ end
56
+
57
+ def output_text(matches, pattern)
58
+ puts "Dependencies matching '#{pattern}':"
59
+ puts
60
+
61
+ matches.group_by { |_, platform| platform }.each do |platform, deps|
62
+ puts "#{platform}:"
63
+ deps.each do |name, _|
64
+ summary = dependency_summary(name, platform)
65
+ puts " #{name}"
66
+ puts " #{summary}"
67
+ end
68
+ puts
69
+ end
70
+ end
71
+
72
+ def output_json(matches, pattern)
73
+ require "json"
74
+
75
+ results = matches.map do |name, platform|
76
+ changes = Models::DependencyChange
77
+ .where(name: name, ecosystem: platform)
78
+ .includes(:commit)
79
+ .order("commits.committed_at ASC")
80
+
81
+ first = changes.first
82
+ last = changes.last
83
+ current = changes.where(change_type: %w[added modified]).last
84
+
85
+ {
86
+ name: name,
87
+ ecosystem: platform,
88
+ first_seen: first&.commit&.committed_at&.iso8601,
89
+ last_changed: last&.commit&.committed_at&.iso8601,
90
+ current_version: current&.requirement,
91
+ removed: changes.last&.change_type == "removed",
92
+ total_changes: changes.count
93
+ }
94
+ end
95
+
96
+ puts JSON.pretty_generate(results)
97
+ end
98
+
99
+ def dependency_summary(name, platform)
100
+ changes = Models::DependencyChange
101
+ .where(name: name, ecosystem: platform)
102
+ .includes(:commit)
103
+ .order("commits.committed_at ASC")
104
+
105
+ first = changes.first
106
+ last = changes.last
107
+
108
+ parts = []
109
+ parts << "added #{first.commit.committed_at.strftime('%Y-%m-%d')}"
110
+
111
+ if last.change_type == "removed"
112
+ parts << "removed #{last.commit.committed_at.strftime('%Y-%m-%d')}"
113
+ else
114
+ current = changes.where(change_type: %w[added modified]).last
115
+ parts << "current: #{current&.requirement || 'unknown'}"
116
+ end
117
+
118
+ parts << "#{changes.count} changes"
119
+ parts.join(", ")
120
+ end
121
+
122
+ def parse_options
123
+ options = {}
124
+
125
+ parser = OptionParser.new do |opts|
126
+ opts.banner = "Usage: git pkgs search <pattern> [options]"
127
+
128
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
129
+ options[:ecosystem] = v
130
+ end
131
+
132
+ opts.on("-d", "--direct", "Only show direct dependencies (not from lockfiles)") do
133
+ options[:direct] = true
134
+ end
135
+
136
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
137
+ options[:format] = v
138
+ end
139
+
140
+ opts.on("-h", "--help", "Show this help") do
141
+ puts opts
142
+ exit
143
+ end
144
+ end
145
+
146
+ parser.parse!(@args)
147
+ options
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Stats
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
+ branch_name = @options[:branch] || repo.default_branch
23
+ branch = Models::Branch.find_by(name: branch_name)
24
+
25
+ data = collect_stats(branch, branch_name)
26
+
27
+ if @options[:format] == "json"
28
+ require "json"
29
+ puts JSON.pretty_generate(data)
30
+ else
31
+ output_text(data)
32
+ end
33
+ end
34
+
35
+ def collect_stats(branch, branch_name)
36
+ data = {
37
+ branch: branch_name,
38
+ commits_analyzed: branch&.commits&.count || 0,
39
+ commits_with_changes: branch&.commits&.where(has_dependency_changes: true)&.count || 0,
40
+ current_dependencies: {},
41
+ changes: {},
42
+ most_changed: [],
43
+ manifests: []
44
+ }
45
+
46
+ if branch&.last_analyzed_sha
47
+ current_commit = Models::Commit.find_by(sha: branch.last_analyzed_sha)
48
+ snapshots = current_commit&.dependency_snapshots || []
49
+
50
+ data[:current_dependencies] = {
51
+ total: snapshots.count,
52
+ by_platform: snapshots.group(:ecosystem).count,
53
+ by_type: snapshots.group(:dependency_type).count
54
+ }
55
+ end
56
+
57
+ data[:changes] = {
58
+ total: Models::DependencyChange.count,
59
+ by_type: Models::DependencyChange.group(:change_type).count
60
+ }
61
+
62
+ most_changed = Models::DependencyChange
63
+ .group(:name, :ecosystem)
64
+ .order("count_all DESC")
65
+ .limit(10)
66
+ .count
67
+
68
+ data[:most_changed] = most_changed.map do |(name, ecosystem), count|
69
+ { name: name, ecosystem: ecosystem, changes: count }
70
+ end
71
+
72
+ data[:manifests] = Models::Manifest.all.map do |manifest|
73
+ { path: manifest.path, ecosystem: manifest.ecosystem, changes: manifest.dependency_changes.count }
74
+ end
75
+
76
+ data
77
+ end
78
+
79
+ def output_text(data)
80
+ puts "Dependency Statistics"
81
+ puts "=" * 40
82
+ puts
83
+
84
+ puts "Branch: #{data[:branch]}"
85
+ puts "Commits analyzed: #{data[:commits_analyzed]}"
86
+ puts "Commits with changes: #{data[:commits_with_changes]}"
87
+ puts
88
+
89
+ if data[:current_dependencies][:total]
90
+ puts "Current Dependencies"
91
+ puts "-" * 20
92
+ puts "Total: #{data[:current_dependencies][:total]}"
93
+
94
+ data[:current_dependencies][:by_platform].sort_by { |_, c| -c }.each do |ecosystem, count|
95
+ puts " #{ecosystem}: #{count}"
96
+ end
97
+
98
+ by_type = data[:current_dependencies][:by_type]
99
+ if by_type.keys.compact.any?
100
+ puts
101
+ puts "By type:"
102
+ by_type.sort_by { |_, c| -c }.each do |type, count|
103
+ puts " #{type || 'unknown'}: #{count}"
104
+ end
105
+ end
106
+ end
107
+
108
+ puts
109
+ puts "Dependency Changes"
110
+ puts "-" * 20
111
+ puts "Total changes: #{data[:changes][:total]}"
112
+ data[:changes][:by_type].each do |type, count|
113
+ puts " #{type}: #{count}"
114
+ end
115
+
116
+ puts
117
+ puts "Most Changed Dependencies"
118
+ puts "-" * 25
119
+ data[:most_changed].each do |dep|
120
+ puts " #{dep[:name]} (#{dep[:ecosystem]}): #{dep[:changes]} changes"
121
+ end
122
+
123
+ puts
124
+ puts "Manifest Files"
125
+ puts "-" * 14
126
+ data[:manifests].each do |m|
127
+ puts " #{m[:path]} (#{m[:ecosystem]}): #{m[:changes]} changes"
128
+ end
129
+ end
130
+
131
+ def parse_options
132
+ options = {}
133
+
134
+ parser = OptionParser.new do |opts|
135
+ opts.banner = "Usage: git pkgs stats [options]"
136
+
137
+ opts.on("-b", "--branch=NAME", "Branch to analyze") do |v|
138
+ options[:branch] = v
139
+ end
140
+
141
+ opts.on("-f", "--format=FORMAT", "Output format (text, json)") do |v|
142
+ options[:format] = v
143
+ end
144
+
145
+ opts.on("-h", "--help", "Show this help") do
146
+ puts opts
147
+ exit
148
+ end
149
+ end
150
+
151
+ parser.parse!(@args)
152
+ options
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end