git-pkgs 0.6.2 → 0.8.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +28 -0
  3. data/.ruby-version +1 -0
  4. data/CHANGELOG.md +25 -0
  5. data/Dockerfile +18 -0
  6. data/Formula/git-pkgs.rb +28 -0
  7. data/README.md +90 -6
  8. data/lib/git/pkgs/analyzer.rb +142 -10
  9. data/lib/git/pkgs/cli.rb +20 -8
  10. data/lib/git/pkgs/commands/blame.rb +0 -18
  11. data/lib/git/pkgs/commands/diff.rb +122 -5
  12. data/lib/git/pkgs/commands/diff_driver.rb +30 -4
  13. data/lib/git/pkgs/commands/init.rb +5 -0
  14. data/lib/git/pkgs/commands/licenses.rb +378 -0
  15. data/lib/git/pkgs/commands/list.rb +60 -15
  16. data/lib/git/pkgs/commands/outdated.rb +312 -0
  17. data/lib/git/pkgs/commands/show.rb +126 -3
  18. data/lib/git/pkgs/commands/stale.rb +6 -2
  19. data/lib/git/pkgs/commands/update.rb +3 -0
  20. data/lib/git/pkgs/commands/vulns/base.rb +358 -0
  21. data/lib/git/pkgs/commands/vulns/blame.rb +276 -0
  22. data/lib/git/pkgs/commands/vulns/diff.rb +173 -0
  23. data/lib/git/pkgs/commands/vulns/exposure.rb +418 -0
  24. data/lib/git/pkgs/commands/vulns/history.rb +345 -0
  25. data/lib/git/pkgs/commands/vulns/log.rb +218 -0
  26. data/lib/git/pkgs/commands/vulns/praise.rb +238 -0
  27. data/lib/git/pkgs/commands/vulns/scan.rb +231 -0
  28. data/lib/git/pkgs/commands/vulns/show.rb +216 -0
  29. data/lib/git/pkgs/commands/vulns/sync.rb +110 -0
  30. data/lib/git/pkgs/commands/vulns.rb +50 -0
  31. data/lib/git/pkgs/config.rb +8 -1
  32. data/lib/git/pkgs/database.rb +151 -5
  33. data/lib/git/pkgs/ecosystems.rb +83 -0
  34. data/lib/git/pkgs/ecosystems_client.rb +96 -0
  35. data/lib/git/pkgs/models/dependency_change.rb +8 -0
  36. data/lib/git/pkgs/models/dependency_snapshot.rb +8 -0
  37. data/lib/git/pkgs/models/package.rb +92 -0
  38. data/lib/git/pkgs/models/version.rb +27 -0
  39. data/lib/git/pkgs/models/vulnerability.rb +300 -0
  40. data/lib/git/pkgs/models/vulnerability_package.rb +59 -0
  41. data/lib/git/pkgs/osv_client.rb +151 -0
  42. data/lib/git/pkgs/output.rb +22 -0
  43. data/lib/git/pkgs/purl_helper.rb +56 -0
  44. data/lib/git/pkgs/spinner.rb +46 -0
  45. data/lib/git/pkgs/version.rb +1 -1
  46. data/lib/git/pkgs.rb +12 -0
  47. metadata +72 -4
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ module Models
6
+ class Version < Sequel::Model
7
+ many_to_one :package, key: :package_purl, primary_key: :purl
8
+
9
+ def parsed_purl
10
+ @parsed_purl ||= Purl.parse(purl)
11
+ end
12
+
13
+ def version_string
14
+ parsed_purl.version
15
+ end
16
+
17
+ def registry_url
18
+ parsed_purl.registry_url
19
+ end
20
+
21
+ def enriched?
22
+ !enriched_at.nil?
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,300 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Models
8
+ class Vulnerability < Sequel::Model
9
+ one_to_many :vulnerability_packages, key: :vulnerability_id
10
+
11
+ dataset_module do
12
+ def by_severity(severity)
13
+ where(severity: severity)
14
+ end
15
+
16
+ def critical
17
+ by_severity("critical")
18
+ end
19
+
20
+ def high
21
+ by_severity("high")
22
+ end
23
+
24
+ def medium
25
+ by_severity("medium")
26
+ end
27
+
28
+ def low
29
+ by_severity("low")
30
+ end
31
+
32
+ def not_withdrawn
33
+ where(withdrawn_at: nil)
34
+ end
35
+
36
+ def stale(max_age_seconds = 86400)
37
+ threshold = Time.now - max_age_seconds
38
+ where { fetched_at < threshold }
39
+ end
40
+
41
+ def fresh(max_age_seconds = 86400)
42
+ threshold = Time.now - max_age_seconds
43
+ where { fetched_at >= threshold }
44
+ end
45
+ end
46
+
47
+ def severity_level
48
+ case severity&.downcase
49
+ when "critical" then 4
50
+ when "high" then 3
51
+ when "medium" then 2
52
+ when "low" then 1
53
+ else 0
54
+ end
55
+ end
56
+
57
+ def severity_display
58
+ severity&.upcase || "UNKNOWN"
59
+ end
60
+
61
+ def withdrawn?
62
+ !withdrawn_at.nil?
63
+ end
64
+
65
+ def aliases_list
66
+ return [] if aliases.nil? || aliases.empty?
67
+
68
+ aliases.split(",").map(&:strip)
69
+ end
70
+
71
+ # Create or update from OSV API response data.
72
+ # Creates both the Vulnerability record and VulnerabilityPackage records
73
+ # for each affected package.
74
+ def self.from_osv(osv_data)
75
+ vuln_id = osv_data["id"]
76
+ severity_info = extract_severity(osv_data)
77
+
78
+ vuln = update_or_create(
79
+ { id: vuln_id },
80
+ {
81
+ aliases: extract_aliases(osv_data),
82
+ severity: severity_info[:severity],
83
+ cvss_score: severity_info[:score],
84
+ cvss_vector: severity_info[:vector],
85
+ summary: osv_data["summary"],
86
+ details: osv_data["details"],
87
+ published_at: parse_timestamp(osv_data["published"]),
88
+ modified_at: parse_timestamp(osv_data["modified"]),
89
+ withdrawn_at: parse_timestamp(osv_data["withdrawn"]),
90
+ fetched_at: Time.now
91
+ }
92
+ )
93
+
94
+ # Create VulnerabilityPackage records for each affected package
95
+ (osv_data["affected"] || []).each do |affected|
96
+ pkg = affected["package"]
97
+ next unless pkg
98
+
99
+ ecosystem = pkg["ecosystem"]
100
+ name = pkg["name"]
101
+
102
+ affected_range = build_affected_range(affected)
103
+ fixed = extract_fixed_versions(affected)
104
+
105
+ VulnerabilityPackage.update_or_create(
106
+ { vulnerability_id: vuln_id, ecosystem: ecosystem, package_name: name },
107
+ {
108
+ affected_versions: affected_range,
109
+ fixed_versions: fixed&.join(",")
110
+ }
111
+ )
112
+ end
113
+
114
+ vuln
115
+ end
116
+
117
+ def self.extract_aliases(osv_data)
118
+ aliases = osv_data["aliases"] || []
119
+ aliases.any? ? aliases.join(",") : nil
120
+ end
121
+
122
+ def self.extract_severity(osv_data)
123
+ result = { severity: nil, score: nil, vector: nil }
124
+
125
+ if osv_data["severity"]&.any?
126
+ sev = osv_data["severity"].first
127
+ result[:vector] = sev["score"]
128
+
129
+ if sev["score"]&.include?("CVSS")
130
+ result[:score] = parse_cvss_score(sev["score"])
131
+ result[:severity] = score_to_severity(result[:score])
132
+ end
133
+ end
134
+
135
+ # Check root-level database_specific (GHSA format)
136
+ if osv_data["database_specific"]&.dig("severity")
137
+ result[:severity] ||= normalize_severity(osv_data["database_specific"]["severity"])
138
+ end
139
+
140
+ # Check affected entries for database_specific severity
141
+ osv_data["affected"]&.each do |affected|
142
+ db_specific = affected["database_specific"]
143
+ if db_specific && db_specific["severity"]
144
+ result[:severity] ||= normalize_severity(db_specific["severity"])
145
+ end
146
+ end
147
+
148
+ result
149
+ end
150
+
151
+ def self.normalize_severity(severity)
152
+ return nil unless severity
153
+
154
+ case severity.downcase
155
+ when "critical" then "critical"
156
+ when "high" then "high"
157
+ when "moderate", "medium" then "medium"
158
+ when "low" then "low"
159
+ end
160
+ end
161
+
162
+ def self.parse_cvss_score(vector)
163
+ return nil unless vector
164
+
165
+ if vector.match?(/\A\d+(\.\d+)?\z/)
166
+ return vector.to_f
167
+ end
168
+
169
+ return nil unless vector.include?("CVSS:")
170
+
171
+ metrics = parse_cvss_metrics(vector)
172
+ return nil if metrics.empty?
173
+
174
+ estimate_cvss_score(metrics)
175
+ end
176
+
177
+ def self.parse_cvss_metrics(vector)
178
+ metrics = {}
179
+ vector.split("/").each do |part|
180
+ key, value = part.split(":")
181
+ metrics[key] = value if key && value
182
+ end
183
+ metrics
184
+ end
185
+
186
+ def self.estimate_cvss_score(metrics)
187
+ impact_values = { "N" => 0, "L" => 1, "H" => 2 }
188
+ c = impact_values[metrics["C"]] || 0
189
+ i = impact_values[metrics["I"]] || 0
190
+ a = impact_values[metrics["A"]] || 0
191
+ max_impact = [c, i, a].max
192
+
193
+ ac_easy = metrics["AC"] == "L"
194
+ av_network = metrics["AV"] == "N"
195
+ pr_none = metrics["PR"] == "N"
196
+ ui_none = metrics["UI"] == "N"
197
+
198
+ if max_impact == 2 && av_network && ac_easy && pr_none && ui_none
199
+ 9.8
200
+ elsif max_impact == 2 && av_network && ac_easy
201
+ 8.1
202
+ elsif max_impact == 2
203
+ 7.0
204
+ elsif max_impact == 1 && av_network
205
+ 5.3
206
+ elsif max_impact == 1
207
+ 4.0
208
+ elsif max_impact == 0
209
+ 0.0
210
+ else
211
+ 5.0
212
+ end
213
+ end
214
+
215
+ def self.score_to_severity(score)
216
+ return nil unless score
217
+
218
+ case score
219
+ when 9.0..10.0 then "critical"
220
+ when 7.0...9.0 then "high"
221
+ when 4.0...7.0 then "medium"
222
+ when 0.0...4.0 then "low"
223
+ end
224
+ end
225
+
226
+ def self.build_affected_range(affected)
227
+ return nil unless affected
228
+
229
+ ranges = affected["ranges"] || []
230
+ versions = affected["versions"] || []
231
+
232
+ return versions.join(",") if versions.any? && ranges.empty?
233
+
234
+ range_parts = ranges.flat_map do |range|
235
+ events = range["events"] || []
236
+ build_range_from_events(events)
237
+ end
238
+
239
+ range_parts.compact.join(" || ")
240
+ end
241
+
242
+ def self.build_range_from_events(events)
243
+ ranges = []
244
+ current_introduced = nil
245
+
246
+ events.each do |event|
247
+ if event["introduced"]
248
+ current_introduced = event["introduced"]
249
+ elsif event["fixed"] && current_introduced
250
+ if current_introduced == "0"
251
+ ranges << "<#{event["fixed"]}"
252
+ else
253
+ ranges << ">=#{current_introduced} <#{event["fixed"]}"
254
+ end
255
+ current_introduced = nil
256
+ elsif event["last_affected"] && current_introduced
257
+ if current_introduced == "0"
258
+ ranges << "<=#{event["last_affected"]}"
259
+ else
260
+ ranges << ">=#{current_introduced} <=#{event["last_affected"]}"
261
+ end
262
+ current_introduced = nil
263
+ end
264
+ end
265
+
266
+ if current_introduced
267
+ ranges << if current_introduced == "0"
268
+ ">=0"
269
+ else
270
+ ">=#{current_introduced}"
271
+ end
272
+ end
273
+
274
+ ranges
275
+ end
276
+
277
+ def self.extract_fixed_versions(affected)
278
+ return nil unless affected
279
+
280
+ fixed = []
281
+ (affected["ranges"] || []).each do |range|
282
+ (range["events"] || []).each do |event|
283
+ fixed << event["fixed"] if event["fixed"]
284
+ end
285
+ end
286
+
287
+ fixed.uniq.empty? ? nil : fixed.uniq
288
+ end
289
+
290
+ def self.parse_timestamp(str)
291
+ return nil unless str
292
+
293
+ Time.parse(str)
294
+ rescue ArgumentError
295
+ nil
296
+ end
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "vers"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module Models
8
+ class VulnerabilityPackage < Sequel::Model
9
+ many_to_one :vulnerability, key: :vulnerability_id
10
+
11
+ dataset_module do
12
+ def for_package(ecosystem, name)
13
+ where(ecosystem: ecosystem, package_name: name)
14
+ end
15
+ end
16
+
17
+ def affects_version?(version)
18
+ return false if affected_versions.nil? || affected_versions.empty?
19
+ return false if version.nil? || version.empty?
20
+
21
+ # Convert OSV ecosystem to purl type for Vers
22
+ bib_ecosystem = Ecosystems.from_osv(ecosystem) || ecosystem.downcase
23
+ purl_type = Ecosystems.to_purl(bib_ecosystem) || bib_ecosystem
24
+
25
+ # Handle || separator (OR conditions between different ranges)
26
+ # Each part separated by || is an independent range (OR)
27
+ # Within each part, space-separated constraints are AND conditions
28
+ affected_versions.split(" || ").any? do |range_part|
29
+ range_matches?(version, range_part, purl_type)
30
+ end
31
+ rescue ArgumentError, Vers::Error
32
+ # If we can't parse the version or range, be conservative and assume affected
33
+ true
34
+ end
35
+
36
+ def range_matches?(version, range_part, purl_type)
37
+ # Extract individual constraints (e.g., ">=7.1.0 <7.1.3.1" -> [">=7.1.0", "<7.1.3.1"])
38
+ constraints = range_part.scan(/[<>=!~^]+[^\s]+/)
39
+ return false if constraints.empty?
40
+
41
+ # All constraints must be satisfied (AND logic)
42
+ constraints.all? do |constraint|
43
+ Vers.satisfies?(version, constraint, purl_type)
44
+ end
45
+ end
46
+
47
+ def fixed_versions_list
48
+ return [] if fixed_versions.nil? || fixed_versions.empty?
49
+
50
+ fixed_versions.split(",").map(&:strip)
51
+ end
52
+
53
+ def purl
54
+ Models::Package.generate_purl(ecosystem, package_name)
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Git
8
+ module Pkgs
9
+ # Client for the OSV (Open Source Vulnerabilities) API.
10
+ # https://google.github.io/osv.dev/api/
11
+ class OsvClient
12
+ API_BASE = "https://api.osv.dev/v1"
13
+ BATCH_SIZE = 1000 # Max queries per batch request
14
+
15
+ class Error < StandardError; end
16
+ class ApiError < Error; end
17
+
18
+ def initialize
19
+ @http_clients = {}
20
+ end
21
+
22
+ # Query vulnerabilities for a single package version.
23
+ #
24
+ # @param ecosystem [String] OSV ecosystem name (e.g., "RubyGems")
25
+ # @param name [String] package name
26
+ # @param version [String] package version
27
+ # @return [Array<Hash>] array of vulnerability hashes
28
+ def query(ecosystem:, name:, version:)
29
+ payload = {
30
+ package: {
31
+ name: name,
32
+ ecosystem: ecosystem
33
+ },
34
+ version: version
35
+ }
36
+
37
+ response = post("/query", payload)
38
+ fetch_all_pages(response, payload)
39
+ end
40
+
41
+ # Batch query vulnerabilities for multiple packages.
42
+ # More efficient than individual queries for large dependency sets.
43
+ #
44
+ # @param packages [Array<Hash>] array of {ecosystem:, name:, version:} hashes
45
+ # @return [Array<Array<Hash>>] array of vulnerability arrays, one per input package
46
+ def query_batch(packages)
47
+ return [] if packages.empty?
48
+
49
+ results = Array.new(packages.size) { [] }
50
+
51
+ packages.each_slice(BATCH_SIZE).with_index do |batch, batch_idx|
52
+ queries = batch.map do |pkg|
53
+ {
54
+ package: {
55
+ name: pkg[:name],
56
+ ecosystem: pkg[:ecosystem]
57
+ },
58
+ version: pkg[:version]
59
+ }
60
+ end
61
+
62
+ response = post("/querybatch", { queries: queries })
63
+ batch_results = response["results"] || []
64
+
65
+ batch_results.each_with_index do |result, idx|
66
+ global_idx = batch_idx * BATCH_SIZE + idx
67
+ results[global_idx] = result["vulns"] || []
68
+ end
69
+ end
70
+
71
+ results
72
+ end
73
+
74
+ # Fetch full details for a specific vulnerability by ID.
75
+ #
76
+ # @param vuln_id [String] vulnerability ID (e.g., "CVE-2024-1234", "GHSA-xxxx")
77
+ # @return [Hash] full vulnerability data
78
+ def get_vulnerability(vuln_id)
79
+ get("/vulns/#{URI.encode_uri_component(vuln_id)}")
80
+ end
81
+
82
+ private
83
+
84
+ def post(path, payload)
85
+ uri = URI("#{API_BASE}#{path}")
86
+ request = Net::HTTP::Post.new(uri)
87
+ request["Content-Type"] = "application/json"
88
+ request.body = JSON.generate(payload)
89
+
90
+ execute_request(uri, request)
91
+ end
92
+
93
+ def get(path)
94
+ uri = URI("#{API_BASE}#{path}")
95
+ request = Net::HTTP::Get.new(uri)
96
+ request["Content-Type"] = "application/json"
97
+
98
+ execute_request(uri, request)
99
+ end
100
+
101
+ def execute_request(uri, request)
102
+ http = http_client(uri)
103
+ response = http.request(request)
104
+
105
+ case response
106
+ when Net::HTTPSuccess
107
+ JSON.parse(response.body)
108
+ else
109
+ raise ApiError, "OSV API error: #{response.code} #{response.message}"
110
+ end
111
+ rescue JSON::ParserError => e
112
+ raise ApiError, "Invalid JSON response from OSV API: #{e.message}"
113
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
114
+ raise ApiError, "OSV API timeout: #{e.message}"
115
+ rescue SocketError, Errno::ECONNREFUSED => e
116
+ raise ApiError, "OSV API connection error: #{e.message}"
117
+ rescue OpenSSL::SSL::SSLError => e
118
+ raise ApiError, "OSV API SSL error: #{e.message}"
119
+ end
120
+
121
+ def http_client(uri)
122
+ key = "#{uri.host}:#{uri.port}"
123
+ @http_clients[key] ||= begin
124
+ http = Net::HTTP.new(uri.host, uri.port)
125
+ http.use_ssl = uri.scheme == "https"
126
+ http.open_timeout = 10
127
+ http.read_timeout = 30
128
+ http
129
+ end
130
+ end
131
+
132
+ MAX_PAGES = 100
133
+
134
+ def fetch_all_pages(response, original_payload)
135
+ vulns = response["vulns"] || []
136
+ page_token = response["next_page_token"]
137
+ pages_fetched = 0
138
+
139
+ while page_token && pages_fetched < MAX_PAGES
140
+ payload = original_payload.merge(page_token: page_token)
141
+ response = post("/query", payload)
142
+ vulns.concat(response["vulns"] || [])
143
+ page_token = response["next_page_token"]
144
+ pages_fetched += 1
145
+ end
146
+
147
+ vulns
148
+ end
149
+ end
150
+ end
151
+ end
@@ -52,6 +52,28 @@ module Git
52
52
 
