git-pkgs 0.2.0 → 0.3.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.
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module Git
4
6
  module Pkgs
5
7
  module Commands
6
8
  class Stats
9
+ include Output
10
+
7
11
  def initialize(args)
8
12
  @args = args
9
13
  @options = parse_options
@@ -11,11 +15,7 @@ module Git
11
15
 
12
16
  def run
13
17
  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
18
+ require_database(repo)
19
19
 
20
20
  Database.connect(repo.git_dir)
21
21
 
@@ -31,19 +31,27 @@ module Git
31
31
  require "json"
32
32
  puts JSON.pretty_generate(data)
33
33
  else
34
- output_text(data)
34
+ paginate { output_text(data) }
35
35
  end
36
36
  end
37
37
  end
38
38
 
39
39
  def collect_stats(branch, branch_name)
40
40
  ecosystem = @options[:ecosystem]
41
+ since_time = @options[:since] ? parse_time(@options[:since]) : nil
42
+ until_time = @options[:until] ? parse_time(@options[:until]) : nil
43
+
44
+ commits = branch&.commits || Models::Commit.none
45
+ commits = commits.where("committed_at >= ?", since_time) if since_time
46
+ commits = commits.where("committed_at <= ?", until_time) if until_time
41
47
 
42
48
  data = {
43
49
  branch: branch_name,
44
50
  ecosystem: ecosystem,
45
- commits_analyzed: branch&.commits&.count || 0,
46
- commits_with_changes: branch&.commits&.where(has_dependency_changes: true)&.count || 0,
51
+ since: @options[:since],
52
+ until: @options[:until],
53
+ commits_analyzed: commits.count,
54
+ commits_with_changes: commits.where(has_dependency_changes: true).count,
47
55
  current_dependencies: {},
48
56
  changes: {},
49
57
  most_changed: [],
@@ -62,8 +70,10 @@ module Git
62
70
  }
63
71
  end
64
72
 
65
- changes = Models::DependencyChange.all
73
+ changes = Models::DependencyChange.joins(:commit)
66
74
  changes = changes.where(ecosystem: ecosystem) if ecosystem
75
+ changes = changes.where("commits.committed_at >= ?", since_time) if since_time
76
+ changes = changes.where("commits.committed_at <= ?", until_time) if until_time
67
77
 
