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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Tree
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 the commit to analyze
23
+ commit_sha = @options[:commit] || repo.head_sha
24
+ commit = find_commit_with_snapshot(commit_sha, repo)
25
+
26
+ unless commit
27
+ $stderr.puts "No dependency data found for commit #{commit_sha[0, 7]}"
28
+ exit 1
29
+ end
30
+
31
+ # Get current snapshots
32
+ snapshots = 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
+ # Group by manifest and build tree
44
+ grouped = snapshots.group_by { |s| s.manifest }
45
+
46
+ grouped.each do |manifest, deps|
47
+ puts "#{manifest.path} (#{manifest.ecosystem})"
48
+ puts
49
+
50
+ # Separate by dependency type
51
+ by_type = deps.group_by { |d| d.dependency_type || "runtime" }
52
+
53
+ by_type.each do |type, type_deps|
54
+ puts " [#{type}]"
55
+ type_deps.sort_by(&:name).each do |dep|
56
+ print_dependency(dep, 2)
57
+ end
58
+ puts
59
+ end
60
+ end
61
+
62
+ # Show summary
63
+ puts "Total: #{snapshots.count} dependencies across #{grouped.keys.count} manifest(s)"
64
+ end
65
+
66
+ def print_dependency(dep, indent)
67
+ prefix = " " * indent
68
+ version = dep.requirement || "*"
69
+ puts "#{prefix}#{dep.name} #{version}"
70
+
71
+ # If this manifest has lockfile data, we could show transitive deps
72
+ # For now, we just show the direct dependencies
73
+ # Future enhancement: parse lockfiles to show full tree
74
+ end
75
+
76
+ def find_commit_with_snapshot(sha, repo)
77
+ commit = Models::Commit.find_by(sha: sha) ||
78
+ Models::Commit.where("sha LIKE ?", "#{sha}%").first
79
+ return commit if commit&.dependency_snapshots&.any?
80
+
81
+ # Find most recent commit with a snapshot
82
+ branch_name = @options[:branch] || repo.default_branch
83
+ branch = Models::Branch.find_by(name: branch_name)
84
+ return nil unless branch
85
+
86
+ branch.commits
87
+ .joins(:dependency_snapshots)
88
+ .where("commits.committed_at <= ?", commit&.committed_at || Time.now)
89
+ .order(committed_at: :desc)
90
+ .distinct
91
+ .first
92
+ end
93
+
94
+ def parse_options
95
+ options = {}
96
+
97
+ parser = OptionParser.new do |opts|
98
+ opts.banner = "Usage: git pkgs tree [options]"
99
+
100
+ opts.on("-c", "--commit=SHA", "Show dependencies at specific commit") do |v|
101
+ options[:commit] = v
102
+ end
103
+
104
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
105
+ options[:ecosystem] = v
106
+ end
107
+
108
+ opts.on("-b", "--branch=NAME", "Branch context for finding snapshots") do |v|
109
+ options[:branch] = v
110
+ end
111
+
112
+ opts.on("-h", "--help", "Show this help") do
113
+ puts opts
114
+ exit
115
+ end
116
+ end
117
+
118
+ parser.parse!(@args)
119
+ options
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Update
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
26
+ $stderr.puts "Branch '#{branch_name}' not in database. Run 'git pkgs init --branch=#{branch_name}' first."
27
+ exit 1
28
+ end
29
+
30
+ since_sha = branch.last_analyzed_sha
31
+ current_sha = repo.branch_target(branch_name)
32
+
33
+ if since_sha == current_sha
34
+ puts "Already up to date."
35
+ return
36
+ end
37
+
38
+ analyzer = Analyzer.new(repo)
39
+
40
+ # Get current snapshot from last analyzed commit
41
+ last_commit = Models::Commit.find_by(sha: since_sha)
42
+ snapshot = {}
43
+
44
+ if last_commit
45
+ last_commit.dependency_snapshots.includes(:manifest).each do |s|
46
+ key = [s.manifest.path, s.name]
47
+ snapshot[key] = {
48
+ ecosystem: s.ecosystem,
49
+ requirement: s.requirement,
50
+ dependency_type: s.dependency_type
51
+ }
52
+ end
53
+ end
54
+
55
+ walker = repo.walk(branch_name, since_sha)
56
+ commits = walker.to_a
57
+ total = commits.size
58
+ processed = 0
59
+ dependency_commits = 0
60
+ last_position = Models::BranchCommit.where(branch: branch).maximum(:position) || 0
61
+
62
+ puts "Updating branch: #{branch_name}"
63
+ puts "Found #{total} new commits"
64
+
65
+ commits.each do |rugged_commit|
66
+ processed += 1
67
+ print "\rProcessing commit #{processed}/#{total}..."
68
+
69
+ result = analyzer.analyze_commit(rugged_commit, snapshot)
70
+
71
+ commit = Models::Commit.find_or_create_from_rugged(rugged_commit)
72
+ Models::BranchCommit.find_or_create_by(
73
+ branch: branch,
74
+ commit: commit,
75
+ position: last_position + processed
76
+ )
77
+
78
+ if result && result[:changes].any?
79
+ dependency_commits += 1
80
+ commit.update(has_dependency_changes: true)
81
+
82
+ result[:changes].each do |change|
83
+ manifest = Models::Manifest.find_or_create(
84
+ path: change[:manifest_path],
85
+ ecosystem: change[:ecosystem],
86
+ kind: change[:kind]
87
+ )
88
+
89
+ Models::DependencyChange.create!(
90
+ commit: commit,
91
+ manifest: manifest,
92
+ name: change[:name],
93
+ ecosystem: change[:ecosystem],
94
+ change_type: change[:change_type],
95
+ requirement: change[:requirement],
96
+ previous_requirement: change[:previous_requirement],
97
+ dependency_type: change[:dependency_type]
98
+ )
99
+ end
100
+
101
+ snapshot = result[:snapshot]
102
+
103
+ snapshot.each do |(manifest_path, name), dep_info|
104
+ manifest = Models::Manifest.find_by(path: manifest_path)
105
+ Models::DependencySnapshot.find_or_create_by(
106
+ commit: commit,
107
+ manifest: manifest,
108
+ name: name
109
+ ) do |s|
110
+ s.ecosystem = dep_info[:ecosystem]
111
+ s.requirement = dep_info[:requirement]
112
+ s.dependency_type = dep_info[:dependency_type]
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ branch.update(last_analyzed_sha: current_sha)
119
+
120
+ puts "\nDone!"
121
+ puts "Processed #{total} new commits"
122
+ puts "Found #{dependency_commits} commits with dependency changes"
123
+ end
124
+
125
+ def parse_options
126
+ options = {}
127
+
128
+ parser = OptionParser.new do |opts|
129
+ opts.banner = "Usage: git pkgs update [options]"
130
+
131
+ opts.on("-b", "--branch=NAME", "Branch to update (default: default branch)") do |v|
132
+ options[:branch] = v
133
+ end
134
+
135
+ opts.on("-h", "--help", "Show this help") do
136
+ puts opts
137
+ exit
138
+ end
139
+ end
140
+
141
+ parser.parse!(@args)
142
+ options
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Why
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 why <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
+ # Find the first time this package was added
30
+ added_change = Models::DependencyChange
31
+ .includes(:commit, :manifest)
32
+ .for_package(package_name)
33
+ .added
34
+ .order("commits.committed_at ASC")
35
+
36
+ if @options[:ecosystem]
37
+ added_change = added_change.for_platform(@options[:ecosystem])
38
+ end
39
+
40
+ added_change = added_change.first
41
+
42
+ unless added_change
43
+ puts "Package '#{package_name}' not found in dependency history"
44
+ return
45
+ end
46
+
47
+ commit = added_change.commit
48
+
49
+ puts "#{package_name} was added in commit #{commit.short_sha}"
50
+ puts
51
+ puts "Date: #{commit.committed_at.strftime("%Y-%m-%d %H:%M")}"
52
+ puts "Author: #{commit.author_name} <#{commit.author_email}>"
53
+ puts "Manifest: #{added_change.manifest.path}"
54
+ puts "Version: #{added_change.requirement}"
55
+ puts
56
+ puts "Commit message:"
57
+ puts commit.message.to_s.lines.map { |l| " #{l}" }.join
58
+ end
59
+
60
+ def parse_options
61
+ options = {}
62
+
63
+ parser = OptionParser.new do |opts|
64
+ opts.banner = "Usage: git pkgs why <package> [options]"
65
+
66
+ opts.on("-e", "--ecosystem=NAME", "Filter by ecosystem") do |v|
67
+ options[:ecosystem] = v
68
+ end
69
+
70
+ opts.on("-h", "--help", "Show this help") do
71
+ puts opts
72
+ exit
73
+ end
74
+ end
75
+
76
+ parser.parse!(@args)
77
+ options
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "sqlite3"
5
+
6
+ module Git
7
+ module Pkgs
8
+ class Database
9
+ DB_FILE = "pkgs.sqlite3"
10
+
11
+ def self.path(git_dir = nil)
12
+ git_dir ||= find_git_dir
13
+ File.join(git_dir, DB_FILE)
14
+ end
15
+
16
+ def self.find_git_dir
17
+ dir = Dir.pwd
18
+ loop do
19
+ git_dir = File.join(dir, ".git")
20
+ return git_dir if File.directory?(git_dir)
21
+
22
+ parent = File.dirname(dir)
23
+ raise NotInGitRepoError, "Not in a git repository" if parent == dir
24
+
25
+ dir = parent
26
+ end
27
+ end
28
+
29
+ def self.connect(git_dir = nil)
30
+ db_path = path(git_dir)
31
+ ActiveRecord::Base.establish_connection(
32
+ adapter: "sqlite3",
33
+ database: db_path
34
+ )
35
+ end
36
+
37
+ def self.connect_memory
38
+ ActiveRecord::Base.establish_connection(
39
+ adapter: "sqlite3",
40
+ database: ":memory:"
41
+ )
42
+ create_schema
43
+ end
44
+
45
+ def self.exists?(git_dir = nil)
46
+ File.exist?(path(git_dir))
47
+ end
48
+
49
+ def self.create_schema(with_indexes: true)
50
+ ActiveRecord::Schema.define do
51
+ create_table :branches, if_not_exists: true do |t|
52
+ t.string :name, null: false
53
+ t.string :last_analyzed_sha
54
+ t.timestamps
55
+ end
56
+ add_index :branches, :name, unique: true, if_not_exists: true
57
+
58
+ create_table :commits, if_not_exists: true do |t|
59
+ t.string :sha, null: false
60
+ t.text :message
61
+ t.string :author_name
62
+ t.string :author_email
63
+ t.datetime :committed_at
64
+ t.boolean :has_dependency_changes, default: false
65
+ t.timestamps
66
+ end
67
+ add_index :commits, :sha, unique: true, if_not_exists: true
68
+
69
+ create_table :branch_commits, if_not_exists: true do |t|
70
+ t.references :branch, foreign_key: true
71
+ t.references :commit, foreign_key: true
72
+ t.integer :position
73
+ end
74
+ add_index :branch_commits, [:branch_id, :commit_id], unique: true, if_not_exists: true
75
+
76
+ create_table :manifests, if_not_exists: true do |t|
77
+ t.string :path, null: false
78
+ t.string :ecosystem
79
+ t.string :kind
80
+ t.timestamps
81
+ end
82
+ add_index :manifests, :path, if_not_exists: true
83
+
84
+ create_table :dependency_changes, if_not_exists: true do |t|
85
+ t.references :commit, foreign_key: true
86
+ t.references :manifest, foreign_key: true
87
+ t.string :name, null: false
88
+ t.string :ecosystem
89
+ t.string :change_type, null: false
90
+ t.string :requirement
91
+ t.string :previous_requirement
92
+ t.string :dependency_type
93
+ t.timestamps
94
+ end
95
+
96
+ create_table :dependency_snapshots, if_not_exists: true do |t|
97
+ t.references :commit, foreign_key: true
98
+ t.references :manifest, foreign_key: true
99
+ t.string :name, null: false
100
+ t.string :ecosystem
101
+ t.string :requirement
102
+ t.string :dependency_type
103
+ t.timestamps
104
+ end
105
+ end
106
+
107
+ create_bulk_indexes if with_indexes
108
+ end
109
+
110
+ def self.create_bulk_indexes
111
+ conn = ActiveRecord::Base.connection
112
+
113
+ # dependency_changes indexes
114
+ conn.add_index :dependency_changes, :name, if_not_exists: true
115
+ conn.add_index :dependency_changes, :ecosystem, if_not_exists: true
116
+ conn.add_index :dependency_changes, [:commit_id, :name], if_not_exists: true
117
+
118
+ # dependency_snapshots indexes
119
+ conn.add_index :dependency_snapshots, [:commit_id, :manifest_id, :name],
120
+ unique: true, name: "idx_snapshots_unique", if_not_exists: true
121
+ conn.add_index :dependency_snapshots, :name, if_not_exists: true
122
+ conn.add_index :dependency_snapshots, :ecosystem, if_not_exists: true
123
+ end
124
+
125
+ def self.optimize_for_bulk_writes
126
+ conn = ActiveRecord::Base.connection
127
+ conn.execute("PRAGMA synchronous = OFF")
128
+ conn.execute("PRAGMA journal_mode = WAL")
129
+ conn.execute("PRAGMA cache_size = -64000") # 64MB cache
130
+ end
131
+
132
+ def self.optimize_for_reads
133
+ conn = ActiveRecord::Base.connection
134
+ conn.execute("PRAGMA synchronous = NORMAL")
135
+ end
136
+
137
+ def self.drop(git_dir = nil)
138
+ ActiveRecord::Base.connection.close if ActiveRecord::Base.connected?
139
+ File.delete(path(git_dir)) if exists?(git_dir)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class Branch < ActiveRecord::Base
7
+ has_many :branch_commits, dependent: :destroy
8
+ has_many :commits, through: :branch_commits
9
+
10
+ validates :name, presence: true, uniqueness: true
11
+
12
+ def self.find_or_create(name)
13
+ find_or_create_by(name: name)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class BranchCommit < ActiveRecord::Base
7
+ belongs_to :branch
8
+ belongs_to :commit
9
+
10
+ validates :branch_id, uniqueness: { scope: :commit_id }
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class Commit < ActiveRecord::Base
7
+ has_many :branch_commits, dependent: :destroy
8
+ has_many :branches, through: :branch_commits
9
+ has_many :dependency_changes, dependent: :destroy
10
+ has_many :dependency_snapshots, dependent: :destroy
11
+
12
+ validates :sha, presence: true, uniqueness: true
13
+
14
+ def self.find_or_create_from_rugged(rugged_commit)
15
+ find_or_create_by(sha: rugged_commit.oid) do |commit|
16
+ commit.message = rugged_commit.message&.strip
17
+ commit.author_name = rugged_commit.author[:name]
18
+ commit.author_email = rugged_commit.author[:email]
19
+ commit.committed_at = rugged_commit.time
20
+ end
21
+ end
22
+
23
+ def short_sha
24
+ sha[0, 7]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class DependencyChange < ActiveRecord::Base
7
+ belongs_to :commit
8
+ belongs_to :manifest
9
+
10
+ validates :name, presence: true
11
+ validates :change_type, presence: true, inclusion: { in: %w[added modified removed] }
12
+
13
+ scope :added, -> { where(change_type: "added") }
14
+ scope :modified, -> { where(change_type: "modified") }
15
+ scope :removed, -> { where(change_type: "removed") }
16
+ scope :for_package, ->(name) { where(name: name) }
17
+ scope :for_platform, ->(platform) { where(ecosystem: platform) }
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class DependencySnapshot < ActiveRecord::Base
7
+ belongs_to :commit
8
+ belongs_to :manifest
9
+
10
+ validates :name, presence: true
11
+
12
+ scope :for_package, ->(name) { where(name: name) }
13
+ scope :for_platform, ->(platform) { where(ecosystem: platform) }
14
+ scope :at_commit, ->(commit) { where(commit: commit) }
15
+
16
+ def self.current_for_branch(branch)
17
+ return none unless branch.last_analyzed_sha
18
+
19
+ commit = Commit.find_by(sha: branch.last_analyzed_sha)
20
+ return none unless commit
21
+
22
+ where(commit: commit)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class Manifest < ActiveRecord::Base
7
+ has_many :dependency_changes, dependent: :destroy
8
+ has_many :dependency_snapshots, dependent: :destroy
9
+
10
+ validates :path, presence: true
11
+
12
+ def self.find_or_create(path:, ecosystem:, kind:)
13
+ find_or_create_by(path: path) do |m|
14
+ m.ecosystem = ecosystem
15
+ m.kind = kind
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end