53
53
  error "Database not initialized. Run 'git pkgs init' first."
54
54
  end
55
+
56
+ # Pick best author from commit, preferring humans over bots
57
+ def best_author(commit)
58
+ author_name = commit.respond_to?(:author_name) ? commit.author_name : commit[:author_name]
59
+ message = commit.respond_to?(:message) ? commit.message : commit[:message]
60
+
61
+ authors = [author_name] + parse_coauthors(message)
62
+
63
+ # Prefer human authors over bots
64
+ human = authors.find { |a| !bot_author?(a) }
65
+ human || authors.first
66
+ end
67
+
68
+ def parse_coauthors(message)
69
+ return [] unless message
70
+
71
+ message.scan(/^Co-authored-by:([^<]+)<[^>]+>/i).flatten.map(&:strip)
72
+ end
73
+
74
+ def bot_author?(name)
75
+ name =~ /\[bot\]$|^dependabot|^renovate|^github-actions/i
76
+ end
55
77
  end
56
78
  end
57
79
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "purl"
4
+
5
+ module Git
6
+ module Pkgs
7
+ module PurlHelper
8
+ # Mapping from Bibliothecary/ecosyste.ms ecosystem names to PURL types
9
+ # Source: https://packages.ecosyste.ms/api/v1/registries/
10
+ ECOSYSTEM_TO_PURL_TYPE = {
11
+ "npm" => "npm",
12
+ "go" => "golang",
13
+ "docker" => "docker",
14
+ "pypi" => "pypi",
15
+ "nuget" => "nuget",
16
+ "maven" => "maven",
17
+ "packagist" => "composer",
18
+ "cargo" => "cargo",
19
+ "rubygems" => "gem",
20
+ "cocoapods" => "cocoapods",
21
+ "pub" => "pub",
22
+ "bower" => "bower",
23
+ "cpan" => "cpan",
24
+ "alpine" => "alpine",
25
+ "actions" => "githubactions",
26
+ "cran" => "cran",
27
+ "clojars" => "clojars",
28
+ "conda" => "conda",
29
+ "hex" => "hex",
30
+ "hackage" => "hackage",
31
+ "julia" => "julia",
32
+ "swiftpm" => "swift",
33
+ "openvsx" => "openvsx",
34
+ "spack" => "spack",
35
+ "homebrew" => "brew",
36
+ "puppet" => "puppet",
37
+ "deno" => "deno",
38
+ "elm" => "elm",
39
+ "vcpkg" => "vcpkg",
40
+ "racket" => "racket",
41
+ "bioconductor" => "bioconductor",
42
+ "carthage" => "carthage",
43
+ "elpa" => "melpa"
44
+ }.freeze
45
+
46
+ def self.purl_type_for(ecosystem)
47
+ ECOSYSTEM_TO_PURL_TYPE.fetch(ecosystem, ecosystem)
48
+ end
49
+
50
+ def self.build_purl(ecosystem:, name:, version: nil)
51
+ type = purl_type_for(ecosystem)
52
+ Purl::PackageURL.new(type: type, name: name, version: version)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Git
4
+ module Pkgs
5
+ class Spinner
6
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
7
+ INTERVAL = 0.08
8
+
9
+ def initialize(message)
10
+ @message = message
11
+ @running = false
12
+ @thread = nil
13
+ end
14
+
15
+ def start
16
+ return unless $stdout.tty? && !Git::Pkgs.quiet
17
+
18
+ @running = true
19
+ @frame_index = 0
20
+ @thread = Thread.new do
21
+ while @running
22
+ print "\r#{FRAMES[@frame_index]} #{@message}"
23
+ @frame_index = (@frame_index + 1) % FRAMES.length
24
+ sleep INTERVAL
25
+ end
26
+ end
27
+ end
28
+
29
+ def stop
30
+ return unless @thread
31
+
32
+ @running = false
33
+ @thread.join
34
+ print "\r#{" " * (@message.length + 3)}\r"
35
+ end
36
+
37
+ def self.with_spinner(message)
38
+ spinner = new(message)
39
+ spinner.start
40
+ yield
41
+ ensure
42
+ spinner.stop
43
+ end
44
+ end
45
+ end
46
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Git
4
4
  module Pkgs
5
- VERSION = "0.6.2"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end