68
78
  data[:changes] = {
69
79
  total: changes.count,
@@ -84,7 +94,10 @@ module Git
84
94
  manifests = manifests.where(ecosystem: ecosystem) if ecosystem
85
95
 
86
96
  data[:manifests] = manifests.map do |manifest|
87
- { path: manifest.path, ecosystem: manifest.ecosystem, changes: manifest.dependency_changes.count }
97
+ manifest_changes = manifest.dependency_changes.joins(:commit)
98
+ manifest_changes = manifest_changes.where("commits.committed_at >= ?", since_time) if since_time
99
+ manifest_changes = manifest_changes.where("commits.committed_at <= ?", until_time) if until_time
100
+ { path: manifest.path, ecosystem: manifest.ecosystem, changes: manifest_changes.count }
88
101
  end
89
102
 
90
103
  data
@@ -97,6 +110,8 @@ module Git
97
110
 
98
111
  puts "Branch: #{data[:branch]}"
99
112
  puts "Ecosystem: #{data[:ecosystem]}" if data[:ecosystem]
113
+ puts "Since: #{data[:since]}" if data[:since]
114
+ puts "Until: #{data[:until]}" if data[:until]
100
115
  puts "Commits analyzed: #{data[:commits_analyzed]}"
101
116
  puts "Commits with changes: #{data[:commits_with_changes]}"
102
117
  puts
@@ -144,11 +159,16 @@ module Git
144
159
  end
145
160
 
146
161
  def output_by_author
162
+ since_time = @options[:since] ? parse_time(@options[:since]) : nil
163
+ until_time = @options[:until] ? parse_time(@options[:until]) : nil
164
+
147
165
  changes = Models::DependencyChange
148
166
  .joins(:commit)
149
167
  .where(change_type: "added")
150
168
 
151
169
  changes = changes.where(ecosystem: @options[:ecosystem]) if @options[:ecosystem]
170
+ changes = changes.where("commits.committed_at >= ?", since_time) if since_time
171
+ changes = changes.where("commits.committed_at <= ?", until_time) if until_time
152
172
 
153
173
  counts = changes
154
174
  .group("commits.author_name")
@@ -157,7 +177,7 @@ module Git
157
177
  .count
158
178
 
159
179
  if counts.empty?
160
- puts "No dependency additions found"
180
+ empty_result "No dependency additions found"
161
181
  return
162
182
  end
163
183
 
@@ -166,15 +186,25 @@ module Git
166
186
  data = counts.map { |name, count| { author: name, added: count } }
167
187
  puts JSON.pretty_generate(data)
168
188
  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
189
+ paginate { output_by_author_text(counts) }
190
+ end
191
+ end
192
+
193
+ def output_by_author_text(counts)
194
+ puts "Dependencies Added by Author"
195
+ puts "=" * 40
196
+ puts
197
+ counts.each do |name, count|
198
+ puts " #{count.to_s.rjust(4)} #{name}"
175
199
  end
176
200
  end
177
201
 
202
+ def parse_time(str)
203
+ Time.parse(str)
204
+ rescue ArgumentError
205
+ error "Invalid date format: #{str}"
206
+ end
207
+
178
208
  def parse_options
179
209
  options = {}
180
210
 
@@ -193,6 +223,14 @@ module Git
193
223
  options[:format] = v
194
224
  end
195
225
 
226
+ opts.on("--since=DATE", "Show changes after date") do |v|
227
+ options[:since] = v
228
+ end
229
+
230
+ opts.on("--until=DATE", "Show changes before date") do |v|
231
+ options[:until] = v
232
+ end
233
+
196
234
  opts.on("--by-author", "Show dependencies added by author") do
197
235
  options[:by_author] = true
198
236
  end
@@ -201,6 +239,10 @@ module Git
201
239
  options[:limit] = v
202
240
  end
203
241
 
242
+ opts.on("--no-pager", "Do not pipe output into a pager") do
243
+ options[:no_pager] = true
244
+ end
245
+
204
246
  opts.on("-h", "--help", "Show this help") do
205
247
  puts opts
206
248
  exit
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class Tree
7
+ include Output
8
+
7
9
  def initialize(args)
8
10
  @args = args
9
11
  @options = parse_options
@@ -11,11 +13,7 @@ module Git
11
13
 
12
14
  def run
13
15
  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
16
+ require_database(repo)
19
17
 
20
18
  Database.connect(repo.git_dir)
21
19
 
@@ -23,10 +21,7 @@ module Git
23
21
  commit_sha = @options[:commit] || repo.head_sha
24
22
  commit = find_commit_with_snapshot(commit_sha, repo)
25
23
 
26
- unless commit
27
- $stderr.puts "No dependency data found for commit #{commit_sha[0, 7]}"
28
- exit 1
29
- end
24
+ error "No dependency data found for commit #{commit_sha[0, 7]}" unless commit
30
25
 
31
26
  # Get current snapshots
32
27
  snapshots = commit.dependency_snapshots.includes(:manifest)
@@ -36,13 +31,17 @@ module Git
36
31
  end
37
32
 
38
33
  if snapshots.empty?
39
- puts "No dependencies found"
34
+ empty_result "No dependencies found"
40
35
  return
41
36
  end
42
37
 
43
38
  # Group by manifest and build tree
44
39
  grouped = snapshots.group_by { |s| s.manifest }
45
40
 
41
+ paginate { output_text(grouped, snapshots) }
42
+ end
43
+
44
+ def output_text(grouped, snapshots)
46
45
  grouped.each do |manifest, deps|
47
46
  puts "#{manifest.path} (#{manifest.ecosystem})"
48
47
  puts
@@ -109,6 +108,10 @@ module Git
109
108
  options[:branch] = v
110
109
  end
111
110
 
111
+ opts.on("--no-pager", "Do not pipe output into a pager") do
112
+ options[:no_pager] = true
113
+ end
114
+
112
115
  opts.on("-h", "--help", "Show this help") do
113
116
  puts opts
114
117
  exit
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class Update
7
+ include Output
8
+
7
9
  def initialize(args)
8
10
  @args = args
9
11
  @options = parse_options
@@ -11,21 +13,14 @@ module Git
11
13
 
12
14
  def run
13
15
  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
16
+ require_database(repo)
19
17
 
20
18
  Database.connect(repo.git_dir)
21
19
 
22
20
  branch_name = @options[:branch] || repo.default_branch
23
21
  branch = Models::Branch.find_by(name: branch_name)
24
22
 
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
23
+ error "Branch '#{branch_name}' not in database. Run 'git pkgs init --branch=#{branch_name}' first." unless branch
29
24
 
30
25
  since_sha = branch.last_analyzed_sha
31
26
  current_sha = repo.branch_target(branch_name)
@@ -62,60 +57,62 @@ module Git
62
57
  puts "Updating branch: #{branch_name}"
63
58
  puts "Found #{total} new commits"
64
59
 
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
60
+ ActiveRecord::Base.transaction do
61
+ commits.each do |rugged_commit|
62
+ processed += 1
63
+ print "\rProcessing commit #{processed}/#{total}..."
64
+
65
+ result = analyzer.analyze_commit(rugged_commit, snapshot)
66
+
67
+ commit = Models::Commit.find_or_create_from_rugged(rugged_commit)
68
+ Models::BranchCommit.find_or_create_by(
69
+ branch: branch,
70
+ commit: commit,
71
+ position: last_position + processed
72
+ )
73
+
74
+ if result && result[:changes].any?
75
+ dependency_commits += 1
76
+ commit.update(has_dependency_changes: true)
77
+
78
+ result[:changes].each do |change|
79
+ manifest = Models::Manifest.find_or_create(
80
+ path: change[:manifest_path],
81
+ ecosystem: change[:ecosystem],
82
+ kind: change[:kind]
83
+ )
84
+
85
+ Models::DependencyChange.create!(
86
+ commit: commit,
87
+ manifest: manifest,
88
+ name: change[:name],
89
+ ecosystem: change[:ecosystem],
90
+ change_type: change[:change_type],
91
+ requirement: change[:requirement],
92
+ previous_requirement: change[:previous_requirement],
93
+ dependency_type: change[:dependency_type]
94
+ )
95
+ end
100
96
 
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]
97
+ snapshot = result[:snapshot]
98
+
99
+ snapshot.each do |(manifest_path, name), dep_info|
100
+ manifest = Models::Manifest.find_by(path: manifest_path)
101
+ Models::DependencySnapshot.find_or_create_by(
102
+ commit: commit,
103
+ manifest: manifest,
104
+ name: name
105
+ ) do |s|
106
+ s.ecosystem = dep_info[:ecosystem]
107
+ s.requirement = dep_info[:requirement]
108
+ s.dependency_type = dep_info[:dependency_type]
109
+ end
113
110
  end
114
111
  end
115
112
  end
116
- end
117
113
 
118
- branch.update(last_analyzed_sha: current_sha)
114
+ branch.update(last_analyzed_sha: current_sha)
115
+ end
119
116
 
120
117
  puts "\nDone!"
121
118
  puts "Processed #{total} new commits"
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Commands
6
+ class Upgrade
7
+ include Output
8
+
9
+ def initialize(args)
10
+ @args = args
11
+ @options = parse_options
12
+ end
13
+
14
+ def run
15
+ repo = Repository.new
16
+ require_database(repo)
17
+
18
+ Database.connect(repo.git_dir, check_version: false)
19
+
20
+ stored = Database.stored_version || 0
21
+ current = Database::SCHEMA_VERSION
22
+
23
+ if stored >= current
24
+ puts "Database is up to date (version #{current})"
25
+ return
26
+ end
27
+
28
+ puts "Upgrading database from version #{stored} to #{current}..."
29
+ puts "This requires re-indexing the repository."
30
+ puts
31
+
32
+ # Run init --force
33
+ Init.new(["--force"]).run
34
+ end
35
+
36
+ def parse_options
37
+ options = {}
38
+
39
+ parser = OptionParser.new do |opts|
40
+ opts.banner = "Usage: git pkgs upgrade [options]"
41
+
42
+ opts.on("-h", "--help", "Show this help") do
43
+ puts opts
44
+ exit
45
+ end
46
+ end
47
+
48
+ parser.parse!(@args)
49
+ options
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -4,6 +4,8 @@ module Git
4
4
  module Pkgs
5
5
  module Commands
6
6
  class Why
7
+ include Output
8
+
7
9
  def initialize(args)
8
10
  @args = args
9
11
  @options = parse_options
@@ -12,17 +14,10 @@ module Git
12
14
  def run
13
15
  package_name = @args.shift
14
16
 
15
- unless package_name
16
- $stderr.puts "Usage: git pkgs why <package>"
17
- exit 1
18
- end
17
+ error "Usage: git pkgs why <package>" unless package_name
19
18
 
20
19
  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
20
+ require_database(repo)
26
21
 
27
22
  Database.connect(repo.git_dir)
28
23
 
@@ -40,7 +35,7 @@ module Git
40
35
  added_change = added_change.first
41
36
 
42
37
  unless added_change
43
- puts "Package '#{package_name}' not found in dependency history"
38
+ empty_result "Package '#{package_name}' not found in dependency history"
44
39
  return
45
40
  end
46
41
 
@@ -7,13 +7,24 @@ module Git
7
7
  module Pkgs
8
8
  class Database
9
9
  DB_FILE = "pkgs.sqlite3"
10
+ SCHEMA_VERSION = 1
10
11
 
11
12
  def self.path(git_dir = nil)
13
+ return ENV["GIT_PKGS_DB"] if ENV["GIT_PKGS_DB"] && !ENV["GIT_PKGS_DB"].empty?
14
+
12
15
  git_dir ||= find_git_dir
13
16
  File.join(git_dir, DB_FILE)
14
17
  end
15
18
 
16
19
  def self.find_git_dir
20
+ # Respect GIT_DIR environment variable
21
+ if ENV["GIT_DIR"] && !ENV["GIT_DIR"].empty?
22
+ git_dir = ENV["GIT_DIR"]
23
+ return git_dir if File.directory?(git_dir)
24
+
25
+ raise NotInGitRepoError, "GIT_DIR '#{git_dir}' does not exist"
26
+ end
27
+
17
28
  dir = Dir.pwd
18
29
  loop do
19
30
  git_dir = File.join(dir, ".git")
@@ -26,12 +37,13 @@ module Git
26
37
  end
27
38
  end
28
39
 
29
- def self.connect(git_dir = nil)
40
+ def self.connect(git_dir = nil, check_version: true)
30
41
  db_path = path(git_dir)
31
42
  ActiveRecord::Base.establish_connection(
32
43
  adapter: "sqlite3",
33
44
  database: db_path
34
45
  )
46
+ check_version! if check_version
35
47
  end
36
48
 
37
49
  def self.connect_memory
@@ -48,6 +60,10 @@ module Git
48
60
 
49
61
  def self.create_schema(with_indexes: true)
50
62
  ActiveRecord::Schema.define do
63
+ create_table :schema_info, if_not_exists: true do |t|
64
+ t.integer :version, null: false
65
+ end
66
+
51
67
  create_table :branches, if_not_exists: true do |t|
52
68
  t.string :name, null: false
53
69
  t.string :last_analyzed_sha
@@ -104,6 +120,7 @@ module Git
104
120
  end
105
121
  end
106
122
 
123
+ set_version
107
124
  create_bulk_indexes if with_indexes
108
125
  end
109
126
 
@@ -122,6 +139,43 @@ module Git
122
139
  conn.add_index :dependency_snapshots, :ecosystem, if_not_exists: true
123
140
  end
124
141
 
142
+ def self.stored_version
143
+ conn = ActiveRecord::Base.connection
144
+ return nil unless conn.table_exists?(:schema_info)
145
+
146
+ result = conn.select_value("SELECT version FROM schema_info LIMIT 1")
147
+ result&.to_i
148
+ end
149
+
150
+ def self.set_version(version = SCHEMA_VERSION)
151
+ conn = ActiveRecord::Base.connection
152
+ conn.execute("DELETE FROM schema_info")
153
+ conn.execute("INSERT INTO schema_info (version) VALUES (#{version})")
154
+ end
155
+
156
+ def self.needs_upgrade?
157
+ conn = ActiveRecord::Base.connection
158
+
159
+ # No tables at all = fresh database, no upgrade needed
160
+ return false unless conn.table_exists?(:commits)
161
+
162
+ # Has commits table but no schema_info = old database, needs upgrade
163
+ return true unless conn.table_exists?(:schema_info)
164
+
165
+ # Check version
166
+ stored = stored_version || 0
167
+ stored < SCHEMA_VERSION
168
+ end
169
+
170
+ def self.check_version!
171
+ return unless needs_upgrade?
172
+
173
+ stored = stored_version || 0
174
+ $stderr.puts "Database schema is outdated (version #{stored}, current is #{SCHEMA_VERSION})."
175
+ $stderr.puts "Run 'git pkgs upgrade' to update."
176
+ exit 1
177
+ end
178
+
125
179
  def self.optimize_for_bulk_writes
126
180
  conn = ActiveRecord::Base.connection
127
181
  conn.execute("PRAGMA synchronous = OFF")
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "pager"
5
+
6
+ module Git
7
+ module Pkgs
8
+ module Output
9
+ include Pager
10
+ # Print error message and exit with code 1.
11
+ # Use for user errors (bad input, invalid refs) and system errors (db missing).
12
+ # When format is :json, outputs JSON to stdout; otherwise outputs text to stderr.
13
+ def error(msg, format: nil)
14
+ format ||= @options[:format] if defined?(@options) && @options.is_a?(Hash)
15
+
16
+ if format == "json"
17
+ puts JSON.generate({ error: msg })
18
+ else
19
+ $stderr.puts msg
20
+ end
21
+ exit 1
22
+ end
23
+
24
+ # Print informational message for "no results" cases.
25
+ # When format is :json, outputs empty JSON array/object; otherwise outputs text.
26
+ def empty_result(msg, format: nil, json_value: [])
27
+ format ||= @options[:format] if defined?(@options) && @options.is_a?(Hash)
28
+
29
+ if format == "json"
30
+ puts JSON.generate(json_value)
31
+ else
32
+ puts msg
33
+ end
34
+ end
35
+
36
+ # Standard check for database existence.
37
+ def require_database(repo)
38
+ return if Database.exists?(repo.git_dir)
39
+
40
+ error "Database not initialized. Run 'git pkgs init' first."
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Pager
8
+ # Returns the pager command following git's precedence:
9
+ # 1. GIT_PAGER environment variable
10
+ # 2. core.pager git config
11
+ # 3. PAGER environment variable
12
+ # 4. less as fallback
13
+ def git_pager
14
+ return ENV["GIT_PAGER"] if ENV["GIT_PAGER"] && !ENV["GIT_PAGER"].empty?
15
+
16
+ config_pager = `git config --get core.pager`.chomp
17
+ return config_pager unless config_pager.empty?
18
+
19
+ return ENV["PAGER"] if ENV["PAGER"] && !ENV["PAGER"].empty?
20
+
21
+ "less -FRSX"
22
+ end
23
+
24
+ # Returns true if paging is disabled (pager set to empty string or 'cat')
25
+ def pager_disabled?
26
+ pager = git_pager
27
+ pager.empty? || pager == "cat"
28
+ end
29
+
30
+ # Captures output from a block and sends it through the pager.
31
+ # Falls back to direct output if:
32
+ # - stdout is not a TTY
33
+ # - pager is disabled
34
+ # - pager command fails
35
+ def paginate
36
+ if !$stdout.tty? || pager_disabled? || paging_disabled?
37
+ yield
38
+ return
39
+ end
40
+
41
+ output = StringIO.new
42
+ old_stdout = $stdout
43
+ $stdout = output
44
+
45
+ begin
46
+ yield
47
+ ensure
48
+ $stdout = old_stdout
49
+ end
50
+
51
+ content = output.string
52
+ return if content.empty?
53
+
54
+ IO.popen(git_pager, "w") { |io| io.write(content) }
55
+ rescue Errno::EPIPE
56
+ # User quit pager early
57
+ rescue Errno::ENOENT
58
+ # Pager command not found, fall back to direct output
59
+ print content
60
+ end
61
+
62
+ # Check if paging was disabled via --no-pager option
63
+ def paging_disabled?
64
+ defined?(@options) && @options.is_a?(Hash) && @options[:no_pager]
65
+ end
66
+ end
67
+ end
68
+ end